@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,228 @@
|
|
|
1
|
+
import { useId, useRef } from "react";
|
|
2
|
+
import type { KeyboardEvent, ReactNode } from "react";
|
|
3
|
+
import { Ban, Check } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { Tooltip } from "./tooltip";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The machinery {@link SwatchPicker} and {@link IconPicker} share: a row of square
|
|
9
|
+
* tiles that behaves as ONE radio group. Private to the package — not re-exported
|
|
10
|
+
* from the barrel.
|
|
11
|
+
*
|
|
12
|
+
* Keksdose had three of these (the category colours, the category symbols, the
|
|
13
|
+
* transaction flags) and all three were rows of `aria-pressed` buttons: a separate
|
|
14
|
+
* tab stop per tile, and "pressed" on a choice that is really one-of-n. The APG radio
|
|
15
|
+
* group is the shape a screen reader expects for that ("Red, radio, 2 of 7, checked")
|
|
16
|
+
* and the one a keyboard expects too — a single tab stop, arrows to move.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type TileSize = "sm" | "md" | "lg";
|
|
20
|
+
|
|
21
|
+
/** 28 / 32 / 44px. `lg` is the touch target a phone surface wants (`min-h-11`). */
|
|
22
|
+
export const TILE_SIZE: Record<TileSize, { tile: string; glyph: string; swatch: string }> = {
|
|
23
|
+
sm: { tile: "size-7", glyph: "size-3.5", swatch: "size-4" },
|
|
24
|
+
md: { tile: "size-8", glyph: "size-4", swatch: "size-5" },
|
|
25
|
+
lg: { tile: "size-11", glyph: "size-5", swatch: "size-6" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One tile, whatever it shows. `key` is `null` for the "none" entry. */
|
|
29
|
+
export interface TileItem<T extends string> {
|
|
30
|
+
key: T | null;
|
|
31
|
+
label: string;
|
|
32
|
+
/** Said after the name, and shown in the bubble under it. Marks the tile with a
|
|
33
|
+
* dot, so the note is visible without hovering every tile to find it. */
|
|
34
|
+
note?: string;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TileRadioGroupProps<T extends string> {
|
|
39
|
+
items: TileItem<T>[];
|
|
40
|
+
/** The checked key, or `undefined` for "nothing is checked" (the mixed state, or
|
|
41
|
+
* a value that is not among the options). */
|
|
42
|
+
checked: T | null | undefined;
|
|
43
|
+
onSelect: (key: T | null) => void;
|
|
44
|
+
/** "automatic": an arrow key moves AND checks, the APG default. "manual": arrows
|
|
45
|
+
* move focus only, Space or Enter checks — for a picker whose change is expensive
|
|
46
|
+
* (a bulk edit that saves on every change). */
|
|
47
|
+
activation: "automatic" | "manual";
|
|
48
|
+
disabled: boolean;
|
|
49
|
+
size: TileSize;
|
|
50
|
+
/** What goes inside a tile. The frame, the tick and the note's dot are drawn here. */
|
|
51
|
+
renderTile: (item: TileItem<T>, selected: boolean) => ReactNode;
|
|
52
|
+
/** Extra classes for a tile, per item. */
|
|
53
|
+
tileClassName?: (item: TileItem<T>, selected: boolean) => string | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The tile's frame. Selection is a thicker, darker frame AND a tick in the corner —
|
|
58
|
+
* never a colour change alone,
|
|
59
|
+
* because the thing inside a swatch tile IS a colour, and a red frame round a red dot
|
|
60
|
+
* says nothing to a reader who cannot see red.
|
|
61
|
+
*
|
|
62
|
+
* `focus-visible` with an offset in the surface colour, as the checkbox does: the ring
|
|
63
|
+
* must not be painted in the colour of the frame it touches.
|
|
64
|
+
*/
|
|
65
|
+
const TILE_BASE =
|
|
66
|
+
"relative inline-flex shrink-0 items-center justify-center rounded-md border transition-colors " +
|
|
67
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface)] " +
|
|
68
|
+
"disabled:cursor-not-allowed disabled:opacity-50";
|
|
69
|
+
const TILE_IDLE =
|
|
70
|
+
"border-[var(--border)] bg-[var(--bg-surface)] hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]";
|
|
71
|
+
const TILE_SELECTED =
|
|
72
|
+
"border-[var(--text-primary)] bg-[var(--bg-surface)] ring-1 ring-[var(--text-primary)]";
|
|
73
|
+
|
|
74
|
+
export function TileRadioGroup<T extends string>({
|
|
75
|
+
items,
|
|
76
|
+
checked,
|
|
77
|
+
onSelect,
|
|
78
|
+
activation,
|
|
79
|
+
disabled,
|
|
80
|
+
size,
|
|
81
|
+
renderTile,
|
|
82
|
+
tileClassName,
|
|
83
|
+
}: TileRadioGroupProps<T>) {
|
|
84
|
+
const refs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
85
|
+
const noteId = useId();
|
|
86
|
+
const s = TILE_SIZE[size];
|
|
87
|
+
const checkedIndex =
|
|
88
|
+
checked === undefined ? -1 : items.findIndex((it) => it.key === checked);
|
|
89
|
+
// The ONE tab stop: the checked tile, else the first that can be chosen — the APG
|
|
90
|
+
// rule, so Tab into a group with nothing checked still lands somewhere.
|
|
91
|
+
const tabStop =
|
|
92
|
+
checkedIndex >= 0 && !items[checkedIndex].disabled
|
|
93
|
+
? checkedIndex
|
|
94
|
+
: items.findIndex((it) => !it.disabled);
|
|
95
|
+
|
|
96
|
+
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
|
97
|
+
// Left and right are VISUAL directions: in a right-to-left page the row runs the
|
|
98
|
+
// other way. Up and down follow reading order either way — the APG's radio group
|
|
99
|
+
// treats them as previous/next, and the tiles wrap, so there is no column to keep.
|
|
100
|
+
const rtl = e.currentTarget.closest("[dir]")?.getAttribute("dir") === "rtl";
|
|
101
|
+
let step: number | "first" | "last";
|
|
102
|
+
switch (e.key) {
|
|
103
|
+
case "ArrowRight":
|
|
104
|
+
step = rtl ? -1 : 1;
|
|
105
|
+
break;
|
|
106
|
+
case "ArrowLeft":
|
|
107
|
+
step = rtl ? 1 : -1;
|
|
108
|
+
break;
|
|
109
|
+
case "ArrowDown":
|
|
110
|
+
step = 1;
|
|
111
|
+
break;
|
|
112
|
+
case "ArrowUp":
|
|
113
|
+
step = -1;
|
|
114
|
+
break;
|
|
115
|
+
case "Home":
|
|
116
|
+
step = "first";
|
|
117
|
+
break;
|
|
118
|
+
case "End":
|
|
119
|
+
step = "last";
|
|
120
|
+
break;
|
|
121
|
+
default:
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
const enabled = items.map((it, i) => (it.disabled ? -1 : i)).filter((i) => i >= 0);
|
|
126
|
+
if (!enabled.length) return;
|
|
127
|
+
const at = enabled.indexOf(index);
|
|
128
|
+
let target: number;
|
|
129
|
+
if (step === "first") target = enabled[0];
|
|
130
|
+
else if (step === "last") target = enabled[enabled.length - 1];
|
|
131
|
+
// Wrapping, as a native radio group does: the last tile's "next" is the first.
|
|
132
|
+
else target = enabled[(at + step + enabled.length) % enabled.length];
|
|
133
|
+
refs.current[target]?.focus();
|
|
134
|
+
if (activation === "automatic") onSelect(items[target].key);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<>
|
|
139
|
+
{items.map((item, i) => {
|
|
140
|
+
const selected = i === checkedIndex;
|
|
141
|
+
const button = (
|
|
142
|
+
<button
|
|
143
|
+
ref={(el) => {
|
|
144
|
+
refs.current[i] = el;
|
|
145
|
+
}}
|
|
146
|
+
type="button"
|
|
147
|
+
role="radio"
|
|
148
|
+
aria-checked={selected}
|
|
149
|
+
aria-label={item.label}
|
|
150
|
+
// The note is a DESCRIPTION, read after the name and the state, not glued
|
|
151
|
+
// into the name — the punctuation between the two would be English's.
|
|
152
|
+
aria-describedby={item.note ? `${noteId}-${i}` : undefined}
|
|
153
|
+
tabIndex={i === tabStop ? 0 : -1}
|
|
154
|
+
disabled={disabled || item.disabled}
|
|
155
|
+
// Clicking the checked tile again does nothing: a radio is not a toggle.
|
|
156
|
+
// "None" is the way back to nothing, and it is a tile of its own.
|
|
157
|
+
onClick={() => !selected && onSelect(item.key)}
|
|
158
|
+
onKeyDown={(e) => onKeyDown(e, i)}
|
|
159
|
+
className={cn(
|
|
160
|
+
TILE_BASE,
|
|
161
|
+
s.tile,
|
|
162
|
+
selected ? TILE_SELECTED : TILE_IDLE,
|
|
163
|
+
tileClassName?.(item, selected),
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{item.key === null ? (
|
|
167
|
+
<Ban aria-hidden className={cn(s.glyph, "text-[var(--text-muted)]")} />
|
|
168
|
+
) : (
|
|
169
|
+
renderTile(item, selected)
|
|
170
|
+
)}
|
|
171
|
+
{item.note && (
|
|
172
|
+
<>
|
|
173
|
+
{/* A dot rather than a dimmed tile: dimming reads as "disabled", and a
|
|
174
|
+
tile with a note is entirely available. */}
|
|
175
|
+
<span
|
|
176
|
+
aria-hidden
|
|
177
|
+
className="absolute end-0.5 top-0.5 size-1.5 rounded-full bg-[var(--text-muted)]"
|
|
178
|
+
/>
|
|
179
|
+
{/* `hidden` still serves as a description: aria-describedby reads
|
|
180
|
+
hidden text, which is what keeps it out of the layout. */}
|
|
181
|
+
<span id={`${noteId}-${i}`} hidden>
|
|
182
|
+
{item.note}
|
|
183
|
+
</span>
|
|
184
|
+
</>
|
|
185
|
+
)}
|
|
186
|
+
{selected && <SelectedTick />}
|
|
187
|
+
</button>
|
|
188
|
+
);
|
|
189
|
+
return (
|
|
190
|
+
// The bubble is the SIGHTED user's name for the tile — eight unlabelled
|
|
191
|
+
// squares are the complaint that put a bubble on Keksdose's (live #289).
|
|
192
|
+
// It wraps a span rather than the button, because a Tooltip hands its
|
|
193
|
+
// child an `aria-describedby`, and a tile named "Red" and described as
|
|
194
|
+
// "Red" is read out twice.
|
|
195
|
+
<Tooltip
|
|
196
|
+
key={item.key ?? "\u0000none"}
|
|
197
|
+
label={
|
|
198
|
+
item.note ? (
|
|
199
|
+
<>
|
|
200
|
+
<span className="block">{item.label}</span>
|
|
201
|
+
<span className="block font-normal text-[var(--text-muted)]">{item.note}</span>
|
|
202
|
+
</>
|
|
203
|
+
) : (
|
|
204
|
+
item.label
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
portal
|
|
208
|
+
>
|
|
209
|
+
<span className="inline-flex">{button}</span>
|
|
210
|
+
</Tooltip>
|
|
211
|
+
);
|
|
212
|
+
})}
|
|
213
|
+
</>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The tick on a selected tile — a small badge in the corner, in the text colour on
|
|
218
|
+
* the surface colour, so it reads the same over every swatch colour. */
|
|
219
|
+
function SelectedTick() {
|
|
220
|
+
return (
|
|
221
|
+
<span
|
|
222
|
+
aria-hidden
|
|
223
|
+
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)]"
|
|
224
|
+
>
|
|
225
|
+
<Check strokeWidth={3} className="size-2.5" />
|
|
226
|
+
</span>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -16,10 +16,8 @@ export interface ToggleOption<T extends string> {
|
|
|
16
16
|
* accepted and the component ignored would be a prop that silently does nothing —
|
|
17
17
|
* worse than one that does not compile.
|
|
18
18
|
*/
|
|
19
|
-
export interface
|
|
19
|
+
export interface ToggleGroupBaseProps<T extends string>
|
|
20
20
|
extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children"> {
|
|
21
|
-
value: T;
|
|
22
|
-
onChange: (value: T) => void;
|
|
23
21
|
options: ToggleOption<T>[];
|
|
24
22
|
className?: string;
|
|
25
23
|
/** Applied to every option button (e.g. to tune height/rounding to match
|
|
@@ -47,17 +45,54 @@ export interface ToggleGroupProps<T extends string>
|
|
|
47
45
|
disabled?: boolean;
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
48
|
+
/** The group as it has always been: one option is always the answer. */
|
|
49
|
+
export interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {
|
|
50
|
+
allowEmpty?: false;
|
|
51
|
+
value: T;
|
|
52
|
+
onChange: (value: T) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A group that can be emptied: clicking the active option clears it, and `onChange`
|
|
57
|
+
* receives `null` (Keksdose's support-panel filters, where "no filter" is reached by
|
|
58
|
+
* clicking the filter that is on).
|
|
59
|
+
*
|
|
60
|
+
* The options become TOGGLE BUTTONS (`aria-pressed`, in a `role="group"`) rather than
|
|
61
|
+
* radios. A radio cannot be unchecked by activating it — no screen reader user expects
|
|
62
|
+
* a second press on "Open, radio, checked" to leave nothing checked, and nothing would
|
|
63
|
+
* tell them it had. "Open, toggle button, pressed" says exactly what a press will do.
|
|
64
|
+
*/
|
|
65
|
+
export interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {
|
|
66
|
+
allowEmpty: true;
|
|
67
|
+
value: T | null;
|
|
68
|
+
onChange: (value: T | null) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `allowEmpty` picks the shape, so `onChange` is typed `(T) => void` unless the group
|
|
72
|
+
* can actually emit `null` — no existing caller has a `null` to handle. */
|
|
73
|
+
export type ToggleGroupProps<T extends string> =
|
|
74
|
+
| ToggleGroupRequiredProps<T>
|
|
75
|
+
| ToggleGroupClearableProps<T>;
|
|
76
|
+
|
|
77
|
+
export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>) {
|
|
78
|
+
const {
|
|
79
|
+
value,
|
|
80
|
+
options,
|
|
81
|
+
className,
|
|
82
|
+
optionClassName,
|
|
83
|
+
ariaLabel,
|
|
84
|
+
disabled = false,
|
|
85
|
+
"aria-label": ariaLabelAttr,
|
|
86
|
+
...restWithMode
|
|
87
|
+
} = props;
|
|
88
|
+
// Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is
|
|
89
|
+
// what lets the `onChange` below be called with `null` only in the mode that allows it.
|
|
90
|
+
const { allowEmpty: _allowEmpty, onChange: _onChange, ...rest } = restWithMode;
|
|
91
|
+
const choose = (next: T) => {
|
|
92
|
+
if (props.allowEmpty) props.onChange(next === value ? null : next);
|
|
93
|
+
else props.onChange(next);
|
|
94
|
+
};
|
|
95
|
+
const clearable = props.allowEmpty === true;
|
|
61
96
|
return (
|
|
62
97
|
<div
|
|
63
98
|
// The audit's named example of a closed prop list (§"Public API design"): the
|
|
@@ -71,7 +106,7 @@ export function ToggleGroup<T extends string>({
|
|
|
71
106
|
// radiogroup role or the disabled state by accident. `className` is destructured
|
|
72
107
|
// out entirely and merged through `cn`, so it is never in here.
|
|
73
108
|
{...rest}
|
|
74
|
-
role="radiogroup"
|
|
109
|
+
role={clearable ? "group" : "radiogroup"}
|
|
75
110
|
// The DOM spelling wins; `ariaLabel` is the fallback for the call sites that
|
|
76
111
|
// have not moved yet.
|
|
77
112
|
aria-label={ariaLabelAttr ?? ariaLabel}
|
|
@@ -106,10 +141,11 @@ export function ToggleGroup<T extends string>({
|
|
|
106
141
|
<button
|
|
107
142
|
key={opt.value}
|
|
108
143
|
type="button"
|
|
109
|
-
role="radio"
|
|
110
|
-
aria-checked={active}
|
|
144
|
+
role={clearable ? undefined : "radio"}
|
|
145
|
+
aria-checked={clearable ? undefined : active}
|
|
146
|
+
aria-pressed={clearable ? active : undefined}
|
|
111
147
|
disabled={disabled}
|
|
112
|
-
onClick={() =>
|
|
148
|
+
onClick={() => choose(opt.value)}
|
|
113
149
|
className={cn(
|
|
114
150
|
// `truncate` (which carries whitespace-nowrap) rather than letting a
|
|
115
151
|
// label wrap: a segmented control sizes its whole row to the tallest
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Internal — not re-exported from the barrel. Shared by the pickers whose trigger is
|
|
2
|
+
// a `role="combobox"` button inside a wrapper: DatePicker, DateRangePicker, MonthPicker.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The attributes that NAME or DESCRIBE a field, which belong on its trigger — the
|
|
6
|
+
* element a `<label htmlFor>`, a form library's control slot or an error message has
|
|
7
|
+
* to reach. They used to land on the wrapper `<div>` with the rest of the caller's
|
|
8
|
+
* props, so an external label named nothing and a form library's `aria-describedby`
|
|
9
|
+
* pointed a screen reader at an element with no role (kastlan, 0.5.0).
|
|
10
|
+
*/
|
|
11
|
+
export interface TriggerAria {
|
|
12
|
+
id?: string;
|
|
13
|
+
"aria-label"?: string;
|
|
14
|
+
"aria-labelledby"?: string;
|
|
15
|
+
"aria-describedby"?: string;
|
|
16
|
+
"aria-invalid"?: boolean | "true" | "false" | "grammar" | "spelling";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Split a field's props into what its trigger takes and what its wrapper takes. */
|
|
20
|
+
export function splitTriggerAria<T extends TriggerAria>(
|
|
21
|
+
props: T,
|
|
22
|
+
): [TriggerAria, Omit<T, keyof TriggerAria>] {
|
|
23
|
+
const {
|
|
24
|
+
id,
|
|
25
|
+
"aria-label": label,
|
|
26
|
+
"aria-labelledby": labelledBy,
|
|
27
|
+
"aria-describedby": describedBy,
|
|
28
|
+
"aria-invalid": invalid,
|
|
29
|
+
...rest
|
|
30
|
+
} = props;
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
id,
|
|
34
|
+
"aria-label": label,
|
|
35
|
+
"aria-labelledby": labelledBy,
|
|
36
|
+
"aria-describedby": describedBy,
|
|
37
|
+
"aria-invalid": invalid,
|
|
38
|
+
},
|
|
39
|
+
rest,
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|