@eifi1/ui-kit 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -1
- package/dist/components/amount-input.d.ts +7 -0
- package/dist/components/autocomplete.d.ts +101 -0
- package/dist/components/autocomplete.js +260 -0
- package/dist/components/autocomplete.js.map +1 -0
- package/dist/components/calculator.d.ts +7 -0
- package/dist/components/chip.d.ts +22 -7
- package/dist/components/chip.js +14 -1
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +100 -0
- package/dist/components/choice-card.js +170 -0
- package/dist/components/choice-card.js.map +1 -0
- package/dist/components/combobox-core.d.ts +76 -6
- package/dist/components/combobox-core.js +119 -49
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +12 -2
- package/dist/components/combobox.js +42 -17
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.js +10 -2
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +91 -0
- package/dist/components/danger-confirm.js +181 -0
- package/dist/components/danger-confirm.js.map +1 -0
- package/dist/components/date-picker.js +19 -7
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +84 -0
- package/dist/components/dialog-frame.js +86 -0
- package/dist/components/dialog-frame.js.map +1 -0
- package/dist/components/disclosure.d.ts +108 -0
- package/dist/components/disclosure.js +127 -0
- package/dist/components/disclosure.js.map +1 -0
- package/dist/components/entity-combobox.d.ts +17 -3
- package/dist/components/entity-combobox.js +25 -5
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/file-button.d.ts +161 -0
- package/dist/components/file-button.js +225 -0
- package/dist/components/file-button.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +72 -23
- package/dist/components/file-dropzone.js +219 -94
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/icon-picker.d.ts +72 -0
- package/dist/components/icon-picker.js +104 -0
- package/dist/components/icon-picker.js.map +1 -0
- package/dist/components/mini-calendar.d.ts +3 -0
- package/dist/components/mini-calendar.js +4 -3
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +8 -1
- package/dist/components/modal.js +4 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.js +16 -5
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +16 -3
- package/dist/components/multi-entity-combobox.js +25 -5
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +41 -1
- package/dist/components/number-field.js +42 -10
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +35 -2
- package/dist/components/number-input.js +35 -4
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +7 -0
- package/dist/components/popover.js +4 -1
- package/dist/components/popover.js.map +1 -1
- package/dist/components/search-field.d.ts +16 -0
- package/dist/components/search-field.js +29 -7
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/signature-pad.d.ts +43 -1
- package/dist/components/signature-pad.js +74 -2
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +69 -0
- package/dist/components/swatch-picker.js +75 -0
- package/dist/components/swatch-picker.js.map +1 -0
- package/dist/components/tile-radio.d.ts +50 -0
- package/dist/components/tile-radio.js +140 -0
- package/dist/components/tile-radio.js.map +1 -0
- package/dist/components/toggle-group.d.ts +27 -5
- package/dist/components/toggle-group.js +22 -15
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/trigger-aria.d.ts +18 -0
- package/dist/components/trigger-aria.js +25 -0
- package/dist/components/trigger-aria.js.map +1 -0
- package/dist/components/ui.d.ts +164 -18
- package/dist/components/ui.js +207 -34
- package/dist/components/ui.js.map +1 -1
- package/dist/i18n/defaults.d.ts +7 -0
- package/dist/i18n/defaults.js +13 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +38 -5
- package/dist/i18n/kit-labels.js +12 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/index.d.ts +16 -7
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/table-text.d.ts +127 -0
- package/dist/lib/table-text.js +82 -0
- package/dist/lib/table-text.js.map +1 -0
- package/dist/rhf/form.d.ts +79 -0
- package/dist/rhf/form.js +143 -0
- package/dist/rhf/form.js.map +1 -0
- package/dist/rhf.d.ts +4 -0
- package/dist/rhf.js +3 -0
- package/dist/rhf.js.map +1 -0
- package/dist/shell/app-shell.js +3 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/table-text.d.ts +1 -0
- package/dist/table-text.js +3 -0
- package/dist/table-text.js.map +1 -0
- package/package.json +14 -1
- package/src/components/autocomplete.tsx +425 -0
- package/src/components/chip.tsx +43 -7
- package/src/components/choice-card.tsx +305 -0
- package/src/components/combobox-core.tsx +228 -58
- package/src/components/combobox.tsx +58 -21
- package/src/components/currency-select.tsx +16 -2
- package/src/components/danger-confirm.tsx +286 -0
- package/src/components/date-picker.tsx +31 -6
- package/src/components/dialog-frame.tsx +179 -0
- package/src/components/disclosure.tsx +259 -0
- package/src/components/entity-combobox.tsx +41 -6
- package/src/components/file-button.tsx +431 -0
- package/src/components/file-dropzone.tsx +323 -117
- package/src/components/icon-picker.tsx +181 -0
- package/src/components/mini-calendar.tsx +7 -3
- package/src/components/modal.tsx +10 -2
- package/src/components/month-picker.tsx +22 -4
- package/src/components/multi-entity-combobox.tsx +40 -6
- package/src/components/number-field.tsx +86 -10
- package/src/components/number-input.tsx +79 -2
- package/src/components/popover.tsx +4 -1
- package/src/components/search-field.tsx +49 -6
- package/src/components/signature-pad.tsx +112 -0
- package/src/components/swatch-picker.tsx +141 -0
- package/src/components/tile-radio.tsx +228 -0
- package/src/components/toggle-group.tsx +54 -18
- package/src/components/trigger-aria.ts +42 -0
- package/src/components/ui.tsx +427 -40
- package/src/i18n/defaults.ts +12 -1
- package/src/i18n/kit-labels.tsx +45 -5
- package/src/index.ts +19 -0
- package/src/lib/table-text.ts +265 -0
- package/src/rhf/form.tsx +300 -0
- package/src/rhf.ts +9 -0
- package/src/shell/app-shell.tsx +3 -1
- package/src/table-text.ts +8 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useMemo } from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
6
|
+
import { TILE_SIZE, TileRadioGroup } from "./tile-radio.js";
|
|
7
|
+
const DEFAULT_SWATCH_PICKER_LABELS = {
|
|
8
|
+
none: "No colour",
|
|
9
|
+
mixed: "Mixed: the selected items have different colours"
|
|
10
|
+
};
|
|
11
|
+
function SwatchPicker({
|
|
12
|
+
options,
|
|
13
|
+
value,
|
|
14
|
+
onChange,
|
|
15
|
+
allowNone = false,
|
|
16
|
+
mixed = false,
|
|
17
|
+
activation = "automatic",
|
|
18
|
+
size = "md",
|
|
19
|
+
disabled = false,
|
|
20
|
+
labels,
|
|
21
|
+
className,
|
|
22
|
+
...rest
|
|
23
|
+
}) {
|
|
24
|
+
const text = useKitLabels("swatchPicker", DEFAULT_SWATCH_PICKER_LABELS, labels);
|
|
25
|
+
const mixedId = useId();
|
|
26
|
+
const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
|
|
27
|
+
const items = [
|
|
28
|
+
...allowNone ? [{ key: null, label: text.none }] : [],
|
|
29
|
+
...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled }))
|
|
30
|
+
];
|
|
31
|
+
return /* @__PURE__ */ jsxs(
|
|
32
|
+
"div",
|
|
33
|
+
{
|
|
34
|
+
...rest,
|
|
35
|
+
role: "radiogroup",
|
|
36
|
+
"aria-disabled": disabled || void 0,
|
|
37
|
+
"aria-describedby": [rest["aria-describedby"], mixed && mixedId].filter(Boolean).join(" ") || void 0,
|
|
38
|
+
className: cn("flex flex-wrap items-center gap-1.5", className),
|
|
39
|
+
children: [
|
|
40
|
+
/* @__PURE__ */ jsx(
|
|
41
|
+
TileRadioGroup,
|
|
42
|
+
{
|
|
43
|
+
items,
|
|
44
|
+
checked: mixed ? void 0 : value,
|
|
45
|
+
onSelect: onChange,
|
|
46
|
+
activation,
|
|
47
|
+
disabled,
|
|
48
|
+
size,
|
|
49
|
+
renderTile: (item) => {
|
|
50
|
+
const opt = item.key === null ? void 0 : byValue.get(item.key);
|
|
51
|
+
return /* @__PURE__ */ jsx(
|
|
52
|
+
"span",
|
|
53
|
+
{
|
|
54
|
+
"aria-hidden": true,
|
|
55
|
+
className: cn(
|
|
56
|
+
"rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15",
|
|
57
|
+
TILE_SIZE[size].swatch,
|
|
58
|
+
opt?.swatchClassName
|
|
59
|
+
),
|
|
60
|
+
style: { background: opt?.color }
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
),
|
|
66
|
+
mixed && /* @__PURE__ */ jsx("span", { id: mixedId, hidden: true, children: text.mixed })
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
export {
|
|
72
|
+
DEFAULT_SWATCH_PICKER_LABELS,
|
|
73
|
+
SwatchPicker
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=swatch-picker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/swatch-picker.tsx"],"sourcesContent":["import { useId, useMemo } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\n\nexport interface SwatchPickerLabels {\n /** The \"no colour\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n}\n\nexport const DEFAULT_SWATCH_PICKER_LABELS: SwatchPickerLabels = {\n none: \"No colour\",\n mixed: \"Mixed: the selected items have different colours\",\n};\n\nexport interface SwatchOption<T extends string> {\n value: T;\n /** Any CSS colour — a token (`var(--chart-3)`), a hex, an `oklch()`. Painted as an\n * inline background, so it does not have to be a class the kit's CSS knows.\n * Leave it out when `swatchClassName` paints the dot instead. */\n color?: string;\n /** The colour's name, in the user's language. Shown in the bubble and read out;\n * required, because a swatch with no name is a colour only some people can read. */\n label: string;\n /** Classes for the swatch dot, for a palette that lives in classes rather than\n * values (a light/dark pair). An inline `color` would win over a class, so pass\n * one or the other. */\n swatchClassName?: string;\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Groceries\"). The tile wears a dot while it has one. */\n note?: string;\n disabled?: boolean;\n}\n\n/**\n * `onChange` is the picker's own — the chosen VALUE, not a DOM event — so the div's\n * is omitted rather than shadowed, as on {@link ToggleGroup}.\n */\nexport interface SwatchPickerProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\"> {\n options: SwatchOption<T>[];\n /** The chosen colour, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no colour\" tile whose value is `null`. Clearing a colour has to be\n * as reachable as setting one, and a radio cannot be unchecked by clicking it. */\n allowNone?: boolean;\n /**\n * \"Some of each\" — a bulk edit over rows whose colours differ. Nothing is checked\n * (not even \"none\", whatever `value` says), and the group is described as mixed.\n * Distinct from `value={null}`, which is an answer: \"no colour\".\n */\n mixed?: boolean;\n /** See {@link IconPickerProps.activation}. Default \"automatic\". */\n activation?: \"automatic\" | \"manual\";\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n disabled?: boolean;\n labels?: Partial<SwatchPickerLabels>;\n}\n\n/**\n * A row of colour swatches that is one radio group.\n *\n * Name the group with `aria-label` or `aria-labelledby` — usually the visible heading\n * above it. Each swatch is named by its `label` and shows it in a bubble on hover and\n * focus, so the name is not the screen reader's alone.\n *\n * Keyboard (the APG radio group): Tab reaches the checked swatch, or the first one\n * while nothing is checked; the arrow keys move between swatches and, by default,\n * choose as they go; Home and End jump to the ends. Left and right follow the page's\n * direction.\n */\nexport function SwatchPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n size = \"md\",\n disabled = false,\n labels,\n className,\n ...rest\n}: SwatchPickerProps<T>) {\n const text = useKitLabels(\"swatchPicker\", DEFAULT_SWATCH_PICKER_LABELS, labels);\n const mixedId = useId();\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),\n ];\n return (\n <div\n {...rest}\n role=\"radiogroup\"\n aria-disabled={disabled || undefined}\n aria-describedby={\n [rest[\"aria-describedby\"], mixed && mixedId].filter(Boolean).join(\" \") || undefined\n }\n className={cn(\"flex flex-wrap items-center gap-1.5\", className)}\n >\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n onSelect={onChange}\n activation={activation}\n disabled={disabled}\n size={size}\n renderTile={(item) => {\n const opt = item.key === null ? undefined : byValue.get(item.key);\n return (\n <span\n aria-hidden\n // A hairline round the dot, drawn over whatever colour it is: a pale\n // yellow swatch on a white surface is otherwise a hole in the row.\n className={cn(\n \"rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15\",\n TILE_SIZE[size].swatch,\n opt?.swatchClassName,\n )}\n style={{ background: opt?.color }}\n />\n );\n }}\n />\n {/* `hidden`, not `sr-only`: a description is read from hidden text, and this\n way it takes no room in the row. */}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n </div>\n );\n}\n"],"mappings":";AAkGI,SAmBQ,KAnBR;AAlGJ,SAAS,OAAO,eAAe;AAE/B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,WAAW,sBAAsB;AAUnC,MAAM,+BAAmD;AAAA,EAC9D,MAAM;AAAA,EACN,OAAO;AACT;AA4DO,SAAS,aAA+B;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,OAAO;AAAA,EACP,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAyB;AACvB,QAAM,OAAO,aAAa,gBAAgB,8BAA8B,MAAM;AAC9E,QAAM,UAAU,MAAM;AACtB,QAAM,UAAU,QAAQ,MAAM,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC;AAClF,QAAM,QAAuB;AAAA,IAC3B,GAAI,YAAY,CAAC,EAAE,KAAK,MAAM,OAAO,KAAK,KAAK,CAAC,IAAI,CAAC;AAAA,IACrD,GAAG,QAAQ,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,OAAO,OAAO,EAAE,OAAO,MAAM,EAAE,MAAM,UAAU,EAAE,SAAS,EAAE;AAAA,EAC9F;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,MAAK;AAAA,MACL,iBAAe,YAAY;AAAA,MAC3B,oBACE,CAAC,KAAK,kBAAkB,GAAG,SAAS,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG,KAAK;AAAA,MAE5E,WAAW,GAAG,uCAAuC,SAAS;AAAA,MAE9D;AAAA;AAAA,UAAC;AAAA;AAAA,YACC;AAAA,YACA,SAAS,QAAQ,SAAY;AAAA,YAC7B,UAAU;AAAA,YACV;AAAA,YACA;AAAA,YACA;AAAA,YACA,YAAY,CAAC,SAAS;AACpB,oBAAM,MAAM,KAAK,QAAQ,OAAO,SAAY,QAAQ,IAAI,KAAK,GAAG;AAChE,qBACE;AAAA,gBAAC;AAAA;AAAA,kBACC,eAAW;AAAA,kBAGX,WAAW;AAAA,oBACT;AAAA,oBACA,UAAU,IAAI,EAAE;AAAA,oBAChB,KAAK;AAAA,kBACP;AAAA,kBACA,OAAO,EAAE,YAAY,KAAK,MAAM;AAAA;AAAA,cAClC;AAAA,YAEJ;AAAA;AAAA,QACF;AAAA,QAGC,SACC,oBAAC,UAAK,IAAI,SAAS,QAAM,MACtB,eAAK,OACR;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The machinery {@link SwatchPicker} and {@link IconPicker} share: a row of square
|
|
6
|
+
* tiles that behaves as ONE radio group. Private to the package — not re-exported
|
|
7
|
+
* from the barrel.
|
|
8
|
+
*
|
|
9
|
+
* Keksdose had three of these (the category colours, the category symbols, the
|
|
10
|
+
* transaction flags) and all three were rows of `aria-pressed` buttons: a separate
|
|
11
|
+
* tab stop per tile, and "pressed" on a choice that is really one-of-n. The APG radio
|
|
12
|
+
* group is the shape a screen reader expects for that ("Red, radio, 2 of 7, checked")
|
|
13
|
+
* and the one a keyboard expects too — a single tab stop, arrows to move.
|
|
14
|
+
*/
|
|
15
|
+
type TileSize = "sm" | "md" | "lg";
|
|
16
|
+
/** 28 / 32 / 44px. `lg` is the touch target a phone surface wants (`min-h-11`). */
|
|
17
|
+
declare const TILE_SIZE: Record<TileSize, {
|
|
18
|
+
tile: string;
|
|
19
|
+
glyph: string;
|
|
20
|
+
swatch: string;
|
|
21
|
+
}>;
|
|
22
|
+
/** One tile, whatever it shows. `key` is `null` for the "none" entry. */
|
|
23
|
+
interface TileItem<T extends string> {
|
|
24
|
+
key: T | null;
|
|
25
|
+
label: string;
|
|
26
|
+
/** Said after the name, and shown in the bubble under it. Marks the tile with a
|
|
27
|
+
* dot, so the note is visible without hovering every tile to find it. */
|
|
28
|
+
note?: string;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
interface TileRadioGroupProps<T extends string> {
|
|
32
|
+
items: TileItem<T>[];
|
|
33
|
+
/** The checked key, or `undefined` for "nothing is checked" (the mixed state, or
|
|
34
|
+
* a value that is not among the options). */
|
|
35
|
+
checked: T | null | undefined;
|
|
36
|
+
onSelect: (key: T | null) => void;
|
|
37
|
+
/** "automatic": an arrow key moves AND checks, the APG default. "manual": arrows
|
|
38
|
+
* move focus only, Space or Enter checks — for a picker whose change is expensive
|
|
39
|
+
* (a bulk edit that saves on every change). */
|
|
40
|
+
activation: "automatic" | "manual";
|
|
41
|
+
disabled: boolean;
|
|
42
|
+
size: TileSize;
|
|
43
|
+
/** What goes inside a tile. The frame, the tick and the note's dot are drawn here. */
|
|
44
|
+
renderTile: (item: TileItem<T>, selected: boolean) => ReactNode;
|
|
45
|
+
/** Extra classes for a tile, per item. */
|
|
46
|
+
tileClassName?: (item: TileItem<T>, selected: boolean) => string | undefined;
|
|
47
|
+
}
|
|
48
|
+
declare function TileRadioGroup<T extends string>({ items, checked, onSelect, activation, disabled, size, renderTile, tileClassName, }: TileRadioGroupProps<T>): react.JSX.Element;
|
|
49
|
+
|
|
50
|
+
export { TILE_SIZE, type TileItem, TileRadioGroup, type TileRadioGroupProps, type TileSize };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useRef } from "react";
|
|
4
|
+
import { Ban, Check } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { Tooltip } from "./tooltip.js";
|
|
7
|
+
const TILE_SIZE = {
|
|
8
|
+
sm: { tile: "size-7", glyph: "size-3.5", swatch: "size-4" },
|
|
9
|
+
md: { tile: "size-8", glyph: "size-4", swatch: "size-5" },
|
|
10
|
+
lg: { tile: "size-11", glyph: "size-5", swatch: "size-6" }
|
|
11
|
+
};
|
|
12
|
+
const TILE_BASE = "relative inline-flex shrink-0 items-center justify-center rounded-md border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface)] disabled:cursor-not-allowed disabled:opacity-50";
|
|
13
|
+
const TILE_IDLE = "border-[var(--border)] bg-[var(--bg-surface)] hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]";
|
|
14
|
+
const TILE_SELECTED = "border-[var(--text-primary)] bg-[var(--bg-surface)] ring-1 ring-[var(--text-primary)]";
|
|
15
|
+
function TileRadioGroup({
|
|
16
|
+
items,
|
|
17
|
+
checked,
|
|
18
|
+
onSelect,
|
|
19
|
+
activation,
|
|
20
|
+
disabled,
|
|
21
|
+
size,
|
|
22
|
+
renderTile,
|
|
23
|
+
tileClassName
|
|
24
|
+
}) {
|
|
25
|
+
const refs = useRef([]);
|
|
26
|
+
const noteId = useId();
|
|
27
|
+
const s = TILE_SIZE[size];
|
|
28
|
+
const checkedIndex = checked === void 0 ? -1 : items.findIndex((it) => it.key === checked);
|
|
29
|
+
const tabStop = checkedIndex >= 0 && !items[checkedIndex].disabled ? checkedIndex : items.findIndex((it) => !it.disabled);
|
|
30
|
+
const onKeyDown = (e, index) => {
|
|
31
|
+
const rtl = e.currentTarget.closest("[dir]")?.getAttribute("dir") === "rtl";
|
|
32
|
+
let step;
|
|
33
|
+
switch (e.key) {
|
|
34
|
+
case "ArrowRight":
|
|
35
|
+
step = rtl ? -1 : 1;
|
|
36
|
+
break;
|
|
37
|
+
case "ArrowLeft":
|
|
38
|
+
step = rtl ? 1 : -1;
|
|
39
|
+
break;
|
|
40
|
+
case "ArrowDown":
|
|
41
|
+
step = 1;
|
|
42
|
+
break;
|
|
43
|
+
case "ArrowUp":
|
|
44
|
+
step = -1;
|
|
45
|
+
break;
|
|
46
|
+
case "Home":
|
|
47
|
+
step = "first";
|
|
48
|
+
break;
|
|
49
|
+
case "End":
|
|
50
|
+
step = "last";
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
const enabled = items.map((it, i) => it.disabled ? -1 : i).filter((i) => i >= 0);
|
|
57
|
+
if (!enabled.length) return;
|
|
58
|
+
const at = enabled.indexOf(index);
|
|
59
|
+
let target;
|
|
60
|
+
if (step === "first") target = enabled[0];
|
|
61
|
+
else if (step === "last") target = enabled[enabled.length - 1];
|
|
62
|
+
else target = enabled[(at + step + enabled.length) % enabled.length];
|
|
63
|
+
refs.current[target]?.focus();
|
|
64
|
+
if (activation === "automatic") onSelect(items[target].key);
|
|
65
|
+
};
|
|
66
|
+
return /* @__PURE__ */ jsx(Fragment, { children: items.map((item, i) => {
|
|
67
|
+
const selected = i === checkedIndex;
|
|
68
|
+
const button = /* @__PURE__ */ jsxs(
|
|
69
|
+
"button",
|
|
70
|
+
{
|
|
71
|
+
ref: (el) => {
|
|
72
|
+
refs.current[i] = el;
|
|
73
|
+
},
|
|
74
|
+
type: "button",
|
|
75
|
+
role: "radio",
|
|
76
|
+
"aria-checked": selected,
|
|
77
|
+
"aria-label": item.label,
|
|
78
|
+
"aria-describedby": item.note ? `${noteId}-${i}` : void 0,
|
|
79
|
+
tabIndex: i === tabStop ? 0 : -1,
|
|
80
|
+
disabled: disabled || item.disabled,
|
|
81
|
+
onClick: () => !selected && onSelect(item.key),
|
|
82
|
+
onKeyDown: (e) => onKeyDown(e, i),
|
|
83
|
+
className: cn(
|
|
84
|
+
TILE_BASE,
|
|
85
|
+
s.tile,
|
|
86
|
+
selected ? TILE_SELECTED : TILE_IDLE,
|
|
87
|
+
tileClassName?.(item, selected)
|
|
88
|
+
),
|
|
89
|
+
children: [
|
|
90
|
+
item.key === null ? /* @__PURE__ */ jsx(Ban, { "aria-hidden": true, className: cn(s.glyph, "text-[var(--text-muted)]") }) : renderTile(item, selected),
|
|
91
|
+
item.note && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
92
|
+
/* @__PURE__ */ jsx(
|
|
93
|
+
"span",
|
|
94
|
+
{
|
|
95
|
+
"aria-hidden": true,
|
|
96
|
+
className: "absolute end-0.5 top-0.5 size-1.5 rounded-full bg-[var(--text-muted)]"
|
|
97
|
+
}
|
|
98
|
+
),
|
|
99
|
+
/* @__PURE__ */ jsx("span", { id: `${noteId}-${i}`, hidden: true, children: item.note })
|
|
100
|
+
] }),
|
|
101
|
+
selected && /* @__PURE__ */ jsx(SelectedTick, {})
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
return (
|
|
106
|
+
// The bubble is the SIGHTED user's name for the tile — eight unlabelled
|
|
107
|
+
// squares are the complaint that put a bubble on Keksdose's (live #289).
|
|
108
|
+
// It wraps a span rather than the button, because a Tooltip hands its
|
|
109
|
+
// child an `aria-describedby`, and a tile named "Red" and described as
|
|
110
|
+
// "Red" is read out twice.
|
|
111
|
+
/* @__PURE__ */ jsx(
|
|
112
|
+
Tooltip,
|
|
113
|
+
{
|
|
114
|
+
label: item.note ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
115
|
+
/* @__PURE__ */ jsx("span", { className: "block", children: item.label }),
|
|
116
|
+
/* @__PURE__ */ jsx("span", { className: "block font-normal text-[var(--text-muted)]", children: item.note })
|
|
117
|
+
] }) : item.label,
|
|
118
|
+
portal: true,
|
|
119
|
+
children: /* @__PURE__ */ jsx("span", { className: "inline-flex", children: button })
|
|
120
|
+
},
|
|
121
|
+
item.key ?? "\0none"
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
}) });
|
|
125
|
+
}
|
|
126
|
+
function SelectedTick() {
|
|
127
|
+
return /* @__PURE__ */ jsx(
|
|
128
|
+
"span",
|
|
129
|
+
{
|
|
130
|
+
"aria-hidden": true,
|
|
131
|
+
className: "absolute -bottom-1 -end-1 inline-flex size-3.5 items-center justify-center rounded-full bg-[var(--text-primary)] text-[var(--bg-surface)]",
|
|
132
|
+
children: /* @__PURE__ */ jsx(Check, { strokeWidth: 3, className: "size-2.5" })
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
export {
|
|
137
|
+
TILE_SIZE,
|
|
138
|
+
TileRadioGroup
|
|
139
|
+
};
|
|
140
|
+
//# sourceMappingURL=tile-radio.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/tile-radio.tsx"],"sourcesContent":["import { useId, useRef } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { Ban, Check } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Tooltip } from \"./tooltip\";\n\n/**\n * The machinery {@link SwatchPicker} and {@link IconPicker} share: a row of square\n * tiles that behaves as ONE radio group. Private to the package — not re-exported\n * from the barrel.\n *\n * Keksdose had three of these (the category colours, the category symbols, the\n * transaction flags) and all three were rows of `aria-pressed` buttons: a separate\n * tab stop per tile, and \"pressed\" on a choice that is really one-of-n. The APG radio\n * group is the shape a screen reader expects for that (\"Red, radio, 2 of 7, checked\")\n * and the one a keyboard expects too — a single tab stop, arrows to move.\n */\n\nexport type TileSize = \"sm\" | \"md\" | \"lg\";\n\n/** 28 / 32 / 44px. `lg` is the touch target a phone surface wants (`min-h-11`). */\nexport const TILE_SIZE: Record<TileSize, { tile: string; glyph: string; swatch: string }> = {\n sm: { tile: \"size-7\", glyph: \"size-3.5\", swatch: \"size-4\" },\n md: { tile: \"size-8\", glyph: \"size-4\", swatch: \"size-5\" },\n lg: { tile: \"size-11\", glyph: \"size-5\", swatch: \"size-6\" },\n};\n\n/** One tile, whatever it shows. `key` is `null` for the \"none\" entry. */\nexport interface TileItem<T extends string> {\n key: T | null;\n label: string;\n /** Said after the name, and shown in the bubble under it. Marks the tile with a\n * dot, so the note is visible without hovering every tile to find it. */\n note?: string;\n disabled?: boolean;\n}\n\nexport interface TileRadioGroupProps<T extends string> {\n items: TileItem<T>[];\n /** The checked key, or `undefined` for \"nothing is checked\" (the mixed state, or\n * a value that is not among the options). */\n checked: T | null | undefined;\n onSelect: (key: T | null) => void;\n /** \"automatic\": an arrow key moves AND checks, the APG default. \"manual\": arrows\n * move focus only, Space or Enter checks — for a picker whose change is expensive\n * (a bulk edit that saves on every change). */\n activation: \"automatic\" | \"manual\";\n disabled: boolean;\n size: TileSize;\n /** What goes inside a tile. The frame, the tick and the note's dot are drawn here. */\n renderTile: (item: TileItem<T>, selected: boolean) => ReactNode;\n /** Extra classes for a tile, per item. */\n tileClassName?: (item: TileItem<T>, selected: boolean) => string | undefined;\n}\n\n/**\n * The tile's frame. Selection is a thicker, darker frame AND a tick in the corner —\n * never a colour change alone,\n * because the thing inside a swatch tile IS a colour, and a red frame round a red dot\n * says nothing to a reader who cannot see red.\n *\n * `focus-visible` with an offset in the surface colour, as the checkbox does: the ring\n * must not be painted in the colour of the frame it touches.\n */\nconst TILE_BASE =\n \"relative inline-flex shrink-0 items-center justify-center rounded-md border transition-colors \" +\n \"focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface)] \" +\n \"disabled:cursor-not-allowed disabled:opacity-50\";\nconst TILE_IDLE =\n \"border-[var(--border)] bg-[var(--bg-surface)] hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]\";\nconst TILE_SELECTED =\n \"border-[var(--text-primary)] bg-[var(--bg-surface)] ring-1 ring-[var(--text-primary)]\";\n\nexport function TileRadioGroup<T extends string>({\n items,\n checked,\n onSelect,\n activation,\n disabled,\n size,\n renderTile,\n tileClassName,\n}: TileRadioGroupProps<T>) {\n const refs = useRef<Array<HTMLButtonElement | null>>([]);\n const noteId = useId();\n const s = TILE_SIZE[size];\n const checkedIndex =\n checked === undefined ? -1 : items.findIndex((it) => it.key === checked);\n // The ONE tab stop: the checked tile, else the first that can be chosen — the APG\n // rule, so Tab into a group with nothing checked still lands somewhere.\n const tabStop =\n checkedIndex >= 0 && !items[checkedIndex].disabled\n ? checkedIndex\n : items.findIndex((it) => !it.disabled);\n\n const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {\n // Left and right are VISUAL directions: in a right-to-left page the row runs the\n // other way. Up and down follow reading order either way — the APG's radio group\n // treats them as previous/next, and the tiles wrap, so there is no column to keep.\n const rtl = e.currentTarget.closest(\"[dir]\")?.getAttribute(\"dir\") === \"rtl\";\n let step: number | \"first\" | \"last\";\n switch (e.key) {\n case \"ArrowRight\":\n step = rtl ? -1 : 1;\n break;\n case \"ArrowLeft\":\n step = rtl ? 1 : -1;\n break;\n case \"ArrowDown\":\n step = 1;\n break;\n case \"ArrowUp\":\n step = -1;\n break;\n case \"Home\":\n step = \"first\";\n break;\n case \"End\":\n step = \"last\";\n break;\n default:\n return;\n }\n e.preventDefault();\n const enabled = items.map((it, i) => (it.disabled ? -1 : i)).filter((i) => i >= 0);\n if (!enabled.length) return;\n const at = enabled.indexOf(index);\n let target: number;\n if (step === \"first\") target = enabled[0];\n else if (step === \"last\") target = enabled[enabled.length - 1];\n // Wrapping, as a native radio group does: the last tile's \"next\" is the first.\n else target = enabled[(at + step + enabled.length) % enabled.length];\n refs.current[target]?.focus();\n if (activation === \"automatic\") onSelect(items[target].key);\n };\n\n return (\n <>\n {items.map((item, i) => {\n const selected = i === checkedIndex;\n const button = (\n <button\n ref={(el) => {\n refs.current[i] = el;\n }}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n aria-label={item.label}\n // The note is a DESCRIPTION, read after the name and the state, not glued\n // into the name — the punctuation between the two would be English's.\n aria-describedby={item.note ? `${noteId}-${i}` : undefined}\n tabIndex={i === tabStop ? 0 : -1}\n disabled={disabled || item.disabled}\n // Clicking the checked tile again does nothing: a radio is not a toggle.\n // \"None\" is the way back to nothing, and it is a tile of its own.\n onClick={() => !selected && onSelect(item.key)}\n onKeyDown={(e) => onKeyDown(e, i)}\n className={cn(\n TILE_BASE,\n s.tile,\n selected ? TILE_SELECTED : TILE_IDLE,\n tileClassName?.(item, selected),\n )}\n >\n {item.key === null ? (\n <Ban aria-hidden className={cn(s.glyph, \"text-[var(--text-muted)]\")} />\n ) : (\n renderTile(item, selected)\n )}\n {item.note && (\n <>\n {/* A dot rather than a dimmed tile: dimming reads as \"disabled\", and a\n tile with a note is entirely available. */}\n <span\n aria-hidden\n className=\"absolute end-0.5 top-0.5 size-1.5 rounded-full bg-[var(--text-muted)]\"\n />\n {/* `hidden` still serves as a description: aria-describedby reads\n hidden text, which is what keeps it out of the layout. */}\n <span id={`${noteId}-${i}`} hidden>\n {item.note}\n </span>\n </>\n )}\n {selected && <SelectedTick />}\n </button>\n );\n return (\n // The bubble is the SIGHTED user's name for the tile — eight unlabelled\n // squares are the complaint that put a bubble on Keksdose's (live #289).\n // It wraps a span rather than the button, because a Tooltip hands its\n // child an `aria-describedby`, and a tile named \"Red\" and described as\n // \"Red\" is read out twice.\n <Tooltip\n key={item.key ?? \"\\u0000none\"}\n label={\n item.note ? (\n <>\n <span className=\"block\">{item.label}</span>\n <span className=\"block font-normal text-[var(--text-muted)]\">{item.note}</span>\n </>\n ) : (\n item.label\n )\n }\n portal\n >\n <span className=\"inline-flex\">{button}</span>\n </Tooltip>\n );\n })}\n </>\n );\n}\n\n/** The tick on a selected tile — a small badge in the corner, in the text colour on\n * the surface colour, so it reads the same over every swatch colour. */\nfunction SelectedTick() {\n return (\n <span\n aria-hidden\n className=\"absolute -bottom-1 -end-1 inline-flex size-3.5 items-center justify-center rounded-full bg-[var(--text-primary)] text-[var(--bg-surface)]\"\n >\n <Check strokeWidth={3} className=\"size-2.5\" />\n </span>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -15,9 +15,7 @@ interface ToggleOption<T extends string> {
|
|
|
15
15
|
* accepted and the component ignored would be a prop that silently does nothing —
|
|
16
16
|
* worse than one that does not compile.
|
|
17
17
|
*/
|
|
18
|
-
interface
|
|
19
|
-
value: T;
|
|
20
|
-
onChange: (value: T) => void;
|
|
18
|
+
interface ToggleGroupBaseProps<T extends string> extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children"> {
|
|
21
19
|
options: ToggleOption<T>[];
|
|
22
20
|
className?: string;
|
|
23
21
|
/** Applied to every option button (e.g. to tune height/rounding to match
|
|
@@ -44,6 +42,30 @@ interface ToggleGroupProps<T extends string> extends Omit<ComponentPropsWithoutR
|
|
|
44
42
|
*/
|
|
45
43
|
disabled?: boolean;
|
|
46
44
|
}
|
|
47
|
-
|
|
45
|
+
/** The group as it has always been: one option is always the answer. */
|
|
46
|
+
interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {
|
|
47
|
+
allowEmpty?: false;
|
|
48
|
+
value: T;
|
|
49
|
+
onChange: (value: T) => void;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A group that can be emptied: clicking the active option clears it, and `onChange`
|
|
53
|
+
* receives `null` (Keksdose's support-panel filters, where "no filter" is reached by
|
|
54
|
+
* clicking the filter that is on).
|
|
55
|
+
*
|
|
56
|
+
* The options become TOGGLE BUTTONS (`aria-pressed`, in a `role="group"`) rather than
|
|
57
|
+
* radios. A radio cannot be unchecked by activating it — no screen reader user expects
|
|
58
|
+
* a second press on "Open, radio, checked" to leave nothing checked, and nothing would
|
|
59
|
+
* tell them it had. "Open, toggle button, pressed" says exactly what a press will do.
|
|
60
|
+
*/
|
|
61
|
+
interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {
|
|
62
|
+
allowEmpty: true;
|
|
63
|
+
value: T | null;
|
|
64
|
+
onChange: (value: T | null) => void;
|
|
65
|
+
}
|
|
66
|
+
/** `allowEmpty` picks the shape, so `onChange` is typed `(T) => void` unless the group
|
|
67
|
+
* can actually emit `null` — no existing caller has a `null` to handle. */
|
|
68
|
+
type ToggleGroupProps<T extends string> = ToggleGroupRequiredProps<T> | ToggleGroupClearableProps<T>;
|
|
69
|
+
declare function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): react.JSX.Element;
|
|
48
70
|
|
|
49
|
-
export { ToggleGroup, type ToggleGroupProps, type ToggleOption };
|
|
71
|
+
export { ToggleGroup, type ToggleGroupBaseProps, type ToggleGroupClearableProps, type ToggleGroupProps, type ToggleGroupRequiredProps, type ToggleOption };
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
|
-
function ToggleGroup({
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
4
|
+
function ToggleGroup(props) {
|
|
5
|
+
const {
|
|
6
|
+
value,
|
|
7
|
+
options,
|
|
8
|
+
className,
|
|
9
|
+
optionClassName,
|
|
10
|
+
ariaLabel,
|
|
11
|
+
disabled = false,
|
|
12
|
+
"aria-label": ariaLabelAttr,
|
|
13
|
+
...restWithMode
|
|
14
|
+
} = props;
|
|
15
|
+
const { allowEmpty: _allowEmpty, onChange: _onChange, ...rest } = restWithMode;
|
|
16
|
+
const choose = (next) => {
|
|
17
|
+
if (props.allowEmpty) props.onChange(next === value ? null : next);
|
|
18
|
+
else props.onChange(next);
|
|
19
|
+
};
|
|
20
|
+
const clearable = props.allowEmpty === true;
|
|
15
21
|
return /* @__PURE__ */ jsx(
|
|
16
22
|
"div",
|
|
17
23
|
{
|
|
18
24
|
...rest,
|
|
19
|
-
role: "radiogroup",
|
|
25
|
+
role: clearable ? "group" : "radiogroup",
|
|
20
26
|
"aria-label": ariaLabelAttr ?? ariaLabel,
|
|
21
27
|
"aria-disabled": disabled || void 0,
|
|
22
28
|
className: cn(
|
|
@@ -45,10 +51,11 @@ function ToggleGroup({
|
|
|
45
51
|
"button",
|
|
46
52
|
{
|
|
47
53
|
type: "button",
|
|
48
|
-
role: "radio",
|
|
49
|
-
"aria-checked": active,
|
|
54
|
+
role: clearable ? void 0 : "radio",
|
|
55
|
+
"aria-checked": clearable ? void 0 : active,
|
|
56
|
+
"aria-pressed": clearable ? active : void 0,
|
|
50
57
|
disabled,
|
|
51
|
-
onClick: () =>
|
|
58
|
+
onClick: () => choose(opt.value),
|
|
52
59
|
className: cn(
|
|
53
60
|
// `truncate` (which carries whitespace-nowrap) rather than letting a
|
|
54
61
|
// label wrap: a segmented control sizes its whole row to the tallest
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/toggle-group.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef } from \"react\";\nimport { cn } from \"../lib/cn\";\n\nexport interface ToggleOption<T extends string> {\n value: T;\n label: string;\n className?: string;\n}\n\n/**\n * `onChange` is the group's own — the chosen VALUE, not a DOM event — so the div's\n * `onChange` is omitted rather than shadowed: leaving both in scope would give the\n * prop two incompatible meanings depending on which overload TypeScript picked.\n *\n * `children` is omitted too: this renders its `options` and nothing else, so a `children` the type\n * accepted and the component ignored would be a prop that silently does nothing —\n * worse than one that does not compile.\n */\nexport interface ToggleGroupProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n value: T;\n onChange: (value: T) => void;\n options: ToggleOption<T>[];\n className?: string;\n /** Applied to every option button (e.g. to tune height/rounding to match\n * adjacent fields). Per-option `className` still wins over this. */\n optionClassName?: string;\n /**\n * @deprecated Pass `aria-label` instead — the DOM spelling, which every other\n * control in this kit now answers to. Kept working because three applications ship\n * this one today; it names the group only when `aria-label` is absent.\n */\n ariaLabel?: string;\n /**\n * Show, refuse the change (Keksdose live #288: a payment dated in the future has no\n * state to set).\n *\n * Whatever `value` says stays pressed and keeps its own fill rather than going grey\n * with the rest — a reader who cannot see WHICH option is chosen has been told less\n * than before it was disabled. A caller with nothing to show passes no value, and\n * the group renders dimmed with nothing pressed, which is the shape Keksdose's\n * status picker uses for a row whose status does not exist yet.\n *\n * On the whole GROUP, not per option: a segmented control where some segments are\n * live and others are not is a menu with holes in it, and no caller here wants one.\n */\n disabled?: boolean;\n}\n\nexport function ToggleGroup<T extends string>({\n value,\n onChange,\n options,\n className,\n optionClassName,\n ariaLabel,\n disabled = false,\n \"aria-label\": ariaLabelAttr,\n ...rest\n}: ToggleGroupProps<T>) {\n return (\n <div\n // The audit's named example of a closed prop list (§\"Public API design\"): the\n // tour locates a step by CSS SELECTOR, so a component that drops every attribute\n // it was not expecting cannot be spotlighted at all — and Keksdose's rule editor\n // carries a comment explaining that it wraps this group in a bare <div> for\n // exactly that reason.\n //\n // `...rest` first, then the attributes the group cannot do without: a caller\n // hanging an anchor or a test id on the group must not be able to overwrite the\n // radiogroup role or the disabled state by accident. `className` is destructured\n // out entirely and merged through `cn`, so it is never in here.\n {...rest}\n role=\"radiogroup\"\n // The DOM spelling wins; `ariaLabel` is the fallback for the call sites that\n // have not moved yet.\n aria-label={ariaLabelAttr ?? ariaLabel}\n // `aria-disabled` on the group as well as `disabled` on each button: a radio\n // group is what the user is being refused, and a screen reader announcing\n // three separately-disabled radios does not say that.\n aria-disabled={disabled || undefined}\n className={cn(\n // `gap-0.5` — the same 2px as the container's own padding, so EVERY segment\n // sits in a uniform 2px moat and no two fills ever touch. Flush segments were\n // Keksdose live #268's rework: the pressed segment wears a saturated fill and\n // an unpressed neighbour wears a pale hover fill, and with a shared edge the\n // two rectangles read as one smeared shape — *\"the boundary of the selected\n // option and hovering next to it overlays the boundary of the selected\n // button\"*. A gap is what makes each segment its own chip; it cannot be\n // undone by a caller's per-option colour, which a hover-only fix could.\n //\n // (The hover fill is the DESKTOP half of that report: Tailwind v4 wraps every\n // `hover:` in `@media (hover: hover)`, so a phone never paints it. The half a\n // phone does see is the focus ring — see the segment's own note below.)\n \"inline-flex w-full gap-0.5 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] p-0.5 shadow-sm\",\n // The whole group fades, the way every other disabled control in this\n // package does; `cursor-not-allowed` is on the buttons, which is what a\n // pointer is actually over.\n disabled && \"opacity-60\",\n className,\n )}\n >\n {options.map((opt) => {\n const active = opt.value === value;\n return (\n <button\n key={opt.value}\n type=\"button\"\n role=\"radio\"\n aria-checked={active}\n disabled={disabled}\n onClick={() => onChange(opt.value)}\n className={cn(\n // `truncate` (which carries whitespace-nowrap) rather than letting a\n // label wrap: a segmented control sizes its whole row to the tallest\n // option, so one two-word option — Keksdose feedback #147's \"Where I\n // am\" — silently doubles the height of every segment beside it.\n //\n // `basis-auto` is what keeps that ellipsis a LAST resort rather than\n // the normal state (Keksdose dev#475). With flex-1's `basis-0`, a\n // shrink-to-fit group (`w-auto`) still resolves to the sum of the\n // labels' widths — and then splits it EQUALLY, so the short option\n // got 66px it did not need and \"Where I am\" got 66 of the 81 it did:\n // truncated at 1778px of free screen. Basing each segment on its own\n // content and sharing only the LEFTOVER space keeps a full-width\n // group's segments near-equal and an auto-width group's exact.\n //\n // `focus-visible` + `ring-inset`, not `focus` + an outset ring. A ring\n // is a box-shadow that spreads OUTWARD, so on a flush group it painted\n // 2px of ring over both neighbours and over the container's own border\n // — and on a phone it appeared on every TAP, because a tap focuses the\n // button. That is the other half of what live #268's rework saw\n // overlaying the selected segment's boundary. Inset keeps the ring\n // inside the segment it belongs to; focus-visible keeps it for the\n // keyboard, which is the only input that needs it.\n \"min-w-0 flex-1 basis-auto truncate rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]\",\n active\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n // No hover fill on a group that cannot be changed — a segment that\n // lights up under the pointer is an offer, and there is none here.\n disabled && \"cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent\",\n optionClassName,\n opt.className,\n )}\n >\n {opt.label}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":";AAyGU;AAxGV,SAAS,UAAU;AAgDZ,SAAS,YAA8B;AAAA,EAC5C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,cAAc;AAAA,EACd,GAAG;AACL,GAAwB;AACtB,SACE;AAAA,IAAC;AAAA;AAAA,MAWE,GAAG;AAAA,MACJ,MAAK;AAAA,MAGL,cAAY,iBAAiB;AAAA,MAI7B,iBAAe,YAAY;AAAA,MAC3B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAaT;AAAA;AAAA;AAAA;AAAA,QAIA,YAAY;AAAA,QACZ;AAAA,MACF;AAAA,MAEC,kBAAQ,IAAI,CAAC,QAAQ;AACpB,cAAM,SAAS,IAAI,UAAU;AAC7B,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,MAAK;AAAA,YACL,gBAAc;AAAA,YACd;AAAA,YACA,SAAS,MAAM,SAAS,IAAI,KAAK;AAAA,YACjC,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAuBT;AAAA,cACA,SACI,sDACA;AAAA;AAAA;AAAA,cAGJ,YAAY;AAAA,cACZ;AAAA,cACA,IAAI;AAAA,YACN;AAAA,YAEC,cAAI;AAAA;AAAA,UAxCA,IAAI;AAAA,QAyCX;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/toggle-group.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef } from \"react\";\nimport { cn } from \"../lib/cn\";\n\nexport interface ToggleOption<T extends string> {\n value: T;\n label: string;\n className?: string;\n}\n\n/**\n * `onChange` is the group's own — the chosen VALUE, not a DOM event — so the div's\n * `onChange` is omitted rather than shadowed: leaving both in scope would give the\n * prop two incompatible meanings depending on which overload TypeScript picked.\n *\n * `children` is omitted too: this renders its `options` and nothing else, so a `children` the type\n * accepted and the component ignored would be a prop that silently does nothing —\n * worse than one that does not compile.\n */\nexport interface ToggleGroupBaseProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n options: ToggleOption<T>[];\n className?: string;\n /** Applied to every option button (e.g. to tune height/rounding to match\n * adjacent fields). Per-option `className` still wins over this. */\n optionClassName?: string;\n /**\n * @deprecated Pass `aria-label` instead — the DOM spelling, which every other\n * control in this kit now answers to. Kept working because three applications ship\n * this one today; it names the group only when `aria-label` is absent.\n */\n ariaLabel?: string;\n /**\n * Show, refuse the change (Keksdose live #288: a payment dated in the future has no\n * state to set).\n *\n * Whatever `value` says stays pressed and keeps its own fill rather than going grey\n * with the rest — a reader who cannot see WHICH option is chosen has been told less\n * than before it was disabled. A caller with nothing to show passes no value, and\n * the group renders dimmed with nothing pressed, which is the shape Keksdose's\n * status picker uses for a row whose status does not exist yet.\n *\n * On the whole GROUP, not per option: a segmented control where some segments are\n * live and others are not is a menu with holes in it, and no caller here wants one.\n */\n disabled?: boolean;\n}\n\n/** The group as it has always been: one option is always the answer. */\nexport interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {\n allowEmpty?: false;\n value: T;\n onChange: (value: T) => void;\n}\n\n/**\n * A group that can be emptied: clicking the active option clears it, and `onChange`\n * receives `null` (Keksdose's support-panel filters, where \"no filter\" is reached by\n * clicking the filter that is on).\n *\n * The options become TOGGLE BUTTONS (`aria-pressed`, in a `role=\"group\"`) rather than\n * radios. A radio cannot be unchecked by activating it — no screen reader user expects\n * a second press on \"Open, radio, checked\" to leave nothing checked, and nothing would\n * tell them it had. \"Open, toggle button, pressed\" says exactly what a press will do.\n */\nexport interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {\n allowEmpty: true;\n value: T | null;\n onChange: (value: T | null) => void;\n}\n\n/** `allowEmpty` picks the shape, so `onChange` is typed `(T) => void` unless the group\n * can actually emit `null` — no existing caller has a `null` to handle. */\nexport type ToggleGroupProps<T extends string> =\n | ToggleGroupRequiredProps<T>\n | ToggleGroupClearableProps<T>;\n\nexport function ToggleGroup<T extends string>(props: ToggleGroupProps<T>) {\n const {\n value,\n options,\n className,\n optionClassName,\n ariaLabel,\n disabled = false,\n \"aria-label\": ariaLabelAttr,\n ...restWithMode\n } = props;\n // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is\n // what lets the `onChange` below be called with `null` only in the mode that allows it.\n const { allowEmpty: _allowEmpty, onChange: _onChange, ...rest } = restWithMode;\n const choose = (next: T) => {\n if (props.allowEmpty) props.onChange(next === value ? null : next);\n else props.onChange(next);\n };\n const clearable = props.allowEmpty === true;\n return (\n <div\n // The audit's named example of a closed prop list (§\"Public API design\"): the\n // tour locates a step by CSS SELECTOR, so a component that drops every attribute\n // it was not expecting cannot be spotlighted at all — and Keksdose's rule editor\n // carries a comment explaining that it wraps this group in a bare <div> for\n // exactly that reason.\n //\n // `...rest` first, then the attributes the group cannot do without: a caller\n // hanging an anchor or a test id on the group must not be able to overwrite the\n // radiogroup role or the disabled state by accident. `className` is destructured\n // out entirely and merged through `cn`, so it is never in here.\n {...rest}\n role={clearable ? \"group\" : \"radiogroup\"}\n // The DOM spelling wins; `ariaLabel` is the fallback for the call sites that\n // have not moved yet.\n aria-label={ariaLabelAttr ?? ariaLabel}\n // `aria-disabled` on the group as well as `disabled` on each button: a radio\n // group is what the user is being refused, and a screen reader announcing\n // three separately-disabled radios does not say that.\n aria-disabled={disabled || undefined}\n className={cn(\n // `gap-0.5` — the same 2px as the container's own padding, so EVERY segment\n // sits in a uniform 2px moat and no two fills ever touch. Flush segments were\n // Keksdose live #268's rework: the pressed segment wears a saturated fill and\n // an unpressed neighbour wears a pale hover fill, and with a shared edge the\n // two rectangles read as one smeared shape — *\"the boundary of the selected\n // option and hovering next to it overlays the boundary of the selected\n // button\"*. A gap is what makes each segment its own chip; it cannot be\n // undone by a caller's per-option colour, which a hover-only fix could.\n //\n // (The hover fill is the DESKTOP half of that report: Tailwind v4 wraps every\n // `hover:` in `@media (hover: hover)`, so a phone never paints it. The half a\n // phone does see is the focus ring — see the segment's own note below.)\n \"inline-flex w-full gap-0.5 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] p-0.5 shadow-sm\",\n // The whole group fades, the way every other disabled control in this\n // package does; `cursor-not-allowed` is on the buttons, which is what a\n // pointer is actually over.\n disabled && \"opacity-60\",\n className,\n )}\n >\n {options.map((opt) => {\n const active = opt.value === value;\n return (\n <button\n key={opt.value}\n type=\"button\"\n role={clearable ? undefined : \"radio\"}\n aria-checked={clearable ? undefined : active}\n aria-pressed={clearable ? active : undefined}\n disabled={disabled}\n onClick={() => choose(opt.value)}\n className={cn(\n // `truncate` (which carries whitespace-nowrap) rather than letting a\n // label wrap: a segmented control sizes its whole row to the tallest\n // option, so one two-word option — Keksdose feedback #147's \"Where I\n // am\" — silently doubles the height of every segment beside it.\n //\n // `basis-auto` is what keeps that ellipsis a LAST resort rather than\n // the normal state (Keksdose dev#475). With flex-1's `basis-0`, a\n // shrink-to-fit group (`w-auto`) still resolves to the sum of the\n // labels' widths — and then splits it EQUALLY, so the short option\n // got 66px it did not need and \"Where I am\" got 66 of the 81 it did:\n // truncated at 1778px of free screen. Basing each segment on its own\n // content and sharing only the LEFTOVER space keeps a full-width\n // group's segments near-equal and an auto-width group's exact.\n //\n // `focus-visible` + `ring-inset`, not `focus` + an outset ring. A ring\n // is a box-shadow that spreads OUTWARD, so on a flush group it painted\n // 2px of ring over both neighbours and over the container's own border\n // — and on a phone it appeared on every TAP, because a tap focuses the\n // button. That is the other half of what live #268's rework saw\n // overlaying the selected segment's boundary. Inset keeps the ring\n // inside the segment it belongs to; focus-visible keeps it for the\n // keyboard, which is the only input that needs it.\n \"min-w-0 flex-1 basis-auto truncate rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]\",\n active\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n // No hover fill on a group that cannot be changed — a segment that\n // lights up under the pointer is an offer, and there is none here.\n disabled && \"cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent\",\n optionClassName,\n opt.className,\n )}\n >\n {opt.label}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":";AA4IU;AA3IV,SAAS,UAAU;AA2EZ,SAAS,YAA8B,OAA4B;AACxE,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW;AAAA,IACX,cAAc;AAAA,IACd,GAAG;AAAA,EACL,IAAI;AAGJ,QAAM,EAAE,YAAY,aAAa,UAAU,WAAW,GAAG,KAAK,IAAI;AAClE,QAAM,SAAS,CAAC,SAAY;AAC1B,QAAI,MAAM,WAAY,OAAM,SAAS,SAAS,QAAQ,OAAO,IAAI;AAAA,QAC5D,OAAM,SAAS,IAAI;AAAA,EAC1B;AACA,QAAM,YAAY,MAAM,eAAe;AACvC,SACE;AAAA,IAAC;AAAA;AAAA,MAWE,GAAG;AAAA,MACJ,MAAM,YAAY,UAAU;AAAA,MAG5B,cAAY,iBAAiB;AAAA,MAI7B,iBAAe,YAAY;AAAA,MAC3B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAaT;AAAA;AAAA;AAAA;AAAA,QAIA,YAAY;AAAA,QACZ;AAAA,MACF;AAAA,MAEC,kBAAQ,IAAI,CAAC,QAAQ;AACpB,cAAM,SAAS,IAAI,UAAU;AAC7B,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,MAAM,YAAY,SAAY;AAAA,YAC9B,gBAAc,YAAY,SAAY;AAAA,YACtC,gBAAc,YAAY,SAAS;AAAA,YACnC;AAAA,YACA,SAAS,MAAM,OAAO,IAAI,KAAK;AAAA,YAC/B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAuBT;AAAA,cACA,SACI,sDACA;AAAA;AAAA;AAAA,cAGJ,YAAY;AAAA,cACZ;AAAA,cACA,IAAI;AAAA,YACN;AAAA,YAEC,cAAI;AAAA;AAAA,UAzCA,IAAI;AAAA,QA0CX;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;","names":[]}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attributes that NAME or DESCRIBE a field, which belong on its trigger — the
|
|
3
|
+
* element a `<label htmlFor>`, a form library's control slot or an error message has
|
|
4
|
+
* to reach. They used to land on the wrapper `<div>` with the rest of the caller's
|
|
5
|
+
* props, so an external label named nothing and a form library's `aria-describedby`
|
|
6
|
+
* pointed a screen reader at an element with no role (kastlan, 0.5.0).
|
|
7
|
+
*/
|
|
8
|
+
interface TriggerAria {
|
|
9
|
+
id?: string;
|
|
10
|
+
"aria-label"?: string;
|
|
11
|
+
"aria-labelledby"?: string;
|
|
12
|
+
"aria-describedby"?: string;
|
|
13
|
+
"aria-invalid"?: boolean | "true" | "false" | "grammar" | "spelling";
|
|
14
|
+
}
|
|
15
|
+
/** Split a field's props into what its trigger takes and what its wrapper takes. */
|
|
16
|
+
declare function splitTriggerAria<T extends TriggerAria>(props: T): [TriggerAria, Omit<T, keyof TriggerAria>];
|
|
17
|
+
|
|
18
|
+
export { type TriggerAria, splitTriggerAria };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
function splitTriggerAria(props) {
|
|
3
|
+
const {
|
|
4
|
+
id,
|
|
5
|
+
"aria-label": label,
|
|
6
|
+
"aria-labelledby": labelledBy,
|
|
7
|
+
"aria-describedby": describedBy,
|
|
8
|
+
"aria-invalid": invalid,
|
|
9
|
+
...rest
|
|
10
|
+
} = props;
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
id,
|
|
14
|
+
"aria-label": label,
|
|
15
|
+
"aria-labelledby": labelledBy,
|
|
16
|
+
"aria-describedby": describedBy,
|
|
17
|
+
"aria-invalid": invalid
|
|
18
|
+
},
|
|
19
|
+
rest
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
splitTriggerAria
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=trigger-aria.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/trigger-aria.ts"],"sourcesContent":["// Internal — not re-exported from the barrel. Shared by the pickers whose trigger is\n// a `role=\"combobox\"` button inside a wrapper: DatePicker, DateRangePicker, MonthPicker.\n\n/**\n * The attributes that NAME or DESCRIBE a field, which belong on its trigger — the\n * element a `<label htmlFor>`, a form library's control slot or an error message has\n * to reach. They used to land on the wrapper `<div>` with the rest of the caller's\n * props, so an external label named nothing and a form library's `aria-describedby`\n * pointed a screen reader at an element with no role (kastlan, 0.5.0).\n */\nexport interface TriggerAria {\n id?: string;\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\";\n}\n\n/** Split a field's props into what its trigger takes and what its wrapper takes. */\nexport function splitTriggerAria<T extends TriggerAria>(\n props: T,\n): [TriggerAria, Omit<T, keyof TriggerAria>] {\n const {\n id,\n \"aria-label\": label,\n \"aria-labelledby\": labelledBy,\n \"aria-describedby\": describedBy,\n \"aria-invalid\": invalid,\n ...rest\n } = props;\n return [\n {\n id,\n \"aria-label\": label,\n \"aria-labelledby\": labelledBy,\n \"aria-describedby\": describedBy,\n \"aria-invalid\": invalid,\n },\n rest,\n ];\n}\n\n"],"mappings":";AAmBO,SAAS,iBACd,OAC2C;AAC3C,QAAM;AAAA,IACJ;AAAA,IACA,cAAc;AAAA,IACd,mBAAmB;AAAA,IACnB,oBAAoB;AAAA,IACpB,gBAAgB;AAAA,IAChB,GAAG;AAAA,EACL,IAAI;AACJ,SAAO;AAAA,IACL;AAAA,MACE;AAAA,MACA,cAAc;AAAA,MACd,mBAAmB;AAAA,MACnB,oBAAoB;AAAA,MACpB,gBAAgB;AAAA,IAClB;AAAA,IACA;AAAA,EACF;AACF;","names":[]}
|