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
16 changes: 16 additions & 0 deletions .agents/skills/codeatlas-ui/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -1970,6 +1970,22 @@ SurfacePanel — a raised `<section>` (elevation 2; with an accessible name it i

Also accepts: `Omit<ComponentProps<typeof Surface>, '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"`
Expand Down
5 changes: 5 additions & 0 deletions .changeset/swatch-group.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<td>` 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)
Expand Down
165 changes: 165 additions & 0 deletions packages/ui/src/lib/components/SwatchGroup/SwatchGroup.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<script lang="ts" module>
export interface SwatchItem {
/** Selected value. When `color` is omitted it is also the `data-preset` the swatch paints from. */
value: string;
/** Accessible name; falls back to `value`. */
label?: string;
/** Any CSS color. Omit to paint from the preset named by `value`. */
color?: string;
disabled?: boolean;
}
</script>

<script lang="ts">
import { Check } from '@lucide/svelte';
import { rovingFocus } from '../../primitives/index.js';

interface Props {
/** Selected `value`; leave unset or empty for "none" (for example while a custom color is active). */
value?: string;
items: SwatchItem[];
size?: 'sm' | 'md';
disabled?: boolean;
class?: string;
'aria-label'?: string;
onchange?: (value: string) => void;
}

let {
value = $bindable(''),
items,
size = 'md',
disabled = false,
class: className = '',
'aria-label': ariaLabel,
onchange,
}: Props = $props();

function select(item: SwatchItem) {
if (disabled || item.disabled || item.value === value) return;
value = item.value;
onchange?.(item.value);
}

function roving(node: HTMLElement) {
return rovingFocus(node, { selector: '.ca-swatch', orientation: 'horizontal' });
}
</script>

<div
role="radiogroup"
aria-label={ariaLabel}
aria-disabled={disabled ? 'true' : undefined}
data-disabled={disabled ? '' : undefined}
class="ca-swatch-group ca-swatch-group--{size} {className}"
{@attach roving}
>
{#each items as item (item.value)}
{@const on = item.value === value}
{@const off = disabled || !!item.disabled}
<button
type="button"
role="radio"
aria-checked={on}
aria-label={item.label ?? item.value}
disabled={off}
data-state={on ? 'on' : 'off'}
data-disabled={off ? '' : undefined}
data-preset={item.color ? undefined : item.value}
class="ca-swatch"
style={item.color ? `--ca-swatch-color:${item.color}` : undefined}
onclick={() => select(item)}
>
{#if on}<Check size={size === 'sm' ? 12 : 14} stroke-width={3} aria-hidden="true" />{/if}
</button>
{/each}
</div>

<style>
@layer ca.reset, ca.tokens, ca.themes, ca.base, ca.materials, ca.utilities, ca.components;
@layer ca.components {
:global(.ca-swatch-group) {
--ca-swatch-size: var(--ca-space-6);
display: inline-flex;
flex-wrap: wrap;
align-items: center;
/* The gap is the touch pad budget: two 44px pads never overlap when the gap plus the
swatch is at least the pad. Under coarse pointers the swatch itself grows to it. */
gap: var(--ca-space-2);
}

:global(.ca-swatch-group--sm) {
--ca-swatch-size: var(--ca-space-5);
}

:global(.ca-swatch) {
position: relative;
display: inline-grid;
place-items: center;
inline-size: var(--ca-swatch-size);
block-size: var(--ca-swatch-size);
padding: 0;
border: none;
border-radius: var(--ca-radius-pill);
background: var(--ca-swatch-color, var(--ca-brand-main));
color: var(--ca-brand-contrast);
cursor: pointer;
box-shadow: 0 0 0 var(--ca-border-width) var(--ca-outline-default);
transition: box-shadow var(--ca-duration-fast) var(--ca-ease-standard);
}

/* A custom color has no preset to take a contrast color from. A white check with a dark halo
stays legible on any fill, so no contrast math is needed. */
:global(.ca-swatch[style*='--ca-swatch-color']) {
color: #fff;
}
:global(.ca-swatch[style*='--ca-swatch-color'] svg) {
filter: drop-shadow(0 0 1px #000) drop-shadow(0 0 1px #000);
}

:global(.ca-swatch[data-state='on']) {
box-shadow:
0 0 0 var(--ca-space-0-5) var(--ca-surface-base),
0 0 0 calc(var(--ca-space-0-5) + var(--ca-border-width-focus)) var(--ca-text-primary);
}

:global(.ca-swatch:focus-visible) {
outline: var(--ca-border-width-focus) solid var(--ca-focus-ring);
outline-offset: var(--ca-space-1);
}

/* A disabled swatch drops its color for the neutral disabled fill: opacity would still look pickable. */
:global(.ca-swatch[disabled]) {
cursor: not-allowed;
background: var(--ca-action-disabled-bg);
color: var(--ca-text-disabled);
box-shadow: 0 0 0 var(--ca-border-width) var(--ca-outline-subtle);
}

/* Coarse pointers: the swatch itself is the 44px target, so neighbours can never overlap
(an expanded ::after pad would, at this pitch). */
@media (pointer: coarse) {
:global(.ca-swatch-group) {
gap: var(--ca-space-1);
}
:global(.ca-swatch) {
inline-size: var(--ca-touch-target);
block-size: var(--ca-touch-target);
background-clip: content-box;
padding: calc((var(--ca-touch-target) - var(--ca-swatch-size)) / 2);
}
}

@media (forced-colors: active) {
:global(.ca-swatch) {
forced-color-adjust: none;
box-shadow: 0 0 0 var(--ca-border-width) CanvasText;
}
:global(.ca-swatch[data-state='on']) {
box-shadow:
0 0 0 var(--ca-space-0-5) Canvas,
0 0 0 calc(var(--ca-space-0-5) + var(--ca-border-width-focus)) Highlight;
}
}
}
</style>
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script lang="ts">
import { untrack } from 'svelte';
import SwatchGroup from './SwatchGroup.svelte';

let { initial = '', ...rest }: { initial?: string; [key: string]: unknown } = $props();
let value = $state(untrack(() => initial));
</script>

<SwatchGroup
bind:value
{...rest}
items={[
{ value: 'amber' },
{ value: 'ocean', label: 'Ocean blue' },
{ value: 'violet', disabled: true },
{ value: 'brand', label: 'Brand', color: '#e8590c' },
]}
/>
<output data-testid="bound">{value}</output>
2 changes: 2 additions & 0 deletions packages/ui/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
6 changes: 3 additions & 3 deletions site/src/components/landing/FinaleActions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
{#snippet leading()}<BookOpen size={18} aria-hidden="true" />{/snippet}
Read the docs
</Button>
<Button size="lg" variant="glass" href={repo}>
Star on GitHub
<Button size="lg" variant="outlined" href={repo}>
See it on GitHub
{#snippet trailing()}<ArrowUpRight size={18} aria-hidden="true" />{/snippet}
</Button>
<Button size="lg" variant="outlined" href={npm}>
<Button size="lg" variant="ghost" href={npm}>
{#snippet leading()}<Package size={18} aria-hidden="true" />{/snippet}
npm
</Button>
Loading
Loading