Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<title>` 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.
133 changes: 133 additions & 0 deletions apps/storybook/stories/ark-code-editor.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>(".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));
}
};
107 changes: 107 additions & 0 deletions apps/storybook/stories/ark-copy-button.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
};
Loading
Loading