From 9ebe0356272c25453ed912d35969aec4562fe79c Mon Sep 17 00:00:00 2001 From: Paulo Freitas Date: Sun, 20 Sep 2026 12:55:03 -0300 Subject: [PATCH 1/7] fix(web-components): ark-toggle-group preserva o disabled individual ao reabilitar o grupo Ao tirar `disabled` do grupo, os itens desabilitados por conta propria voltavam habilitados: syncToggles marcava todo item com data-group-disabled, inclusive os que ja estavam desabilitados, e a marca e o que autoriza a remocao. Um item ja desabilitado agora nao leva a marca, entao so o disabled que o grupo aplicou e retirado. - ark-toggle.stories.ts: KeyboardAndDisabled (Enter/Espaco, tecla vinda de um filho, clique cancelado e setters com a regra dos wrappers), ClassAndTestHooks (reescrever class mantem as classes do host; hooks no host) e GroupPropagation (value por propriedade, size/intent/theme/disabled descendo para os itens, regressao acima). - CHANGELOG: entrada em Unreleased. Signed-off-by: Paulo Freitas --- CHANGELOG.md | 2 + apps/storybook/stories/ark-toggle.stories.ts | 153 ++++++++++++++++++ .../src/components/ark-toggle-group.ts | 7 +- 3 files changed, 160 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 165ba87..196c55d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ 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`. ## [1.0.0] - 2026-09-19 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/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"); From 3cf7d81ff0ae90028505464043f99d7ecf38502b Mon Sep 17 00:00:00 2001 From: Paulo Freitas Date: Sun, 20 Sep 2026 12:55:04 -0300 Subject: [PATCH 2/7] fix(web-components): ark-toaster mantem o foco do teclado ao receber um toast novo Cada toast novo refazia a pilha inteira (innerHTML = "" e todos os cards recriados): o botao que o usuario de teclado estava lendo era destruido e o foco caia no body, ao contrario do que o README promete, e o raise() que deveria preservar o foco nunca via foco dentro da pilha. Um card em saida tambem era recriado oculto e perdia a animacao. O render passou a ser incremental: cards ja montados ficam onde estao, os que sairam da lista sao removidos e so o novo e criado, na posicao da lista (o mais novo no topo). O corpo do loop antigo virou buildCard(). Mudar um atributo do host (position, rich-colors, close-button, lang, testid) continua recriando todos os cards, sem reanimar a entrada; um id repetido tira o card antigo e volta ao topo como novo. cardOf() centraliza o seletor com CSS.escape. - ark-toaster.stories.ts: FocoNoTeclado (o foco fica e o card e o mesmo no), TiposAcoesERichColors (icone e paleta por tipo, acao emite ark-toast-action, cancelamento so fecha, excedente de max-visible), Posicoes (seis posicoes, valor invalido e maiusculas, entrada e saida em cada uma), AutoDismissEFila (duration do host, timers cancelados ao sair da fila ou do DOM, mesmo id, atributos invalidos, evento sem titulo) e TestHooks. - CHANGELOG: entrada em Unreleased. Signed-off-by: Paulo Freitas --- CHANGELOG.md | 4 + apps/storybook/stories/ark-toaster.stories.ts | 270 ++++++++++++++++++ .../src/components/ark-toaster.ts | 205 +++++++------ 3 files changed, 390 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 196c55d..0faecbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and 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 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/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; } } From 1ad5f7e7350ae200243940a565360db043a2589d Mon Sep 17 00:00:00 2001 From: Paulo Freitas Date: Sun, 20 Sep 2026 12:55:04 -0300 Subject: [PATCH 3/7] test(storybook): interacoes do @tooark/motion (stagger, reveal, FLIP e swipe) O arquivo nao tinha nenhuma play function: as demos montavam os helpers, mas nada os executava, e o pacote ficava em 42% de linhas (arkFlip em 0%, arkSwipe em 35%). Cada story ganhou um play, e entraram duas de cobertura: RevealOnce (once padrao) e TargetsAndOptions (seletor, elemento, lista, NodeList, SVG ignorado, presets, distancia em rem/em/px, easing por token, curva e nome da lib, from, movimento reduzido). - Swipe usa PointerEvent feito a mao (pointerId 1, isPrimary) com setTimeout entre os eventos para a velocidade, como o ark-split-pane; movimento reduzido no JS entra por um stub de matchMedia em try/finally. - settled() espera transform (JS) e opacity (WAAPI) na mesma waitFor: o transform chega a none um frame antes. - O pacote sobe para 99% de linhas e 93% de branches. Signed-off-by: Paulo Freitas --- .../stories/ark-motion-plus.stories.ts | 393 +++++++++++++++++- 1 file changed, 391 insertions(+), 2 deletions(-) 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(); } }; From 60fb74307d71465f2fb53ab0211d1cb5bbf842d8 Mon Sep 17 00:00:00 2001 From: Paulo Freitas Date: Sun, 20 Sep 2026 12:55:05 -0300 Subject: [PATCH 4/7] refactor(motion): arkReveal deixa o once por conta do inView O guard `if (once && revealed) return` nunca rodava: o inView da Motion documenta que, sem cleanup devolvido pelo callback, o elemento deixa de ser observado depois da primeira entrada. A flag e o guard cobriam um caso que a lib garante que nao acontece; o comentario passa a apontar o contrato. Signed-off-by: Paulo Freitas --- packages/motion/src/reveal.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 }); From eb7d28eeb3e710947f0a03954efd5abce9ffa1b1 Mon Sep 17 00:00:00 2001 From: Paulo Freitas Date: Sun, 20 Sep 2026 12:55:05 -0300 Subject: [PATCH 5/7] test(storybook): propriedades do ark-code-editor e fallbacks do ark-copy-button - ark-code-editor.stories.ts: Properties cobre todos os setters e getters (reflexo no atributo, validacao de faixa, booleanos com a regra dos wrappers), variableKeys/completions/completionSource/formatter depois de montado e focus(). Os 19 setters nunca eram chamados; o componente sobe de 77% para 98% de linhas. - ark-copy-button.stories.ts: PropertiesAndFallbacks cobre value/feedbackMs por propriedade, `for` em input e sem alvo, locale-json com lang="custom", copia repetida dentro do feedback, fallback de execCommand quando a Clipboard API nega, icon-only com aria-label proprio e do usuario, comentarios e filhos do usuario. Signed-off-by: Paulo Freitas --- .../stories/ark-code-editor.stories.ts | 133 ++++++++++++++++++ .../stories/ark-copy-button.stories.ts | 107 ++++++++++++++ 2 files changed, 240 insertions(+) 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; + } + } +}; From ea2485e964eb61dad0781e4092a5b149235a666f Mon Sep 17 00:00:00 2001 From: Paulo Freitas Date: Sun, 20 Sep 2026 12:55:06 -0300 Subject: [PATCH 6/7] docs: armadilhas das play functions no CLAUDE.md Seis padroes que custaram uma rodada cada nesta leva de testes: o instrumentador do storybook/test embrulha funcoes passadas ao expect, userEvent.click recusa pointer-events: none, setTimeout em handler de demo vaza para a story seguinte, PointerEvent sintetico com setPointerCapture, corrida transform/opacity dos helpers da Motion e o stub de matchMedia para movimento reduzido no JS. Signed-off-by: Paulo Freitas --- CLAUDE.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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. From 80951e91b3362c730f390a79218edefba6f54540 Mon Sep 17 00:00:00 2001 From: Paulo Freitas <paulosfjunior@gmail.com> Date: Sun, 20 Sep 2026 12:55:09 -0300 Subject: [PATCH 7/7] chore(release): 1.0.1 Versao raiz e dos dez pacotes (scripts/sync-versions.mjs); o CHANGELOG fecha a secao 1.0.1 com as correcoes do ark-wysiwyg-editor, ark-carousel, ark-toggle-group e ark-toaster. O release.yml publica no push para a main. Signed-off-by: Paulo Freitas <paulosfjunior@gmail.com> --- CHANGELOG.md | 5 ++++- package.json | 2 +- packages/angular/package.json | 2 +- packages/chart/package.json | 2 +- packages/code/package.json | 2 +- packages/core/package.json | 2 +- packages/motion/package.json | 2 +- packages/react/package.json | 2 +- packages/tokens/package.json | 2 +- packages/vue/package.json | 2 +- packages/web-components/package.json | 2 +- packages/wysiwyg/package.json | 2 +- 12 files changed, 15 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0faecbb..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 @@ -49,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/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 <ark-chart> 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 (`<ark-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/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/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 (<ark-wysiwyg-editor>, <ark-wysiwyg-viewer>) built on Tiptap, featuring sanitized JSON content", "keywords": [ "tooark",