diff --git a/CHANGELOG.md b/CHANGELOG.md
index 165ba87..03b1b51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
## [Unreleased]
+## [1.0.1] - 2026-09-20
+
### Fixed
- `ark-wysiwyg-editor`: toolbar buttons no longer take focus from the editor on mouse click, so the selection
@@ -18,6 +20,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
initial positioning (it went back to `start-index`), which let the smooth scroll finish on its own and fire a second
`ark-slide-change`. The initial positioning now starts from the current index, and the pending frame is cancelled
when the element is removed.
+- `ark-toggle-group`: removing `disabled` from the group re-enabled items that were disabled on their own, because
+ the group marked every item as disabled by it. An item already disabled keeps its own `disabled`.
+- `ark-toaster`: every new toast rebuilt the whole stack, so keyboard focus on a toast's button was dropped to the
+ page (contrary to the documented behavior) and a toast leaving the stack lost its exit animation. Rendering is now
+ incremental: existing cards stay, only the new card is created (at the top) and dismissed ones are removed. Changing
+ a host attribute (`position`, `rich-colors`, `close-button`, `lang`, `testid`) still rebuilds the cards.
## [1.0.0] - 2026-09-19
@@ -43,5 +51,6 @@ First public release of the Tooark Web Components family.
(`ark-code-editor` on CodeMirror 6 with completions, formatting, indentation and line-ending options),
`@tooark/motion` (stagger, reveal, FLIP and swipe on the Motion library).
-[Unreleased]: https://github.com/Tooark/web-components/compare/v1.0.0...HEAD
+[Unreleased]: https://github.com/Tooark/web-components/compare/v1.0.1...HEAD
+[1.0.1]: https://github.com/Tooark/web-components/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/Tooark/web-components/releases/tag/v1.0.0
diff --git a/CLAUDE.md b/CLAUDE.md
index cc06b24..31a52ec 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -118,6 +118,15 @@ Each of `packages/{react,vue,angular}/src` has one file per component plus `regi
`apps/storybook/.storybook/main.ts` aliases every `@tooark/*` import to the package `src/index.ts`, so Storybook and tests always run against source. `preview.ts` imports the lib CSS from source and registers web-components, chart, wysiwyg and code. `manager.ts` sets the Tooark brand (`storybook/theming` `create()` without `base`, so the manager keeps following the OS color scheme); `public/tooark-logo.svg` (mark from the tooark.github.io site plus wordmark, colors switched by `prefers-color-scheme` inside the SVG) and `public/favicon.svg` (the site's favicon, picked up automatically because it sits at the `staticDirs` root; Biome wants a `
` in every SVG) live there. `vitest.config.ts` sets a 30 s test timeout and `coverage.allowExternal` so coverage covers `packages/*`.
+Pitfalls when writing `play` functions (each one cost a failing run):
+
+- `storybook/test` instruments `expect`: a function passed as an argument gets wrapped, so `expect(el.formatter).toBe(fn)` fails even when the getter returns `fn`. Compare identity outside the matcher (`expect(el.formatter === fn).toBe(true)`).
+- `userEvent.click` refuses an element with `pointer-events: none` (every disabled control). Dispatch the `click` directly and assert on `dispatchEvent`'s return value (`false` when the handler cancelled it).
+- A demo handler that schedules a `setTimeout` (the toaster's "Loading" button fires a second toast 1.8 s later) leaks into the next story in the file and re-renders whatever it touches. In a `play`, call the service directly instead of the button, or wait the timer out inside the story.
+- `PointerEvent`s made by hand work with `setPointerCapture` as long as `pointerId` is 1 (the mouse is always an active pointer); set `isPrimary: true` yourself, the constructor defaults it to `false`. `timeStamp` cannot be set, so a velocity computed from consecutive events needs a real `setTimeout` between them.
+- A Motion lib enter animation drives `transform` from JS and `opacity` through WAAPI: `style.transform` reads `none` one frame before computed `opacity` reaches `1`. Wait for both in the same `waitFor` (`settled()` in `ark-motion-plus.stories.ts`). On completion the lib writes `opacity: 1` inline, so "no inline opacity" is not a valid "revealed" check.
+- JS-side `prefersReducedMotion()` reads `matchMedia`; stub `window.matchMedia` in a `try/finally` (an own `matches` via `Object.defineProperty` over `Object.create(list)`, the prototype getter has no setter). CSS-side reduced motion is forced by rewriting the `@media` rules' `mediaText`, as `ark-motion.stories.ts` does.
+
### Dependency policy
`pnpm-workspace.yaml` pins security floors via `overrides` (Angular >= 21.2.19, postcss, nanoid), disables build scripts for `esbuild`/`@parcel/watcher` and allows TypeScript 6 for `ng-packagr` 21.2 (`peerDependencyRules.allowedVersions`: the Angular 21.2 compiler accepts `<6.1` and the build passes, only ng-packagr's declared peer lags; drop the rule when Angular moves to 22). The Angular peer range in `packages/angular/package.json` must stay aligned with that floor.
diff --git a/apps/storybook/stories/ark-code-editor.stories.ts b/apps/storybook/stories/ark-code-editor.stories.ts
index 1084ab5..0ee249f 100644
--- a/apps/storybook/stories/ark-code-editor.stories.ts
+++ b/apps/storybook/stories/ark-code-editor.stories.ts
@@ -488,3 +488,136 @@ export const TestHooks = {
expect(root.hasAttribute("data-testid")).toBe(false);
}
};
+
+export const Properties = {
+ args: { language: "json", minHeight: "6rem" },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Cada propriedade JS reflete no atributo correspondente e le de volta com validacao (valor fora da faixa cai no padrao); booleanos seguem a regra dos wrappers ("" e true ligam, false, "false", null e undefined desligam). `variableKeys`, `completions`, `completionSource` e `formatter` tambem valem depois de montado, e `focus()` foca o CodeMirror.'
+ }
+ }
+ },
+ render: (args: StoryArgs) => createEditor(args, "{}"),
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const editor = canvasElement.querySelector("ark-code-editor") as ArkCodeEditor;
+ const content = editor.querySelector(".cm-content")!;
+ const root = editor.view!.dom;
+
+ // Padroes (os argumentos da story deixam placeholder e theme nos valores do meta).
+ expect(editor.indentStyle).toBe("space");
+ expect(editor.indentSize).toBe(2);
+ expect(editor.lineEnding).toBe("auto");
+ expect(editor.tabIndent).toBe(true);
+ expect(editor.autocomplete).toBe(true);
+ expect(editor.language).toBe("json");
+ expect(editor.readonly).toBe(false);
+ expect(editor.wrap).toBe(false);
+ expect(editor.lineNumbers).toBe(true);
+ expect(editor.fold).toBe(true);
+ expect(editor.minHeight).toBe("6rem");
+ expect(editor.theme).toBe("auto");
+
+ // Recuo e fim de linha: setter reflete, getter valida.
+ editor.indentStyle = "tab";
+ await expect(editor).toHaveAttribute("indent-style", "tab");
+ editor.indentSize = 4;
+ await expect(editor).toHaveAttribute("indent-size", "4");
+ expect(editor.indentSize).toBe(4);
+ editor.indentSize = 99;
+ expect(editor.indentSize).toBe(2);
+ editor.lineEnding = "crlf";
+ await expect(editor).toHaveAttribute("line-ending", "crlf");
+ expect(editor.lineEnding).toBe("crlf");
+ editor.setAttribute("line-ending", "cr");
+ expect(editor.lineEnding).toBe("auto");
+
+ // Booleanos com a regra dos wrappers.
+ editor.tabIndent = false;
+ await expect(editor).toHaveAttribute("tab-indent", "false");
+ expect(editor.tabIndent).toBe(false);
+ editor.tabIndent = "";
+ expect(editor.tabIndent).toBe(true);
+ editor.autocomplete = "false";
+ expect(editor.autocomplete).toBe(false);
+ editor.autocomplete = true;
+ await expect(editor).toHaveAttribute("autocomplete", "true");
+ editor.readonly = true;
+ await expect(editor).toHaveAttribute("readonly", "");
+ expect(content.getAttribute("contenteditable")).toBe("false");
+ editor.readonly = null;
+ expect(editor.readonly).toBe(false);
+ expect(content.getAttribute("contenteditable")).toBe("true");
+ editor.wrap = "";
+ expect(editor.wrap).toBe(true);
+ expect(content.classList.contains("cm-lineWrapping")).toBe(true);
+ editor.wrap = "false";
+ expect(editor.wrap).toBe(false);
+ editor.lineNumbers = false;
+ expect(editor.querySelector(".cm-lineNumbers")).toBeNull();
+ editor.lineNumbers = undefined;
+ expect(editor.lineNumbers).toBe(false);
+ editor.lineNumbers = true;
+ expect(editor.querySelector(".cm-lineNumbers")).not.toBeNull();
+ editor.fold = false;
+ await expect(editor).toHaveAttribute("fold", "false");
+ expect(editor.querySelector(".cm-foldGutter")).toBeNull();
+ editor.fold = "";
+ expect(editor.querySelector(".cm-foldGutter")).not.toBeNull();
+
+ // Linguagem, placeholder, altura minima e tema.
+ editor.language = "yaml";
+ await expect(editor).toHaveAttribute("language", "yaml");
+ expect(editor.language).toBe("yaml");
+ editor.setAttribute("language", "rust");
+ expect(editor.language).toBe("text");
+ editor.placeholder = "Vazio";
+ await expect(editor).toHaveAttribute("placeholder", "Vazio");
+ editor.value = "";
+ await expect(editor.querySelector(".cm-placeholder")).toHaveTextContent("Vazio");
+ editor.placeholder = null;
+ expect(editor.hasAttribute("placeholder")).toBe(false);
+ expect(editor.placeholder).toBe("");
+ expect(editor.querySelector(".cm-placeholder")).toBeNull();
+ editor.minHeight = "10rem";
+ await expect(editor).toHaveAttribute("min-height", "10rem");
+ expect(root.style.getPropertyValue("--ark-code-min-height")).toBe("10rem");
+ editor.minHeight = "";
+ expect(editor.hasAttribute("min-height")).toBe(false);
+ expect(editor.minHeight).toBe("8rem");
+ expect(root.style.getPropertyValue("--ark-code-min-height")).toBe("8rem");
+ editor.theme = "dark";
+ await expect(editor).toHaveAttribute("theme", "dark");
+ await waitFor(() => expect(editor.resolvedTheme).toBe("dark"));
+ editor.setAttribute("theme", "sepia");
+ expect(editor.theme).toBe("auto");
+
+ // Propriedades JS depois de montado: entradas invalidas sao filtradas.
+ editor.variableKeys = ["baseUrl", "", 3 as never];
+ expect(editor.variableKeys).toEqual(["baseUrl"]);
+ editor.variableKeys = null;
+ expect(editor.variableKeys).toEqual([]);
+ editor.completions = [{ label: "tooark" }, { detail: "sem label" } as never];
+ expect(editor.completions).toEqual([{ label: "tooark" }]);
+ editor.completions = undefined;
+ expect(editor.completions).toEqual([]);
+ // O instrumentador do Storybook embrulha funcoes passadas ao expect: a identidade e comparada fora dele.
+ const source = (): null => null;
+ editor.completionSource = source;
+ expect(editor.completionSource === source).toBe(true);
+ editor.completionSource = null;
+ expect(editor.completionSource).toBeUndefined();
+ const formatter = (value: string): string => value.trim();
+ editor.formatter = formatter;
+ expect(editor.formatter === formatter).toBe(true);
+ expect(editor.canFormat).toBe(true);
+ editor.formatter = null;
+ expect(editor.formatter).toBeUndefined();
+ expect(editor.canFormat).toBe(false);
+
+ // focus() entrega o foco ao CodeMirror.
+ editor.focus();
+ await waitFor(() => expect(editor.view!.hasFocus).toBe(true));
+ }
+};
diff --git a/apps/storybook/stories/ark-copy-button.stories.ts b/apps/storybook/stories/ark-copy-button.stories.ts
index 826a42c..43252b7 100644
--- a/apps/storybook/stories/ark-copy-button.stories.ts
+++ b/apps/storybook/stories/ark-copy-button.stories.ts
@@ -236,3 +236,110 @@ export const TestHooks = {
await expect(canvasElement.querySelector('[data-ark="button"]')).toBeNull();
}
};
+
+export const PropertiesAndFallbacks = {
+ render: () => {
+ const wrap = row(createCopyButton({ value: "inicial", feedbackMs: 300, lang: "en", variant: "outline" }));
+ const input = document.createElement("input");
+ input.id = "fonte-input";
+ input.value = "valor do input";
+ input.className = "rounded border border-slate-300 px-2 py-1 text-sm";
+ wrap.appendChild(input);
+ wrap.appendChild(createCopyButton({ htmlFor: "fonte-input", feedbackMs: 300, lang: "en", variant: "outline" }));
+ wrap.appendChild(createCopyButton({ htmlFor: "nao-existe", feedbackMs: 300, lang: "en", variant: "outline" }));
+ return wrap;
+ },
+ // Setters refletem nos atributos; `for` le o value de um input e devolve vazio sem alvo; `locale-json` troca os
+ // rotulos; copiar de novo dentro do feedback reinicia o timer; sem Clipboard API cai no execCommand com um
+ // textarea temporario; icon-only escreve o aria-label proprio sem apagar um do usuario; filhos do usuario
+ // (fora comentarios) tiram o rotulo proprio e o icone volta ao inicio do host.
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const [el, fromInput, missing] = Array.from(canvasElement.querySelectorAll("ark-copy-button")) as CopyButtonEl[];
+ const written: string[] = [];
+ let denied = false;
+ const clipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: {
+ writeText: (text: string) => {
+ if (denied) return Promise.reject(new Error("denied"));
+ written.push(text);
+ return Promise.resolve();
+ }
+ }
+ });
+ const execCommand = document.execCommand;
+
+ try {
+ el.value = "novo";
+ await expect(el).toHaveAttribute("value", "novo");
+ expect(el.value).toBe("novo");
+ el.feedbackMs = 200;
+ await expect(el).toHaveAttribute("feedback-ms", "200");
+ expect(el.feedbackMs).toBe(200);
+ el.setAttribute("feedback-ms", "abc");
+ expect(el.feedbackMs).toBe(1500);
+ el.feedbackMs = 200;
+
+ expect(fromInput.value).toBe("valor do input");
+ expect(missing.value).toBe("");
+ missing.removeAttribute("for");
+ expect(missing.value).toBe("");
+
+ // locale-json vale com lang="custom" e e mesclado sobre o en.
+ el.setAttribute("locale-json", JSON.stringify({ copy: "Copiar link", copied: "Link copiado" }));
+ expect(el.textContent?.trim()).toBe("Copy");
+ el.setAttribute("lang", "custom");
+ expect(el.textContent?.trim()).toBe("Copiar link");
+ expect(el.title).toBe("Copiar link");
+
+ // Duas copias dentro do feedback: um so ciclo, timer reiniciado.
+ expect(await el.copy()).toBe(true);
+ expect(await el.copy()).toBe(true);
+ expect(el.textContent?.trim()).toBe("Link copiado");
+ expect(written).toEqual(["novo", "novo"]);
+ await waitFor(() => expect(el).not.toHaveAttribute("data-ark-copied"));
+
+ // Clipboard API negada: fallback com textarea + execCommand, removido depois; erro no execCommand = falha.
+ denied = true;
+ document.execCommand = () => true;
+ expect(await el.copy()).toBe(true);
+ expect(document.querySelector("textarea")).toBeNull();
+ await waitFor(() => expect(el).not.toHaveAttribute("data-ark-copied"));
+ document.execCommand = () => {
+ throw new Error("sem permissao");
+ };
+ expect(await el.copy()).toBe(false);
+ expect(el).not.toHaveAttribute("data-ark-copied");
+
+ // icon-only: aria-label proprio, retirado ao voltar; um aria-label do usuario fica.
+ el.setAttribute("icon-only", "");
+ await expect(el).toHaveAttribute("aria-label", "Copiar link");
+ expect(el.querySelector('[data-ark="copy-button-text"]')).toBeNull();
+ el.removeAttribute("icon-only");
+ expect(el.hasAttribute("aria-label")).toBe(false);
+ expect(el.querySelector('[data-ark="copy-button-text"]')).not.toBeNull();
+ el.setAttribute("aria-label", "Meu rotulo");
+ el.setAttribute("icon-only", "");
+ await expect(el).toHaveAttribute("aria-label", "Meu rotulo");
+ el.removeAttribute("icon-only");
+ await expect(el).toHaveAttribute("aria-label", "Meu rotulo");
+
+ // Comentario nao conta como conteudo do usuario; um elemento inserido antes do icone manda o icone de
+ // volta ao inicio e dispensa o rotulo proprio.
+ el.appendChild(document.createComment("nota"));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(el.querySelector('[data-ark="copy-button-text"]')).not.toBeNull();
+ const badge = document.createElement("b");
+ badge.textContent = "!";
+ el.prepend(badge);
+ await waitFor(() => expect(el.firstElementChild).toHaveAttribute("data-ark", "copy-button-icon"));
+ expect(el.querySelector('[data-ark="copy-button-text"]')).toBeNull();
+ expect(el.contains(badge)).toBe(true);
+ } finally {
+ document.execCommand = execCommand;
+ if (clipboard) Object.defineProperty(navigator, "clipboard", clipboard);
+ else delete (navigator as { clipboard?: unknown }).clipboard;
+ }
+ }
+};
diff --git a/apps/storybook/stories/ark-motion-plus.stories.ts b/apps/storybook/stories/ark-motion-plus.stories.ts
index f150b64..8dc9850 100644
--- a/apps/storybook/stories/ark-motion-plus.stories.ts
+++ b/apps/storybook/stories/ark-motion-plus.stories.ts
@@ -1,5 +1,6 @@
-import type { ArkSwipeDirection } from "@tooark/motion";
+import type { ArkSwipeDirection, ArkSwipeInfo } from "@tooark/motion";
import { arkFlip, arkReveal, arkStaggerEnter, arkSwipe } from "@tooark/motion";
+import { expect, userEvent, waitFor, within } from "storybook/test";
const meta = {
title: "Motion/ArkMotion",
@@ -36,6 +37,50 @@ function createActionButton(label: string): HTMLButtonElement {
return button;
}
+// Cleanup do reveal de cada pagina, para o play parar a observacao no fim.
+const revealStops = new WeakMap void>();
+
+const tick = (ms: number): Promise => new Promise((resolve) => window.setTimeout(resolve, ms));
+const nextFrame = (): Promise => new Promise((resolve) => requestAnimationFrame(resolve));
+
+// Fim de uma entrada: o transform (animado em JS) chega a `none` um frame antes da opacidade (WAAPI) fechar em 1,
+// entao os dois entram na mesma espera.
+const settled = (...elements: HTMLElement[]): Promise =>
+ waitFor(
+ () => {
+ for (const el of elements) {
+ expect(el.style.transform).toBe("none");
+ expect(getComputedStyle(el).opacity).toBe("1");
+ }
+ },
+ { timeout: 3000 }
+ );
+
+// O Chromium dos testes roda sem movimento reduzido: troca o matchMedia enquanto `run` executa, como as stories
+// de ark-motion fazem com as media queries do CSS.
+async function withReducedMotion(run: () => Promise | void): Promise {
+ const original = window.matchMedia;
+ window.matchMedia = (query: string): MediaQueryList => {
+ const list = original.call(window, query);
+ if (!query.includes("prefers-reduced-motion")) return list;
+ // `matches` e um getter do prototipo sem setter: a propriedade propria sombreia sem atribuir.
+ return Object.defineProperty(Object.create(list) as MediaQueryList, "matches", { value: true });
+ };
+ try {
+ await run();
+ } finally {
+ window.matchMedia = original;
+ }
+}
+
+// Evento de ponteiro sintetico: o swipe exige `isPrimary` e o mouse (pointerId 1) e sempre um ponteiro ativo, entao
+// setPointerCapture nao lanca.
+function pointer(target: HTMLElement, type: string, x: number, y: number, init: PointerEventInit = {}): void {
+ target.dispatchEvent(
+ new PointerEvent(type, { pointerId: 1, isPrimary: true, clientX: x, clientY: y, bubbles: true, ...init })
+ );
+}
+
export const Stagger = {
parameters: {
docs: { description: { story: "arkStaggerEnter: entrada escalonada de listas com presets e tokens --ark-*." } }
@@ -62,6 +107,19 @@ export const Stagger = {
});
return container;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const canvas = within(canvasElement);
+ const cards = Array.from(canvasElement.querySelectorAll(".grid > div"));
+ expect(cards).toHaveLength(9);
+
+ // A entrada inicial roda num microtask e termina com todos visiveis e sem transform residual.
+ await settled(...cards);
+
+ // Reexecutar: os itens ainda no intervalo ficam invisiveis (fill both) e tudo termina visivel de novo.
+ await userEvent.click(canvas.getByRole("button", { name: "Reexecutar stagger" }));
+ await waitFor(() => expect(cards.map((card) => getComputedStyle(card).opacity)).toContain("0"));
+ await settled(...cards);
}
};
@@ -90,10 +148,87 @@ export const ScrollReveal = {
}
queueMicrotask(() => {
- arkReveal(blocks, { preset: "slide-up", once: false, amount: 0.35 });
+ revealStops.set(page, arkReveal(blocks, { preset: "slide-up", once: false, amount: 0.35 }));
});
return page;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const page = canvasElement.querySelector(".flex")!;
+ const blocks = Array.from(page.querySelectorAll("div"));
+ const first = blocks[0];
+ const last = blocks[blocks.length - 1];
+
+ // O primeiro bloco ja esta na viewport: revela e anima ate ficar visivel; o ultimo espera escondido.
+ await settled(first);
+ expect(last.style.opacity).toBe("0");
+ expect(last.style.willChange).toBe("opacity, transform");
+
+ last.scrollIntoView({ block: "center" });
+ await settled(last);
+
+ // once: false — sair da viewport esconde de novo e a proxima entrada revela outra vez.
+ window.scrollTo(0, 0);
+ await waitFor(() => expect(last.style.opacity).toBe("0"));
+ last.scrollIntoView({ block: "center" });
+ await waitFor(() => expect(last.style.opacity).toBe(""));
+ await settled(last);
+
+ // Cleanup: para de observar; voltar a viewport nao revela mais.
+ window.scrollTo(0, 0);
+ await waitFor(() => expect(last.style.opacity).toBe("0"));
+ revealStops.get(page)!();
+ last.scrollIntoView({ block: "center" });
+ await tick(150);
+ expect(last.style.opacity).toBe("0");
+ window.scrollTo(0, 0);
+ }
+};
+
+export const RevealOnce = {
+ parameters: {
+ layout: "fullscreen",
+ docs: {
+ description: {
+ story: "arkReveal com `once` (padrao): cada bloco anima na primeira entrada e fica visivel ao sair da viewport."
+ }
+ }
+ },
+ render: (): HTMLElement => {
+ const page = document.createElement("div");
+ page.className = "mx-auto flex max-w-xl flex-col gap-10 p-8";
+
+ const blocks: HTMLElement[] = [];
+ for (let i = 1; i <= 8; i++) {
+ const block = createCard(`Bloco ${i}`, 20 + i * 30);
+ block.style.height = "8rem";
+ page.appendChild(block);
+ blocks.push(block);
+ }
+
+ queueMicrotask(() => {
+ revealStops.set(page, arkReveal(blocks, { preset: "scale", duration: "quick" }));
+ });
+
+ return page;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const page = canvasElement.querySelector(".flex")!;
+ const blocks = Array.from(page.querySelectorAll("div"));
+ const last = blocks[blocks.length - 1];
+
+ await settled(blocks[0]);
+ expect(last.style.opacity).toBe("0");
+
+ last.scrollIntoView({ block: "center" });
+ await settled(last);
+
+ // Sair da viewport nao esconde: a observacao do bloco termina na primeira entrada.
+ window.scrollTo(0, 0);
+ await tick(150);
+ expect(last.style.opacity).not.toBe("0");
+ expect(getComputedStyle(last).opacity).toBe("1");
+ revealStops.get(page)!();
}
};
@@ -125,6 +260,64 @@ export const FlipReorder = {
container.appendChild(shuffle);
container.appendChild(grid);
return container;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const canvas = within(canvasElement);
+ const grid = canvasElement.querySelector(".grid")!;
+ const shuffle = canvas.getByRole("button", { name: "Embaralhar (FLIP)" });
+ const cards = Array.from(grid.children) as HTMLElement[];
+ const [first, second] = cards;
+ const last = cards[cards.length - 1];
+ const slotOfEighth = cards[7].getBoundingClientRect();
+
+ // Primeiro vai para o fim e o ultimo sai do DOM: o primeiro cruza a grade (x e y), o segundo so anda em x e
+ // o removido, desconectado, e pulado. Alvos por seletor.
+ const run = arkFlip(".grid > div", () => {
+ last.remove();
+ grid.appendChild(first);
+ });
+ expect(grid.children).toHaveLength(8);
+ expect(grid.lastElementChild).toBe(first);
+ await waitFor(() => expect(first.style.transform).toMatch(/^translateX\(.+\) translateY\(.+\)$/));
+ await waitFor(() => expect(second.style.transform).toMatch(/^translateX\(.+\)$/));
+ expect(last.style.transform).toBe("");
+ await run;
+ expect(first.style.transform).toBe("none");
+ expect(second.style.transform).toBe("none");
+ const rect = first.getBoundingClientRect();
+ expect(rect.left).toBeCloseTo(slotOfEighth.left, 0);
+ expect(rect.top).toBeCloseTo(slotOfEighth.top, 0);
+
+ // Um espacador no topo empurra todos so em y; o botao, fora da grade, nao se move e nao recebe animacao.
+ const spacer = document.createElement("div");
+ spacer.className = "col-span-3 h-8";
+ const push = arkFlip([shuffle, ...cards], () => grid.prepend(spacer), { stiffness: 600, damping: 40 });
+ await waitFor(() => expect(first.style.transform).toMatch(/^translateY\(.+\)$/));
+ expect(shuffle.style.transform).toBe("");
+ await push;
+ expect(first.style.transform).toBe("none");
+
+ // Sem alvos ou com movimento reduzido a mutacao e aplicada e nada anima.
+ let mutated = 0;
+ await arkFlip([], () => {
+ mutated += 1;
+ });
+ expect(mutated).toBe(1);
+ await withReducedMotion(async () => {
+ const reduced = arkFlip(cards, () => {
+ mutated += 1;
+ spacer.remove();
+ });
+ await nextFrame();
+ expect(first.style.transform).toBe("none");
+ await reduced;
+ });
+ expect(mutated).toBe(2);
+ expect(grid.contains(spacer)).toBe(false);
+
+ // O botao da demo embaralha de verdade.
+ await userEvent.click(shuffle);
+ expect(grid.children).toHaveLength(8);
}
};
@@ -162,5 +355,201 @@ export const Swipe = {
container.appendChild(status);
container.appendChild(card);
return container;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const canvas = within(canvasElement);
+ const card = canvas.getByText("Arraste-me");
+ const status = canvas.getByText("Arraste o cartao para os lados");
+ const box = card.getBoundingClientRect();
+ const x0 = box.left + box.width / 2;
+ const y0 = box.top + box.height / 2;
+
+ expect(card.style.touchAction).toBe("pan-y");
+
+ // Arrasto curto e lento: o cartao acompanha com resistencia (0.4 -> 60% do delta), nao e swipe e volta com
+ // spring ate a posicao original.
+ pointer(card, "pointerdown", x0, y0);
+ await tick(20);
+ pointer(card, "pointermove", x0 + 20, y0);
+ expect(card.style.transform).toBe("translateX(12px)");
+ // Outro ponteiro e um evento com id diferente sao ignorados enquanto o gesto esta ativo.
+ pointer(card, "pointerdown", x0, y0, { pointerId: 2 });
+ pointer(card, "pointermove", x0 + 200, y0, { pointerId: 2 });
+ pointer(card, "pointerup", x0 + 200, y0, { pointerId: 2 });
+ expect(card.style.transform).toBe("translateX(12px)");
+ await tick(300);
+ pointer(card, "pointermove", x0 + 21, y0);
+ pointer(card, "pointerup", x0 + 21, y0);
+ expect(status.textContent).toBe("Arraste o cartao para os lados");
+ await waitFor(() => expect(card.style.transform).toMatch(/^translateX\(/));
+ await waitFor(() => expect(card.style.transform).toBe("none"), { timeout: 3000 });
+
+ // Distancia acima do threshold (48px) com velocidade baixa: swipe para a direita.
+ pointer(card, "pointerdown", x0, y0);
+ await tick(20);
+ pointer(card, "pointermove", x0 + 80, y0);
+ await tick(300);
+ pointer(card, "pointermove", x0 + 81, y0);
+ pointer(card, "pointerup", x0 + 81, y0);
+ expect(status.textContent).toMatch(/^Swipe → #1 \(delta 81px, \d+px\/s\)$/);
+ await waitFor(() => expect(card.style.transform).toBe("none"), { timeout: 3000 });
+
+ // Abaixo do threshold mas rapido (>= 500px/s): swipe para a esquerda pela velocidade; pointercancel encerra
+ // como pointerup.
+ pointer(card, "pointerdown", x0, y0);
+ await tick(5);
+ pointer(card, "pointermove", x0 - 40, y0);
+ pointer(card, "pointercancel", x0 - 40, y0);
+ expect(status.textContent).toMatch(/^Swipe ← #2 \(delta -40px, -\d+px\/s\)$/);
+ await waitFor(() => expect(card.style.transform).toBe("none"), { timeout: 3000 });
+
+ // Ponteiro nao primario nao inicia gesto.
+ pointer(card, "pointerdown", x0, y0, { isPrimary: false });
+ pointer(card, "pointermove", x0 + 100, y0, { isPrimary: false });
+ pointer(card, "pointerup", x0 + 100, y0, { isPrimary: false });
+ expect(card.style.transform).toBe("none");
+ expect(status.textContent).toMatch(/#2 /);
+
+ // Com movimento reduzido o retorno e imediato, sem spring.
+ await withReducedMotion(async () => {
+ pointer(card, "pointerdown", x0, y0);
+ await tick(20);
+ pointer(card, "pointermove", x0 + 10, y0);
+ pointer(card, "pointerup", x0 + 10, y0);
+ expect(card.style.transform).toBe("");
+ await nextFrame();
+ await nextFrame();
+ expect(card.style.transform).toBe("");
+ });
+
+ // Eixo y sem feedback: nada de transform; resistencia fora de 0-1 e limitada; cleanup restaura touch-action e
+ // remove os listeners.
+ const column = createCard("Vertical", 120);
+ column.style.height = "6rem";
+ column.style.width = "100%";
+ column.style.touchAction = "manipulation";
+ canvasElement.appendChild(column);
+ const swipes: Array<[ArkSwipeDirection, ArkSwipeInfo]> = [];
+ const stop = arkSwipe(column, {
+ axis: "y",
+ feedback: false,
+ resistance: 2,
+ threshold: 30,
+ onSwipe: (direction, info) => swipes.push([direction, info])
+ });
+ expect(column.style.touchAction).toBe("pan-x");
+ const cy = column.getBoundingClientRect();
+ const cx0 = cy.left + cy.width / 2;
+ const cy0 = cy.top + cy.height / 2;
+ pointer(column, "pointerdown", cx0, cy0);
+ await tick(20);
+ pointer(column, "pointermove", cx0, cy0 - 50);
+ expect(column.style.transform).toBe("");
+ pointer(column, "pointerup", cx0, cy0 - 50);
+ expect(swipes).toHaveLength(1);
+ expect(swipes[0][0]).toBe("up");
+ expect(swipes[0][1].delta).toBe(-50);
+ pointer(column, "pointerdown", cx0, cy0);
+ await tick(20);
+ pointer(column, "pointermove", cx0, cy0 + 40);
+ pointer(column, "pointerup", cx0, cy0 + 40);
+ expect(swipes[1][0]).toBe("down");
+
+ stop();
+ expect(column.style.touchAction).toBe("manipulation");
+ pointer(column, "pointerdown", cx0, cy0);
+ await tick(20);
+ pointer(column, "pointermove", cx0, cy0 + 80);
+ pointer(column, "pointerup", cx0, cy0 + 80);
+ expect(swipes).toHaveLength(2);
+ column.remove();
+ }
+};
+
+export const TargetsAndOptions = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Alvos aceitos (seletor, elemento, lista, NodeList; SVG e ignorado), presets, distancia em rem/em/px, easing por token, curva ou nome da lib e o atalho com movimento reduzido."
+ }
+ }
+ },
+ render: (): HTMLElement => {
+ const container = document.createElement("div");
+ container.className = "mx-auto flex max-w-md flex-col gap-3";
+ for (let i = 1; i <= 3; i++) {
+ const card = createCard(`Alvo ${i}`, 300 + i * 20);
+ card.classList.add("motion-target");
+ container.appendChild(card);
+ }
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("width", "24");
+ svg.setAttribute("height", "24");
+ svg.setAttribute("aria-hidden", "true");
+ container.appendChild(svg);
+ return container;
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const cards = Array.from(canvasElement.querySelectorAll(".motion-target"));
+ const svg = canvasElement.querySelector("svg")!;
+
+ // Sem alvos, ou so com elementos que nao sao HTMLElement, resolve na hora.
+ await arkStaggerEnter([]);
+ await arkStaggerEnter(svg);
+ await arkStaggerEnter([svg]);
+ expect(svg.getAnimations()).toHaveLength(0);
+
+ // Seletor + scale + duracao em ms.
+ await arkStaggerEnter(".motion-target", { preset: "scale", duration: 80, interval: 0 });
+ await settled(...cards);
+
+ // Elemento unico + slide-left com distancia em rem, easing por token e duracao por token.
+ await arkStaggerEnter(cards[0], { preset: "slide-left", distance: "1rem", ease: "overshoot", duration: "quick" });
+ await settled(...cards);
+
+ // NodeList + slide-right com distancia em em, curva custom e escalonamento a partir do ultimo.
+ const list = canvasElement.querySelectorAll(".motion-target");
+ const run = arkStaggerEnter(list, {
+ preset: "slide-right",
+ distance: "2em",
+ ease: [0.2, 0, 0, 1],
+ from: "last",
+ duration: 150,
+ interval: 20
+ });
+ await waitFor(() => expect(cards[2].style.transform).toMatch(/^translateX\(/));
+ await run;
+ await settled(...cards);
+
+ // Array misto (SVG filtrado) + slide-down em px com nome de easing da lib Motion.
+ const down = arkStaggerEnter([svg, cards[1]], {
+ preset: "slide-down",
+ distance: "24px",
+ ease: "easeInOut",
+ duration: 150
+ });
+ await waitFor(() => expect(cards[1].style.transform).toMatch(/^translateY\(-/));
+ await down;
+ await settled(...cards);
+
+ // Distancia invalida cai no padrao; fade nao tem deslocamento.
+ await arkStaggerEnter(cards, { preset: "slide-up", distance: "abc", duration: 50, interval: 0 });
+ await arkStaggerEnter(cards, { preset: "fade", duration: 50, interval: 0 });
+ await settled(...cards);
+
+ // Movimento reduzido: limpa opacity/transform residuais e resolve; o reveal vira um no-op.
+ await withReducedMotion(async () => {
+ cards[0].style.opacity = "0";
+ cards[0].style.transform = "scale(0.5)";
+ await arkStaggerEnter(cards[0]);
+ expect(cards[0].style.opacity).toBe("");
+ expect(cards[0].style.transform).toBe("");
+ const stop = arkReveal(cards);
+ expect(cards[0].style.opacity).toBe("");
+ stop();
+ });
+ const none = arkReveal([]);
+ none();
}
};
diff --git a/apps/storybook/stories/ark-toaster.stories.ts b/apps/storybook/stories/ark-toaster.stories.ts
index b52c5d4..2d66499 100644
--- a/apps/storybook/stories/ark-toaster.stories.ts
+++ b/apps/storybook/stories/ark-toaster.stories.ts
@@ -1,5 +1,6 @@
import type { ArkTheme, ArkToastPosition } from "@tooark/core";
import { toast } from "@tooark/core";
+import type { ArkToaster } from "@tooark/web-components";
import { expect, userEvent, waitFor, within } from "storybook/test";
const meta = {
@@ -46,8 +47,18 @@ type StoryArgs = {
closeButton: boolean;
maxVisible: number;
duration: number;
+ testid?: string;
};
+// Titulos dos cards na ordem da pilha (o mais novo primeiro).
+function titlesOf(root: HTMLElement): string[] {
+ return Array.from(root.querySelectorAll('[data-ark="toaster-toast-title"]')).map((title) => title.textContent ?? "");
+}
+
+function cardOf(root: HTMLElement, title: string): HTMLElement {
+ return within(root).getByText(title).closest('[data-ark="toaster-toast"]')!;
+}
+
function createButton(label: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
@@ -170,6 +181,10 @@ function renderToaster(args: StoryArgs): HTMLElement {
toaster.setAttribute("close-button", "false");
}
+ if (args.testid) {
+ toaster.setAttribute("testid", args.testid);
+ }
+
toaster.addEventListener("ark-toast-action", (event) => {
const detail = (event as CustomEvent<{ id: string; actionId: string | null }>).detail;
if (detail.actionId === "retry-publish") {
@@ -302,3 +317,258 @@ export const TopLayer = {
expect(getComputedStyle(stack).display).toBe("none");
}
};
+
+export const TiposAcoesERichColors = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Interaction test: cada tipo do servico (`toast.success/info/warning/error/loading`) ganha icone e, com `rich-colors`, a paleta do intent; o botao de acao emite `ark-toast-action` e o de cancelamento so fecha; o mais novo fica no topo e o excedente de `max-visible` sai da pilha."
+ }
+ }
+ },
+ args: {
+ richColors: true,
+ duration: 0
+ },
+ render: renderToaster,
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const canvas = within(canvasElement);
+ const toaster = canvasElement.querySelector("ark-toaster")!;
+ const actions: Array<{ id: string; actionId: string | null }> = [];
+ toaster.addEventListener("ark-toast-action", (event) => {
+ actions.push((event as CustomEvent<{ id: string; actionId: string | null }>).detail);
+ });
+
+ await userEvent.click(canvas.getByRole("button", { name: "Success" }));
+ await userEvent.click(canvas.getByRole("button", { name: "Info" }));
+ await userEvent.click(canvas.getByRole("button", { name: "Warning" }));
+ await userEvent.click(canvas.getByRole("button", { name: "Error" }));
+ await canvas.findByText("Falha ao publicar");
+ expect(titlesOf(canvasElement)).toEqual([
+ "Falha ao publicar",
+ "Conexao instavel",
+ "Nova versao disponivel",
+ "Upload concluido"
+ ]);
+
+ const expectType = (title: string, icon: string, intent: string): void => {
+ const card = cardOf(canvasElement, title);
+ expect(card.querySelector("span")).toHaveTextContent(icon);
+ expect(card.className).toContain(`ark:bg-${intent}-soft`);
+ };
+ expectType("Upload concluido", "✓", "success");
+ expectType("Nova versao disponivel", "i", "info");
+ expectType("Conexao instavel", "!", "warning");
+ expectType("Falha ao publicar", "x", "danger");
+
+ // Acao: emite ark-toast-action com o actionId e fecha o toast; a story responde com um toast informativo,
+ // disparado enquanto o card sai (re-render no meio da saida).
+ await userEvent.click(
+ cardOf(canvasElement, "Falha ao publicar").querySelector('[data-ark="toaster-toast-action"]')!
+ );
+ expect(actions).toEqual([{ id: expect.stringMatching(/^ark-toast-/), actionId: "retry-publish" }]);
+ await canvas.findByText("Tentando novamente");
+ // O toast de resposta e o quinto: com max-visible 4 o mais antigo sai da pilha.
+ expect(canvas.queryByText("Upload concluido")).not.toBeInTheDocument();
+ await waitFor(() => expect(canvas.queryByText("Falha ao publicar")).not.toBeInTheDocument());
+ expect(titlesOf(canvasElement)).toEqual(["Tentando novamente", "Conexao instavel", "Nova versao disponivel"]);
+
+ // Cancelamento: so fecha, sem evento.
+ await userEvent.click(canvas.getByRole("button", { name: "Error" }));
+ await canvas.findByText("Falha ao publicar");
+ await userEvent.click(
+ cardOf(canvasElement, "Falha ao publicar").querySelector('[data-ark="toaster-toast-cancel"]')!
+ );
+ await waitFor(() => expect(canvas.queryByText("Falha ao publicar")).not.toBeInTheDocument());
+ expect(actions).toHaveLength(1);
+
+ // Loading entra no topo e nao expira sozinho (direto pelo servico: o botao da demo agenda um timer de 1,8 s
+ // que vazaria para a story seguinte).
+ toast.loading("Processando pagamento", { description: "Validando dados de cobranca." });
+ await canvas.findByText("Processando pagamento");
+ expect(titlesOf(canvasElement)).toEqual([
+ "Processando pagamento",
+ "Tentando novamente",
+ "Conexao instavel",
+ "Nova versao disponivel"
+ ]);
+ expectType("Processando pagamento", "...", "info");
+
+ await userEvent.click(canvas.getByRole("button", { name: "Dismiss all" }));
+ await waitFor(() => expect(titlesOf(canvasElement)).toHaveLength(0));
+ }
+};
+
+export const Posicoes = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Interaction test: as seis posicoes ancoram a pilha nos cantos e no centro; a entrada desce nas de cima e sobe nas de baixo, a saida vai para o lado da borda (fade no centro). Valor invalido cai em bottom-right."
+ }
+ }
+ },
+ args: {
+ duration: 0
+ },
+ render: renderToaster,
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const canvas = within(canvasElement);
+ const toaster = canvasElement.querySelector("ark-toaster")!;
+ const stack = canvasElement.querySelector('[data-ark="toaster"]')!;
+ const expected: Array<[string, string]> = [
+ ["top-left", "ark:left-0 ark:top-0 ark:items-start"],
+ ["top-center", "ark:left-1/2 ark:top-0 ark:-translate-x-1/2 ark:items-center"],
+ ["top-right", "ark:right-0 ark:top-0 ark:items-end"],
+ ["bottom-left", "ark:bottom-0 ark:left-0 ark:items-start"],
+ ["bottom-center", "ark:bottom-0 ark:left-1/2 ark:-translate-x-1/2 ark:items-center"],
+ ["bottom-right", "ark:bottom-0 ark:right-0 ark:items-end"],
+ ["middle", "ark:bottom-0 ark:right-0 ark:items-end"],
+ ["TOP-LEFT", "ark:left-0 ark:top-0 ark:items-start"]
+ ];
+
+ for (const [position, classes] of expected) {
+ toaster.setAttribute("position", position);
+ expect(stack.className).toContain(classes);
+
+ const id = toast(`Posicao ${position}`);
+ const card = cardOf(canvasElement, `Posicao ${position}`);
+ expect(card.getAnimations().length, `entrada em ${position}`).toBeGreaterThan(0);
+ await waitFor(() => expect(card.getAnimations()).toHaveLength(0));
+
+ toast.dismiss(id);
+ expect(card.getAnimations().length, `saida em ${position}`).toBeGreaterThan(0);
+ await waitFor(() => expect(canvas.queryByText(`Posicao ${position}`)).not.toBeInTheDocument());
+ }
+ }
+};
+
+export const AutoDismissEFila = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Interaction test: `duration` do host vale para toasts sem duracao propria e o timer some quando o toast e empurrado para fora de `max-visible` ou o host sai do DOM; o mesmo `id` substitui o toast; valores invalidos dos atributos caem nos padroes e um evento sem titulo e ignorado."
+ }
+ }
+ },
+ args: {
+ duration: 300,
+ maxVisible: 2
+ },
+ render: renderToaster,
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const toaster = canvasElement.querySelector("ark-toaster")!;
+ const cards = (): number => canvasElement.querySelectorAll('[data-ark="toaster-toast"]').length;
+
+ // Tres toasts sem duracao propria com max-visible 2: o primeiro sai da fila e os outros expiram em 300 ms.
+ toast("Primeiro");
+ toast("Segundo");
+ toast("Terceiro");
+ expect(titlesOf(canvasElement)).toEqual(["Terceiro", "Segundo"]);
+ await waitFor(() => expect(cards()).toBe(0), { timeout: 3000 });
+
+ // Mesmo id substitui o toast por um card novo; dispensar duas vezes durante a saida nao quebra e um toast novo
+ // nesse meio tempo entra por cima sem tocar no card que sai (a animacao de saida segue nele).
+ toast("Um", { id: "fixo", duration: 0 });
+ const um = cardOf(canvasElement, "Um");
+ toast("Dois", { id: "fixo", duration: 0 });
+ expect(titlesOf(canvasElement)).toEqual(["Dois"]);
+ const dois = cardOf(canvasElement, "Dois");
+ expect(dois).not.toBe(um);
+ toast.dismiss("fixo");
+ toast.dismiss("fixo");
+ toast("Durante a saida", { duration: 0 });
+ expect(cardOf(canvasElement, "Dois")).toBe(dois);
+ expect(dois.getAnimations().length).toBeGreaterThan(0);
+ expect(titlesOf(canvasElement)).toEqual(["Durante a saida", "Dois"]);
+ await waitFor(() => expect(titlesOf(canvasElement)).toEqual(["Durante a saida"]));
+
+ // Atributos invalidos: max-visible volta a 4 e duration a 4000 ms; evento sem titulo e ignorado.
+ toaster.setAttribute("max-visible", "abc");
+ toaster.setAttribute("duration", "abc");
+ for (let i = 1; i <= 5; i++) toast(`Fila ${i}`, { duration: 0 });
+ expect(titlesOf(canvasElement)).toEqual(["Fila 5", "Fila 4", "Fila 3", "Fila 2"]);
+ window.dispatchEvent(new CustomEvent("ark-toast", { detail: { description: "sem titulo" } }));
+ expect(cards()).toBe(4);
+ toast("Base");
+ expect(titlesOf(canvasElement)[0]).toBe("Base");
+
+ // Fora do DOM os timers pendentes sao cancelados; de volta, a pilha e re-renderizada.
+ toaster.remove();
+ canvasElement.appendChild(toaster);
+ expect(titlesOf(canvasElement)[0]).toBe("Base");
+ toaster.dismiss();
+ await waitFor(() => expect(cards()).toBe(0));
+ }
+};
+
+export const FocoNoTeclado = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Interaction test: um toast novo entra por cima sem recriar os cards existentes, entao o botao que o usuario de teclado esta lendo continua focado (e a pilha nao reentra no top layer nesse caso). Trocar um atributo do host recria os cards, sem reanimar a entrada."
+ }
+ }
+ },
+ args: {
+ duration: 0
+ },
+ render: renderToaster,
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const toaster = canvasElement.querySelector("ark-toaster")!;
+
+ toast.success("Verde", { duration: 0 });
+ toast("Lendo", { duration: 0 });
+ const lendo = cardOf(canvasElement, "Lendo");
+ const close = lendo.querySelector('[data-ark="toaster-toast-close"]')!;
+ close.focus();
+ expect(document.activeElement).toBe(close);
+
+ toast("Outro", { duration: 0 });
+ expect(document.activeElement).toBe(close);
+ expect(cardOf(canvasElement, "Lendo")).toBe(lendo);
+ expect(titlesOf(canvasElement)).toEqual(["Outro", "Lendo", "Verde"]);
+ expect(cardOf(canvasElement, "Verde").className).not.toContain("ark:bg-success-soft");
+
+ // Atributo do host: cards recriados com a nova aparencia, sem animacao de entrada.
+ toaster.setAttribute("rich-colors", "");
+ const verde = cardOf(canvasElement, "Verde");
+ expect(verde.className).toContain("ark:bg-success-soft");
+ expect(cardOf(canvasElement, "Lendo")).not.toBe(lendo);
+ expect(verde.getAnimations()).toHaveLength(0);
+ expect(titlesOf(canvasElement)).toEqual(["Outro", "Lendo", "Verde"]);
+
+ toast.dismiss();
+ await waitFor(() => expect(titlesOf(canvasElement)).toHaveLength(0));
+ }
+};
+
+export const TestHooks = {
+ args: {
+ duration: 0,
+ testid: "avisos"
+ },
+ render: renderToaster,
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const stack = canvasElement.querySelector('[data-ark="toaster"]');
+ await expect(stack).toHaveAttribute("data-testid", "avisos");
+
+ const id = toast.error("Hooks", {
+ description: "Descricao",
+ actionLabel: "Tentar",
+ cancelLabel: "Cancelar"
+ });
+ const card = cardOf(canvasElement, "Hooks");
+ await expect(card).toHaveAttribute("data-testid", "avisos-toast");
+ await expect(card).toHaveAttribute("data-toast-id", id);
+ for (const part of ["title", "description", "close", "action", "cancel"]) {
+ const node = card.querySelector(`[data-ark="toaster-toast-${part}"]`);
+ await expect(node).not.toBeNull();
+ await expect(node).toHaveAttribute("data-testid", `avisos-toast-${part}`);
+ }
+ toast.dismiss(id);
+ }
+};
diff --git a/apps/storybook/stories/ark-toggle.stories.ts b/apps/storybook/stories/ark-toggle.stories.ts
index e884238..e86b082 100644
--- a/apps/storybook/stories/ark-toggle.stories.ts
+++ b/apps/storybook/stories/ark-toggle.stories.ts
@@ -181,3 +181,156 @@ export const GroupEmitsOnce = {
await expect(received).toEqual(["ark-toggle-group"]);
}
};
+
+type ToggleElement = HTMLElement & {
+ pressed: boolean | string | null | undefined;
+ disabled: boolean | string | null | undefined;
+ value: string;
+ toggle(): void;
+};
+
+export const KeyboardAndDisabled = {
+ render: () => {
+ const wrap = document.createElement("div");
+ wrap.className = "flex gap-2";
+ const toggle = createToggle("Favorito", { value: "fav" });
+ const inner = document.createElement("span");
+ inner.textContent = " (com filho)";
+ toggle.appendChild(inner);
+ wrap.append(toggle, createToggle("Bloqueado", { disabled: true, value: "locked" }));
+ return wrap;
+ },
+ // Enter e Espaco alternam (Espaco no keyup, como um botao nativo); teclas vindas de um filho sao ignoradas;
+ // desabilitado nao reage a clique nem teclado e sai do tabindex; os setters aceitam a regra dos wrappers.
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const canvas = within(canvasElement);
+ const toggle = canvas.getByRole("button", { name: /Favorito/ }) as ToggleElement;
+ const locked = canvas.getByRole("button", { name: "Bloqueado" }) as ToggleElement;
+ const changes: Array<{ pressed: boolean; value: string }> = [];
+ canvasElement.addEventListener("change", (event) => {
+ changes.push((event as CustomEvent<{ pressed: boolean; value: string }>).detail);
+ });
+
+ toggle.focus();
+ await userEvent.keyboard("{Enter}");
+ await expect(toggle).toHaveAttribute("aria-pressed", "true");
+ await userEvent.keyboard(" ");
+ await expect(toggle).toHaveAttribute("aria-pressed", "false");
+ expect(changes).toEqual([
+ { pressed: true, value: "fav" },
+ { pressed: false, value: "fav" }
+ ]);
+
+ // Tecla disparada por um filho nao alterna.
+ const inner = toggle.querySelector("span")!;
+ inner.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ inner.dispatchEvent(new KeyboardEvent("keyup", { key: " ", bubbles: true }));
+ await expect(toggle).toHaveAttribute("aria-pressed", "false");
+
+ // Desabilitado: tabindex -1, aria-disabled, clique cancelado (o CSS ja corta pointer-events; o evento vai
+ // direto) e teclado ignorado.
+ await expect(locked).toHaveAttribute("tabindex", "-1");
+ await expect(locked).toHaveAttribute("aria-disabled", "true");
+ expect(locked.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))).toBe(false);
+ locked.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ locked.dispatchEvent(new KeyboardEvent("keyup", { key: " ", bubbles: true }));
+ locked.toggle();
+ await expect(locked).toHaveAttribute("aria-pressed", "false");
+ expect(changes).toHaveLength(2);
+
+ // Setters com a regra dos wrappers: "" liga, "false" desliga.
+ locked.disabled = "false";
+ expect(locked.hasAttribute("disabled")).toBe(false);
+ await expect(locked).toHaveAttribute("tabindex", "0");
+ expect(locked.hasAttribute("aria-disabled")).toBe(false);
+ locked.toggle();
+ await expect(locked).toHaveAttribute("aria-pressed", "true");
+ expect(changes.at(-1)).toEqual({ pressed: true, value: "locked" });
+ locked.disabled = "";
+ await expect(locked).toHaveAttribute("aria-disabled", "true");
+ toggle.pressed = "";
+ await expect(toggle).toHaveAttribute("aria-pressed", "true");
+ toggle.pressed = "false";
+ await expect(toggle).toHaveAttribute("aria-pressed", "false");
+ expect(changes).toHaveLength(3);
+ }
+};
+
+export const ClassAndTestHooks = {
+ render: () => {
+ const toggle = createToggle("Hooks", { value: "hooks" });
+ toggle.setAttribute("testid", "meu-toggle");
+ toggle.className = "extra";
+ return toggle;
+ },
+ // O host carrega as classes: um framework que reescreve `class` nao as apaga; os hooks ficam no proprio host.
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const toggle = canvasElement.querySelector("ark-toggle")!;
+ await expect(toggle).toHaveAttribute("data-ark", "toggle");
+ await expect(toggle).toHaveAttribute("data-testid", "meu-toggle");
+ expect(toggle.classList.contains("extra")).toBe(true);
+ expect(toggle.classList.contains("ark:inline-flex")).toBe(true);
+
+ toggle.className = "outra";
+ expect(toggle.classList.contains("outra")).toBe(true);
+ expect(toggle.classList.contains("extra")).toBe(false);
+ expect(toggle.classList.contains("ark:inline-flex")).toBe(true);
+
+ toggle.setAttribute("class", "");
+ expect(toggle.classList.contains("ark:inline-flex")).toBe(true);
+ }
+};
+
+export const GroupPropagation = {
+ render: () => {
+ const group = document.createElement("ark-toggle-group");
+ group.setAttribute("value", "left");
+ group.setAttribute("testid", "alinhamento");
+ group.className = "extra";
+ group.appendChild(createToggle("Left", { value: "left" }));
+ group.appendChild(createToggle("Center", { value: "center" }));
+ group.appendChild(createToggle("Right", { value: "right", disabled: true }));
+ return group;
+ },
+ // `value` por propriedade seleciona o item (vazio limpa); size/intent/theme/disabled do grupo descem para os itens
+ // e o disabled do grupo e retirado sem apagar o individual; `class` reescrita mantem as classes do host.
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const canvas = within(canvasElement);
+ const group = canvasElement.querySelector("ark-toggle-group")!;
+ const left = canvas.getByRole("button", { name: "Left" });
+ const center = canvas.getByRole("button", { name: "Center" });
+ const right = canvas.getByRole("button", { name: "Right" });
+
+ await expect(group).toHaveAttribute("data-ark", "toggle-group");
+ await expect(group).toHaveAttribute("data-testid", "alinhamento");
+
+ group.value = "center";
+ await expect(group).toHaveAttribute("value", "center");
+ await expect(center).toHaveAttribute("aria-pressed", "true");
+ await expect(left).toHaveAttribute("aria-pressed", "false");
+ group.value = "";
+ expect(group.hasAttribute("value")).toBe(false);
+ await expect(center).toHaveAttribute("aria-pressed", "false");
+
+ group.setAttribute("size", "lg");
+ group.setAttribute("intent", "danger");
+ group.setAttribute("theme", "dark");
+ await expect(left).toHaveAttribute("size", "lg");
+ await expect(left).toHaveAttribute("intent", "danger");
+ await expect(left).toHaveAttribute("theme", "dark");
+
+ group.setAttribute("disabled", "");
+ await expect(left).toHaveAttribute("aria-disabled", "true");
+ await expect(left).toHaveAttribute("data-group-disabled", "true");
+ // O "Right" ja era desabilitado por conta propria: nao leva a marca do grupo.
+ expect(right.hasAttribute("data-group-disabled")).toBe(false);
+ group.removeAttribute("disabled");
+ expect(left.hasAttribute("disabled")).toBe(false);
+ expect(left.hasAttribute("data-group-disabled")).toBe(false);
+ await expect(right).toHaveAttribute("aria-disabled", "true");
+
+ group.className = "outra";
+ expect(group.classList.contains("outra")).toBe(true);
+ expect(group.classList.contains("ark:inline-flex")).toBe(true);
+ }
+};
diff --git a/package.json b/package.json
index 9070ae6..d6bee41 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/root",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Web Component Library for Angular, React, and Vue projects",
"private": true,
"type": "module",
diff --git a/packages/angular/package.json b/packages/angular/package.json
index e764a33..5614673 100644
--- a/packages/angular/package.json
+++ b/packages/angular/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/angular",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Tooark Angular — standalone wrappers for ark-* components for Angular",
"keywords": [
"tooark",
diff --git a/packages/chart/package.json b/packages/chart/package.json
index 7b8a639..a526f98 100644
--- a/packages/chart/package.json
+++ b/packages/chart/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/chart",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Tooark Chart — Custom Element based on ECharts, themed using tokens",
"keywords": [
"tooark",
diff --git a/packages/code/package.json b/packages/code/package.json
index e1ab2d4..18b32a1 100644
--- a/packages/code/package.json
+++ b/packages/code/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/code",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Tooark Code — a code editor (``) based on CodeMirror 6 (supporting JSON, JavaScript, and YAML), featuring code completion, formatting, and token-based theming",
"keywords": [
"tooark",
diff --git a/packages/core/package.json b/packages/core/package.json
index 72df6fd..6dc078c 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/core",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Tooark Core — types, i18n, toast and announce services, dependency-free motion, and overlay helpers for ark-* components",
"keywords": [
"tooark",
diff --git a/packages/motion/package.json b/packages/motion/package.json
index b78830d..a8350ba 100644
--- a/packages/motion/package.json
+++ b/packages/motion/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/motion",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Tooark Motion — opt-in animation helpers (stagger, scroll reveal, FLIP, swipe) on top of the Motion lib, with design system tokens",
"keywords": [
"tooark",
diff --git a/packages/motion/src/reveal.ts b/packages/motion/src/reveal.ts
index 068843e..838d262 100644
--- a/packages/motion/src/reveal.ts
+++ b/packages/motion/src/reveal.ts
@@ -29,14 +29,11 @@ export function arkReveal(targets: ArkMotionTargets, options: ArkRevealOptions =
el.style.opacity = "0";
el.style.willChange = "opacity, transform";
- let revealed = false;
-
- // Configura a observação do elemento usando a função inView.
+ // Configura a observação do elemento usando a função inView. O `once` é do próprio inView: sem cleanup
+ // devolvido ele para de observar o elemento depois da primeira entrada, então o callback não roda de novo.
const stop = inView(
el,
() => {
- if (once && revealed) return;
- revealed = true;
el.style.opacity = "";
void arkStaggerEnter([el], { ...options, interval: 0 });
diff --git a/packages/react/package.json b/packages/react/package.json
index a82e029..b4a27cc 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/react",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Tooark React — typed wrappers for ark-* components for React 18 and 19",
"keywords": [
"tooark",
diff --git a/packages/tokens/package.json b/packages/tokens/package.json
index 9b5295b..529bec6 100644
--- a/packages/tokens/package.json
+++ b/packages/tokens/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/tokens",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Tooark Tokens — design primitives (colors by intent, sizes, radii, motion) as CSS tokens and TypeScript types",
"keywords": [
"tooark",
diff --git a/packages/vue/package.json b/packages/vue/package.json
index ac2945a..381142b 100644
--- a/packages/vue/package.json
+++ b/packages/vue/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/vue",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Tooark Vue — ark-* component wrappers for Vue 3",
"keywords": [
"tooark",
diff --git a/packages/web-components/package.json b/packages/web-components/package.json
index 31f6195..b56a7fe 100644
--- a/packages/web-components/package.json
+++ b/packages/web-components/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/web-components",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Tooark Web Components — Custom Elements (ark-*) (buttons, fields, dialogs, menus, calendar, key/value editor...) with Tailwind v4 and tokens",
"keywords": [
"tooark",
diff --git a/packages/web-components/src/components/ark-toaster.ts b/packages/web-components/src/components/ark-toaster.ts
index 4faa841..32ac45e 100644
--- a/packages/web-components/src/components/ark-toaster.ts
+++ b/packages/web-components/src/components/ark-toaster.ts
@@ -58,6 +58,9 @@ export class ArkToaster extends HTMLElement {
}
attributeChangedCallback(): void {
+ // Posição, rich-colors, close-button, lang e testid mudam os cards já montados: recria todos (sem reanimar a
+ // entrada, que `entered` lembra).
+ this.root?.replaceChildren();
this.render();
}
@@ -70,6 +73,11 @@ export class ArkToaster extends HTMLElement {
type: options.type || "default"
};
+ // Mesmo id: o card antigo sai e o toast volta ao topo como um novo.
+ if (this.toasts.some((toast) => toast.id === id)) {
+ this.cardOf(id)?.remove();
+ this.entered.delete(id);
+ }
this.toasts = [item, ...this.toasts.filter((toast) => toast.id !== id)];
const maxVisible = this.getMaxVisible();
@@ -107,7 +115,7 @@ export class ArkToaster extends HTMLElement {
this.render();
};
- const card = this.root?.querySelector(`[data-toast-id="${id}"]`);
+ const card = this.cardOf(id);
if (card) {
this.exiting.add(id);
arkExit(card, this.getExitPreset()).then(finalize);
@@ -116,6 +124,10 @@ export class ArkToaster extends HTMLElement {
}
}
+ private cardOf(id: string): HTMLElement | null {
+ return this.root?.querySelector(`[data-toast-id="${CSS.escape(id)}"]`) ?? null;
+ }
+
private readonly handleToast = (event: Event): void => {
const detail = (event as CustomEvent).detail;
if (!detail?.title) return;
@@ -287,16 +299,14 @@ export class ArkToaster extends HTMLElement {
this.render();
}
+ // Incremental: os cards da lista ficam onde estão (recriar um card focado jogaria o foco do teclado no body e
+ // o leitor de tela perderia o toast que estava lendo, e um card em saída perderia a animação), os que saíram da
+ // lista vão embora e só os novos são criados, cada um na posição da lista (o mais novo no topo).
private render(): void {
if (!this.root) return;
- const position = this.getPosition();
- const richColors = this.hasRichColors();
- const closeButton = this.hasCloseButton();
const palette = this.getPalette();
-
- this.root.className = `${palette.stack} ${this.getPositionClasses(position)}`;
- this.root.innerHTML = "";
+ this.root.className = `${palette.stack} ${this.getPositionClasses(this.getPosition())}`;
applyTestHooks(this, "toaster", this.root);
this.syncPopover();
@@ -305,100 +315,117 @@ export class ArkToaster extends HTMLElement {
if (!activeIds.has(id)) this.entered.delete(id);
}
- for (const toast of this.toasts) {
- const card = document.createElement("section");
- card.setAttribute("part", "toast");
- card.dataset.toastId = toast.id;
- // Cada card compartilha o hook "toaster-toast"; desambigue via data-toast-id.
- applyTestHooks(this, "toaster", card, "toast");
- card.className = `${palette.toastBase} ${this.getToastTypeClasses(toast.type, richColors)}`.trim();
-
- const row = document.createElement("div");
- row.className = "ark:flex ark:items-start ark:gap-3";
-
- const icon = document.createElement("span");
- icon.className = `${palette.icon} ark:mt-0.5 ark:inline-flex ark:h-5 ark:w-5 ark:items-center ark:justify-center ark:rounded-full ark:border ark:border-current/20`;
- icon.textContent = this.getToastIcon(toast.type);
-
- const content = document.createElement("div");
- content.className = "ark:min-w-0 ark:flex-1";
-
- const title = document.createElement("h4");
- title.className = palette.title;
- applyTestHooks(this, "toaster", title, "toast-title");
- title.textContent = toast.title;
- content.appendChild(title);
-
- if (toast.description) {
- const description = document.createElement("p");
- description.className = palette.description;
- applyTestHooks(this, "toaster", description, "toast-description");
- description.textContent = toast.description;
- content.appendChild(description);
+ const existing = new Map();
+ for (const card of Array.from(this.root.querySelectorAll("[data-toast-id]"))) {
+ const id = card.dataset.toastId ?? "";
+ if (activeIds.has(id)) {
+ existing.set(id, card);
+ } else {
+ card.remove();
}
+ }
- row.appendChild(icon);
- row.appendChild(content);
-
- if (closeButton) {
- const close = document.createElement("button");
- close.type = "button";
- close.className = palette.closeButton;
- applyTestHooks(this, "toaster", close, "toast-close");
- close.textContent = this.getCloseLabel();
- close.addEventListener("click", () => this.dismiss(toast.id));
- row.appendChild(close);
+ // Do mais antigo (fim da pilha) ao mais novo: um card novo entra antes do card do toast seguinte na lista.
+ let next: HTMLElement | null = null;
+ for (let index = this.toasts.length - 1; index >= 0; index--) {
+ const toast = this.toasts[index];
+ let card = existing.get(toast.id);
+ if (!card) {
+ card = this.buildCard(toast, palette);
+ this.root.insertBefore(card, next);
+ if (!this.entered.has(toast.id)) {
+ this.entered.add(toast.id);
+ arkEnter(card, this.getEnterPreset());
+ }
}
+ next = card;
+ }
+ }
- card.appendChild(row);
+ private buildCard(toast: ArkToastItem, palette: ArkToasterPalette): HTMLElement {
+ const card = document.createElement("section");
+ card.setAttribute("part", "toast");
+ card.dataset.toastId = toast.id;
+ // Cada card compartilha o hook "toaster-toast"; desambigue via data-toast-id.
+ applyTestHooks(this, "toaster", card, "toast");
+ card.className = `${palette.toastBase} ${this.getToastTypeClasses(toast.type, this.hasRichColors())}`.trim();
+
+ const row = document.createElement("div");
+ row.className = "ark:flex ark:items-start ark:gap-3";
+
+ const icon = document.createElement("span");
+ icon.className = `${palette.icon} ark:mt-0.5 ark:inline-flex ark:h-5 ark:w-5 ark:items-center ark:justify-center ark:rounded-full ark:border ark:border-current/20`;
+ icon.textContent = this.getToastIcon(toast.type);
+
+ const content = document.createElement("div");
+ content.className = "ark:min-w-0 ark:flex-1";
+
+ const title = document.createElement("h4");
+ title.className = palette.title;
+ applyTestHooks(this, "toaster", title, "toast-title");
+ title.textContent = toast.title;
+ content.appendChild(title);
+
+ if (toast.description) {
+ const description = document.createElement("p");
+ description.className = palette.description;
+ applyTestHooks(this, "toaster", description, "toast-description");
+ description.textContent = toast.description;
+ content.appendChild(description);
+ }
- if (toast.actionLabel || toast.cancelLabel) {
- const actions = document.createElement("div");
- actions.className = "ark:mt-3 ark:flex ark:items-center ark:justify-end ark:gap-2";
+ row.appendChild(icon);
+ row.appendChild(content);
+
+ if (this.hasCloseButton()) {
+ const close = document.createElement("button");
+ close.type = "button";
+ close.className = palette.closeButton;
+ applyTestHooks(this, "toaster", close, "toast-close");
+ close.textContent = this.getCloseLabel();
+ close.addEventListener("click", () => this.dismiss(toast.id));
+ row.appendChild(close);
+ }
- if (toast.cancelLabel) {
- const cancel = document.createElement("button");
- cancel.type = "button";
- cancel.className = palette.cancelButton;
- applyTestHooks(this, "toaster", cancel, "toast-cancel");
- cancel.textContent = toast.cancelLabel;
- cancel.addEventListener("click", () => this.dismiss(toast.id));
- actions.appendChild(cancel);
- }
+ card.appendChild(row);
- if (toast.actionLabel) {
- const action = document.createElement("button");
- action.type = "button";
- action.className = palette.actionButton;
- applyTestHooks(this, "toaster", action, "toast-action");
- action.textContent = toast.actionLabel;
- action.addEventListener("click", () => {
- this.dispatchEvent(
- new CustomEvent("ark-toast-action", {
- detail: { id: toast.id, actionId: toast.actionId || null },
- bubbles: true,
- composed: true
- })
- );
- this.dismiss(toast.id);
- });
- actions.appendChild(action);
- }
+ if (toast.actionLabel || toast.cancelLabel) {
+ const actions = document.createElement("div");
+ actions.className = "ark:mt-3 ark:flex ark:items-center ark:justify-end ark:gap-2";
- card.appendChild(actions);
+ if (toast.cancelLabel) {
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.className = palette.cancelButton;
+ applyTestHooks(this, "toaster", cancel, "toast-cancel");
+ cancel.textContent = toast.cancelLabel;
+ cancel.addEventListener("click", () => this.dismiss(toast.id));
+ actions.appendChild(cancel);
}
- this.root.appendChild(card);
-
- if (this.exiting.has(toast.id)) {
- // Re-render durante uma saída em andamento: mantém o card oculto
- card.style.opacity = "0";
- card.style.pointerEvents = "none";
- } else if (!this.entered.has(toast.id)) {
- this.entered.add(toast.id);
- arkEnter(card, this.getEnterPreset());
+ if (toast.actionLabel) {
+ const action = document.createElement("button");
+ action.type = "button";
+ action.className = palette.actionButton;
+ applyTestHooks(this, "toaster", action, "toast-action");
+ action.textContent = toast.actionLabel;
+ action.addEventListener("click", () => {
+ this.dispatchEvent(
+ new CustomEvent("ark-toast-action", {
+ detail: { id: toast.id, actionId: toast.actionId || null },
+ bubbles: true,
+ composed: true
+ })
+ );
+ this.dismiss(toast.id);
+ });
+ actions.appendChild(action);
}
+
+ card.appendChild(actions);
}
+
+ return card;
}
}
diff --git a/packages/web-components/src/components/ark-toggle-group.ts b/packages/web-components/src/components/ark-toggle-group.ts
index b47e8c6..e6ffec0 100644
--- a/packages/web-components/src/components/ark-toggle-group.ts
+++ b/packages/web-components/src/components/ark-toggle-group.ts
@@ -141,8 +141,11 @@ export class ArkToggleGroup extends HTMLElement {
if (intent) toggle.setAttribute("intent", intent);
if (theme) toggle.setAttribute("theme", theme);
if (disabled) {
- toggle.setAttribute("disabled", "");
- toggle.dataset.groupDisabled = "true";
+ // Um item já desabilitado por conta própria não leva a marca do grupo, senão ela apagaria o individual.
+ if (!toggle.hasAttribute("disabled")) {
+ toggle.setAttribute("disabled", "");
+ toggle.dataset.groupDisabled = "true";
+ }
} else if (toggle.dataset.groupDisabled) {
// Só remove o "disabled" que o próprio grupo aplicou; preserva o individual.
toggle.removeAttribute("disabled");
diff --git a/packages/wysiwyg/package.json b/packages/wysiwyg/package.json
index 6ecd146..0bf2fa8 100644
--- a/packages/wysiwyg/package.json
+++ b/packages/wysiwyg/package.json
@@ -1,6 +1,6 @@
{
"name": "@tooark/wysiwyg",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Tooark WYSIWYG — rich text editor and viewer (, ) built on Tiptap, featuring sanitized JSON content",
"keywords": [
"tooark",