diff --git a/.agents/skills/codeatlas-ui/API.md b/.agents/skills/codeatlas-ui/API.md index 99f276f8..374bdc2d 100644 --- a/.agents/skills/codeatlas-ui/API.md +++ b/.agents/skills/codeatlas-ui/API.md @@ -1970,6 +1970,22 @@ SurfacePanel — a raised `
` (elevation 2; with an accessible name it i Also accepts: `Omit, 'as'>` +## SwatchGroup + +`import { SwatchGroup } from "@codeatlasdev/ui"` + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `value?` | `string` | `''` | `bind:` Selected `value`; leave unset or empty for "none" (for example while a custom color is active). | +| `items` | `SwatchItem[]` | | | +| `size?` | `'sm' \| 'md'` | `'md'` | | +| `disabled?` | `boolean` | `false` | | +| `class?` | `string` | `''` | | +| `aria-label?` | `string` | | | +| `onchange?` | `(value: string) => void` | | | + +Classes: `ca-swatch` `ca-swatch-group` `ca-swatch-group--sm` + ## Switch `import { Switch } from "@codeatlasdev/ui"` diff --git a/.changeset/swatch-group.md b/.changeset/swatch-group.md new file mode 100644 index 00000000..6042f566 --- /dev/null +++ b/.changeset/swatch-group.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +Add `SwatchGroup`: a single-choice color swatch picker (`role="radiogroup"`, roving focus, preset or custom `color`). Its coarse-pointer hit area is the swatch itself, so neighbouring swatches never overlap and every click lands on the swatch the user aimed at. diff --git a/AGENTS.md b/AGENTS.md index 9af417f9..cec3d6d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -464,11 +464,12 @@ import { DataTable, createCaTable, createCaColumnHelper, caTableFeatures } from - **Touch target**: `--ca-touch-target: 44px` (`tokens.css`). Under `@media (pointer: coarse)` it becomes `min-height`/`min-width` of `IconButton`, the `Select` trigger, `TabsTrigger`, `ToggleGroup`, `SegmentedControl`, `PinInput` and calendar cells. It is a media query, not a prop: do not write 44px overrides in the app. ### Inputs (own primitives with glass tokens) -`Button` · `IconButton` · `TextField` · `TextArea` · `Select` · `Combobox` · `Checkbox` · `Switch` · `Slider` · `NumberField` · `FileUpload` · `ToggleGroup` · `SegmentedControl` · `PinInput` · `CopyButton` · `DatePicker` · `DateRangePicker` · `DateField` · `TimeField` · `Calendar` · `RangeCalendar` +`Button` · `IconButton` · `TextField` · `TextArea` · `Select` · `Combobox` · `Checkbox` · `Switch` · `Slider` · `NumberField` · `FileUpload` · `ToggleGroup` · `SegmentedControl` · `SwatchGroup` · `PinInput` · `CopyButton` · `DatePicker` · `DateRangePicker` · `DateField` · `TimeField` · `Calendar` · `RangeCalendar` - **`Button` / `IconButton` `loading`**: swaps `leading` (Button) or the icon (IconButton) for a `Spinner`, applies `aria-busy="true"`, disables the click (`onclick` does not fire) and keeps the label. Class: `ca-btn--loading`. - **`ToggleGroup`**: `type="single" | "multiple"`, `bind:value` (`string` or `string[]`), `items: { value, label?, icon?, disabled?, 'aria-label'? }[]` (an icon-only item requires `aria-label`), `size="sm" | "md"`, `variant="outlined" | "ghost"`, `orientation`, `onchange`. - **`SegmentedControl`**: single `ToggleGroup` with a sliding pill (`.ca-seg__thumb`, no animation under `reducedMotion`). Props: `bind:value`, `items`, `size="sm" | "md"`, `fullWidth`, `disabled`, `onchange`. +- **`SwatchGroup`**: single-choice color picker (`role="radiogroup"`). `bind:value`, `items: { value, label?, color?, disabled? }[]` (without `color` the swatch paints from `data-preset={value}`; `value=""` means none selected), `size="sm" | "md"`, `disabled`, `onchange`. Under `pointer: coarse` the swatch itself is the 44px target. - **`PinInput`**: OTP. `length=6`, `bind:value`, `type="numeric" | "text"`, `label`, `description`, `error`, `mask`, `oncomplete(value)`. - **`CopyButton`**: `value`, `label="Copy"` (`''` = icon only), `size`, `variant="ghost"`, `toast?: string` (success message), `oncopy`. The icon becomes `Check` for 2s. - **Dates** (`DateValue` values from `@internationalized/date`, re-exported by the root: `CalendarDate`, `Time`, `today`, `parseDate`…): `locale="pt-BR"`, `weekStartsOn=1`, `minValue`, `maxValue`, `label`, `description`, `error`, `disabled`, `required`, `bind:value`. diff --git a/llms.txt b/llms.txt index 510ad251..268238b9 100644 --- a/llms.txt +++ b/llms.txt @@ -34,7 +34,7 @@ - [Docs primitives](packages/ui/src/lib/components/Docs/): DocsShell, DocsNav, TableOfContents, DocsSearch, CodeTabs/CodeTab, Callout, Steps, LinkCard, CardGrid, MethodBadge; `.ca-prose` typography in [prose.css](packages/ui/src/lib/styles/prose.css) - [AlertDialog](packages/ui/src/lib/components/AlertDialog/AlertDialog.svelte): confirm dialog (`title`, `confirmLabel`, `destructive`, `pending`, async `onconfirm`); replaces native `confirm()` - [Dialog](packages/ui/src/lib/components/Dialog/Dialog.svelte): `size="sm|md|lg|xl|split"` and `chromeless` -- `Button`/`IconButton` take `loading` (spinner, `aria-busy`, blocks click). `CopyButton`, `PinInput` (OTP), `ToggleGroup`, `SegmentedControl` are in the root import +- `Button`/`IconButton` take `loading` (spinner, `aria-busy`, blocks click). `CopyButton`, `PinInput` (OTP), `ToggleGroup`, `SegmentedControl`, `SwatchGroup` are in the root import - [Date components](packages/ui/src/lib/components/Date/): DatePicker, DateRangePicker, DateField, TimeField, Calendar, RangeCalendar (`@internationalized/date` values, pt-BR default; `CalendarDate`, `Time`, `today` re-exported) - `SimpleTable responsive="cards"`: rows become cards below `md`; every `` needs `data-label`. `--ca-touch-target` (44px) applies under `pointer: coarse` - [Forms subpath](packages/ui/src/lib/forms/): `@codeatlasdev/ui/forms` (no peer): MaskedField, MoneyInput (value in minor units; `currency`, `locale`; BRL default), PhoneField (E.164), CepField (`onlookup` injected, no fetch), `use:mask`, maskCPF/maskCNPJ/maskCPFCNPJ/maskCEP/maskPhoneBR/maskDateBR/maskRG, isValidCPF/isValidCNPJ/isValidDateBR, centsToDisplay, parseInputToCents, currencyDecimals. `useForm` supports `arrays` (`FieldArrayState`: add/remove/move) diff --git a/packages/ui/src/lib/components/SwatchGroup/SwatchGroup.svelte b/packages/ui/src/lib/components/SwatchGroup/SwatchGroup.svelte new file mode 100644 index 00000000..dfdcd077 --- /dev/null +++ b/packages/ui/src/lib/components/SwatchGroup/SwatchGroup.svelte @@ -0,0 +1,165 @@ + + + + +
+ {#each items as item (item.value)} + {@const on = item.value === value} + {@const off = disabled || !!item.disabled} + + {/each} +
+ + diff --git a/packages/ui/src/lib/components/SwatchGroup/SwatchGroup.svelte.test.ts b/packages/ui/src/lib/components/SwatchGroup/SwatchGroup.svelte.test.ts new file mode 100644 index 00000000..7074e449 --- /dev/null +++ b/packages/ui/src/lib/components/SwatchGroup/SwatchGroup.svelte.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; +import { render } from "vitest-browser-svelte"; +import { checkA11y } from "../../../../tests/a11y"; +import SwatchGroupHarness from "./SwatchGroupHarness.test.svelte"; + +describe("SwatchGroup", () => { + it("is a radiogroup with named radios and passes axe", async () => { + const screen = await render(SwatchGroupHarness, { "aria-label": "Brand", initial: "ocean" }); + await expect + .element(screen.getByRole("radio", { name: "Ocean blue" })) + .toHaveAttribute("aria-checked", "true"); + await expect + .element(screen.getByRole("radio", { name: "amber" })) + .toHaveAttribute("aria-checked", "false"); + expect(await checkA11y(screen.container)).toHaveNoViolations(); + }); + + it("click selects, calls onchange once and ignores the active or disabled swatch", async () => { + const onchange = vi.fn(); + const screen = await render(SwatchGroupHarness, { "aria-label": "Brand", onchange }); + await screen.getByRole("radio", { name: "amber" }).click(); + await screen.getByRole("radio", { name: "amber" }).click(); + expect(onchange).toHaveBeenCalledTimes(1); + expect(onchange).toHaveBeenLastCalledWith("amber"); + await screen.getByRole("radio", { name: "violet" }).click({ force: true }); + expect(onchange).toHaveBeenCalledTimes(1); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("amber"); + }); + + it("keyboard: one tab stop, arrows move focus and skip disabled, Space selects", async () => { + const screen = await render(SwatchGroupHarness, { "aria-label": "Brand", initial: "amber" }); + screen.getByRole("radio", { name: "amber" }).element().focus(); + await userEvent.keyboard("{ArrowRight}"); + expect(document.activeElement).toBe( + screen.getByRole("radio", { name: "Ocean blue" }).element() + ); + await userEvent.keyboard("{ArrowRight}"); + expect(document.activeElement).toBe(screen.getByRole("radio", { name: "Brand" }).element()); + await userEvent.keyboard(" "); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("brand"); + }); + + it("paints from the preset by default and from `color` when given", async () => { + const screen = await render(SwatchGroupHarness, { "aria-label": "Brand" }); + const amber = screen.getByRole("radio", { name: "amber" }).element(); + expect(amber.getAttribute("data-preset")).toBe("amber"); + const brand = screen.getByRole("radio", { name: "Brand" }).element(); + expect(brand.hasAttribute("data-preset")).toBe(false); + expect(getComputedStyle(brand).backgroundColor).toBe("rgb(232, 89, 12)"); + }); + + // Regression (#122): 44px ::after tap pads on 26px swatches at a 34px pitch overlapped, so the + // neighbour's pad intercepted the click and selecting a swatch silently failed. A swatch's tap + // area (its box plus its ::after pad) must stay inside its own slot. + it("no swatch tap area reaches into a neighbouring swatch", async () => { + const screen = await render(SwatchGroupHarness, { "aria-label": "Brand" }); + const radios = screen.getByRole("radio").elements() as HTMLElement[]; + const tapArea = (el: HTMLElement) => { + const b = el.getBoundingClientRect(); + const a = getComputedStyle(el, "::after"); + if (a.content === "none" || a.position !== "absolute") return b; + const w = Number.parseFloat(a.width); + const h = Number.parseFloat(a.height); + const cx = b.left + b.width / 2; + const cy = b.top + b.height / 2; + return { left: cx - w / 2, right: cx + w / 2, top: cy - h / 2, bottom: cy + h / 2 }; + }; + const areas = radios.map(tapArea); + for (let i = 1; i < areas.length; i++) { + expect(areas[i].left, `swatch ${i} overlaps swatch ${i - 1}`).toBeGreaterThanOrEqual( + areas[i - 1].right + ); + } + for (const r of radios) { + const b = r.getBoundingClientRect(); + const at = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); + expect(at?.closest(".ca-swatch")).toBe(r); + } + }); +}); diff --git a/packages/ui/src/lib/components/SwatchGroup/SwatchGroupHarness.test.svelte b/packages/ui/src/lib/components/SwatchGroup/SwatchGroupHarness.test.svelte new file mode 100644 index 00000000..6f0437f5 --- /dev/null +++ b/packages/ui/src/lib/components/SwatchGroup/SwatchGroupHarness.test.svelte @@ -0,0 +1,19 @@ + + + +{value} diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts index 8ee16582..8b22cc7e 100644 --- a/packages/ui/src/lib/index.ts +++ b/packages/ui/src/lib/index.ts @@ -165,6 +165,8 @@ export { default as Stepper } from "./components/Stepper/Stepper.svelte"; export { default as StickyActions } from "./components/StickyActions/StickyActions.svelte"; export { default as Surface } from "./components/Surface/Surface.svelte"; export { default as SurfacePanel } from "./components/SurfacePanel/SurfacePanel.svelte"; +export type { SwatchItem } from "./components/SwatchGroup/SwatchGroup.svelte"; +export { default as SwatchGroup } from "./components/SwatchGroup/SwatchGroup.svelte"; export { default as Switch } from "./components/Switch/Switch.svelte"; // ── TABLE (listagens simples) ───────────────────────────────────────────────── export { SimpleTable, UserCell } from "./components/Table/index.js"; diff --git a/site/src/components/landing/FinaleActions.svelte b/site/src/components/landing/FinaleActions.svelte index 9044426a..e11c7132 100644 --- a/site/src/components/landing/FinaleActions.svelte +++ b/site/src/components/landing/FinaleActions.svelte @@ -14,11 +14,11 @@ {#snippet leading()}