@eifi1/ui-kit 0.5.1 → 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 +3 -2
- 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/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/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/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/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/ui.d.ts +150 -12
- package/dist/components/ui.js +196 -22
- 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 +24 -2
- 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/danger-confirm.tsx +286 -0
- 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/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/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/ui.tsx +400 -24
- 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
|
@@ -27,8 +27,30 @@ interface SearchFieldOwnProps
|
|
|
27
27
|
* decision, not a default: a filter you can type into and not untype is the
|
|
28
28
|
* complaint that produced the button on three of these four screens. */
|
|
29
29
|
clearLabel?: string;
|
|
30
|
+
/**
|
|
31
|
+
* `"field"` (default): the bordered filter box that sits on a page.
|
|
32
|
+
*
|
|
33
|
+
* `"inline"`: no box at all — no border, no fill, no padding of its own — and no
|
|
34
|
+
* type size of its own either, so it takes the size of the header it sits in.
|
|
35
|
+
* For a search that IS the header: a command-palette sheet, a picker's top line
|
|
36
|
+
* (Keksdose's transaction search, which lost its `text-base` headline to a
|
|
37
|
+
* bordered `text-sm` box when it moved onto this component). The container is
|
|
38
|
+
* what frames it, so give the container the focus cue if it needs one
|
|
39
|
+
* (`focus-within:`); the caret is the field's own.
|
|
40
|
+
*/
|
|
41
|
+
variant?: "field" | "inline";
|
|
42
|
+
/** Classes for the `<input>` itself. `className` styles the wrapper — the box the
|
|
43
|
+
* icon and the clear button are positioned against — so it could set a width
|
|
44
|
+
* and nothing else. See {@link Input}'s `inputClassName`. */
|
|
45
|
+
inputClassName?: string;
|
|
30
46
|
}
|
|
31
47
|
|
|
48
|
+
// The inline look: FIELD_DISPLAY's idea (the chrome gone, the value the thing
|
|
49
|
+
// itself) without even its baseline, because the header it sits in already draws
|
|
50
|
+
// the line. No `text-*` size, on purpose — the input inherits the caller's.
|
|
51
|
+
const SEARCH_INLINE =
|
|
52
|
+
"block w-full min-w-0 border-0 bg-transparent py-1 text-[var(--text-primary)] shadow-none placeholder:text-[var(--text-placeholder)] focus:outline-none focus:ring-0";
|
|
53
|
+
|
|
32
54
|
/**
|
|
33
55
|
* One of the two spellings is REQUIRED, and the union is how that survives the
|
|
34
56
|
* deprecation. Making `label` merely optional would have been the easy change and the
|
|
@@ -70,7 +92,18 @@ export type SearchFieldProps = SearchFieldOwnProps &
|
|
|
70
92
|
* the next query can be typed straight away.
|
|
71
93
|
*/
|
|
72
94
|
export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField(
|
|
73
|
-
{
|
|
95
|
+
{
|
|
96
|
+
value,
|
|
97
|
+
onChange,
|
|
98
|
+
label,
|
|
99
|
+
clearLabel,
|
|
100
|
+
className,
|
|
101
|
+
inputClassName,
|
|
102
|
+
placeholder,
|
|
103
|
+
variant = "field",
|
|
104
|
+
"aria-label": ariaLabel,
|
|
105
|
+
...rest
|
|
106
|
+
},
|
|
74
107
|
ref,
|
|
75
108
|
) {
|
|
76
109
|
const innerRef = useRef<HTMLInputElement>(null);
|
|
@@ -78,11 +111,17 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
|
|
|
78
111
|
// fallback, so a box named only by `aria-label` still says what it is before anyone
|
|
79
112
|
// has typed in it — the same deal `label` has always had.
|
|
80
113
|
const name = ariaLabel ?? label;
|
|
114
|
+
const inline = variant === "inline";
|
|
81
115
|
return (
|
|
82
116
|
<div className={cn("relative", className)}>
|
|
83
117
|
<Search
|
|
84
118
|
aria-hidden
|
|
85
|
-
className=
|
|
119
|
+
className={cn(
|
|
120
|
+
"pointer-events-none absolute top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
|
|
121
|
+
// Logical sides throughout, so a right-to-left page gets the icon at the
|
|
122
|
+
// start of the line and the clear button at its end.
|
|
123
|
+
inline ? "start-0" : "start-3",
|
|
124
|
+
)}
|
|
86
125
|
/>
|
|
87
126
|
<input
|
|
88
127
|
ref={(node) => {
|
|
@@ -117,12 +156,13 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
|
|
|
117
156
|
spellCheck={false}
|
|
118
157
|
{...rest}
|
|
119
158
|
className={cn(
|
|
120
|
-
FIELD_BASE,
|
|
121
|
-
"
|
|
159
|
+
inline ? SEARCH_INLINE : FIELD_BASE,
|
|
160
|
+
inline ? "ps-7" : "ps-9",
|
|
122
161
|
// Room for our own "×" only when there is one; a field that can't be
|
|
123
162
|
// cleared has no reason to reserve the space.
|
|
124
|
-
clearLabel === undefined ? "
|
|
163
|
+
clearLabel === undefined ? (inline ? "pe-0" : "pe-3") : inline ? "pe-8" : "pe-9",
|
|
125
164
|
"[&::-webkit-search-cancel-button]:appearance-none",
|
|
165
|
+
inputClassName,
|
|
126
166
|
)}
|
|
127
167
|
/>
|
|
128
168
|
{clearLabel !== undefined && value !== "" && (
|
|
@@ -135,7 +175,10 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
|
|
|
135
175
|
innerRef.current?.focus();
|
|
136
176
|
}}
|
|
137
177
|
aria-label={clearLabel}
|
|
138
|
-
className=
|
|
178
|
+
className={cn(
|
|
179
|
+
"absolute top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]",
|
|
180
|
+
inline ? "end-0" : "end-2",
|
|
181
|
+
)}
|
|
139
182
|
>
|
|
140
183
|
<X className="size-4" />
|
|
141
184
|
</button>
|
|
@@ -72,6 +72,12 @@ export interface SignaturePadLabels {
|
|
|
72
72
|
/** Live-region messages after the matching button. */
|
|
73
73
|
cleared: string;
|
|
74
74
|
undone: string;
|
|
75
|
+
/** {@link SignatureView}: what it says when there is no signature to show. */
|
|
76
|
+
viewEmpty: string;
|
|
77
|
+
/** {@link SignatureView}: the saved PNG's alternative text. */
|
|
78
|
+
viewDrawn: string;
|
|
79
|
+
/** {@link SignatureView}: the accessible name of a typed-name signature. */
|
|
80
|
+
viewTyped: (name: string) => string;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
export const DEFAULT_SIGNATURE_PAD_LABELS: SignaturePadLabels = {
|
|
@@ -88,6 +94,9 @@ export const DEFAULT_SIGNATURE_PAD_LABELS: SignaturePadLabels = {
|
|
|
88
94
|
typedName: "Full name",
|
|
89
95
|
cleared: "Signature cleared",
|
|
90
96
|
undone: "Last stroke removed",
|
|
97
|
+
viewEmpty: "Not signed",
|
|
98
|
+
viewDrawn: "Handwritten signature",
|
|
99
|
+
viewTyped: (name) => `Signed with the typed name ${name}`,
|
|
91
100
|
};
|
|
92
101
|
|
|
93
102
|
/* ── Types ───────────────────────────────────────────────────────────────── */
|
|
@@ -628,3 +637,106 @@ export const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(fu
|
|
|
628
637
|
);
|
|
629
638
|
});
|
|
630
639
|
SignaturePad.displayName = "SignaturePad";
|
|
640
|
+
|
|
641
|
+
/* ── Read-only ───────────────────────────────────────────────────────────── */
|
|
642
|
+
|
|
643
|
+
export interface SignatureViewProps extends ComponentPropsWithoutRef<"figure"> {
|
|
644
|
+
/** The saved PNG — what `SignaturePad` handed to `onChange`/`onSave`, as stored. */
|
|
645
|
+
value?: string | null;
|
|
646
|
+
/**
|
|
647
|
+
* A signature given as a typed name (`detail.typedName`), for a host that stores the
|
|
648
|
+
* text rather than its PNG. Rendered in the same script face the pad draws it in.
|
|
649
|
+
* Ignored while `value` is set.
|
|
650
|
+
*/
|
|
651
|
+
typedName?: string | null;
|
|
652
|
+
/** Visible caption above the signature; defaults to `labels.label`. `null` hides it
|
|
653
|
+
* (the image keeps its alternative text) — for a signature block whose own heading
|
|
654
|
+
* already says whose signature it is. */
|
|
655
|
+
label?: ReactNode;
|
|
656
|
+
/**
|
|
657
|
+
* The exported PNG is dark ink on a transparent ground (`exportInk`), which is
|
|
658
|
+
* invisible on the dark theme's surface. By default the image is colour-inverted
|
|
659
|
+
* under `.dark`, so the ink reads light; `false` keeps the pixels as stored — for a
|
|
660
|
+
* PNG exported with its own `exportBackground`, which inverting would turn black.
|
|
661
|
+
*/
|
|
662
|
+
adaptInk?: boolean;
|
|
663
|
+
/** Classes for the frame — its height, chiefly (default `h-40`, the pad's). */
|
|
664
|
+
frameClassName?: string;
|
|
665
|
+
/** User-facing strings; see {@link SignaturePadLabels} (`view*`). */
|
|
666
|
+
labels?: Partial<SignaturePadLabels>;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* A saved signature, shown — the read side of {@link SignaturePad}: the same frame and
|
|
671
|
+
* signing line, none of the drawing chrome (no canvas, buttons, instructions or live
|
|
672
|
+
* region). A separate component rather than a `readOnly` pad because there is nothing
|
|
673
|
+
* of the pad to reuse: the pad's scene is STROKES, and a stored signature is a PNG
|
|
674
|
+
* (or a name) that cannot be turned back into them.
|
|
675
|
+
*
|
|
676
|
+
* Kastlan's handover protocol, reopened after signing, is the case: two signatures
|
|
677
|
+
* that must be visible, and must not look editable.
|
|
678
|
+
*/
|
|
679
|
+
export function SignatureView({
|
|
680
|
+
value,
|
|
681
|
+
typedName,
|
|
682
|
+
label,
|
|
683
|
+
adaptInk = true,
|
|
684
|
+
frameClassName,
|
|
685
|
+
labels: labelsProp,
|
|
686
|
+
className,
|
|
687
|
+
...rest
|
|
688
|
+
}: SignatureViewProps) {
|
|
689
|
+
const labels = useKitLabels("signaturePad", DEFAULT_SIGNATURE_PAD_LABELS, labelsProp);
|
|
690
|
+
const name = typedName?.trim() ?? "";
|
|
691
|
+
const kind = value ? "drawn" : name ? "typed" : "empty";
|
|
692
|
+
const caption = label === undefined ? labels.label : label;
|
|
693
|
+
const captionId = useId();
|
|
694
|
+
const captioned = caption !== null && caption !== false;
|
|
695
|
+
return (
|
|
696
|
+
<figure
|
|
697
|
+
// Named explicitly: the figcaption-to-figure name is not computed everywhere.
|
|
698
|
+
aria-labelledby={captioned ? captionId : undefined}
|
|
699
|
+
{...rest}
|
|
700
|
+
className={cn("m-0 space-y-1.5", className)}
|
|
701
|
+
data-empty={kind === "empty" || undefined}
|
|
702
|
+
>
|
|
703
|
+
{captioned && (
|
|
704
|
+
<figcaption id={captionId} className="block text-sm font-medium text-[var(--text-primary)]">{caption}</figcaption>
|
|
705
|
+
)}
|
|
706
|
+
<div
|
|
707
|
+
className={cn(
|
|
708
|
+
"relative flex h-40 items-center justify-center overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)]",
|
|
709
|
+
frameClassName,
|
|
710
|
+
)}
|
|
711
|
+
>
|
|
712
|
+
{kind === "drawn" && (
|
|
713
|
+
<img
|
|
714
|
+
src={value as string}
|
|
715
|
+
alt={labels.viewDrawn}
|
|
716
|
+
className={cn("relative max-h-full max-w-full object-contain", adaptInk && "dark:invert")}
|
|
717
|
+
/>
|
|
718
|
+
)}
|
|
719
|
+
{kind === "typed" && (
|
|
720
|
+
// One name for the whole thing: the visible text alone would be read as a bare
|
|
721
|
+
// name, with nothing saying it IS the signature.
|
|
722
|
+
<span
|
|
723
|
+
role="img"
|
|
724
|
+
aria-label={labels.viewTyped(name)}
|
|
725
|
+
className="relative max-w-[85%] truncate px-2 text-4xl italic text-[var(--text-primary)]"
|
|
726
|
+
style={{ fontFamily: SCRIPT_FONT }}
|
|
727
|
+
>
|
|
728
|
+
{name}
|
|
729
|
+
</span>
|
|
730
|
+
)}
|
|
731
|
+
{kind === "empty" && (
|
|
732
|
+
<span className="relative text-xs text-[var(--text-muted)]">{labels.viewEmpty}</span>
|
|
733
|
+
)}
|
|
734
|
+
{/* The signing line, as on the pad. Decoration only. */}
|
|
735
|
+
<div
|
|
736
|
+
aria-hidden="true"
|
|
737
|
+
className="pointer-events-none absolute inset-x-6 bottom-8 border-b border-dashed border-[var(--border-strong)]"
|
|
738
|
+
/>
|
|
739
|
+
</div>
|
|
740
|
+
</figure>
|
|
741
|
+
);
|
|
742
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useId, useMemo } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
|
+
import { TILE_SIZE, TileRadioGroup } from "./tile-radio";
|
|
6
|
+
import type { TileItem, TileSize } from "./tile-radio";
|
|
7
|
+
|
|
8
|
+
export interface SwatchPickerLabels {
|
|
9
|
+
/** The "no colour" tile's name, when `allowNone` is set. */
|
|
10
|
+
none: string;
|
|
11
|
+
/** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */
|
|
12
|
+
mixed: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_SWATCH_PICKER_LABELS: SwatchPickerLabels = {
|
|
16
|
+
none: "No colour",
|
|
17
|
+
mixed: "Mixed: the selected items have different colours",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface SwatchOption<T extends string> {
|
|
21
|
+
value: T;
|
|
22
|
+
/** Any CSS colour — a token (`var(--chart-3)`), a hex, an `oklch()`. Painted as an
|
|
23
|
+
* inline background, so it does not have to be a class the kit's CSS knows.
|
|
24
|
+
* Leave it out when `swatchClassName` paints the dot instead. */
|
|
25
|
+
color?: string;
|
|
26
|
+
/** The colour's name, in the user's language. Shown in the bubble and read out;
|
|
27
|
+
* required, because a swatch with no name is a colour only some people can read. */
|
|
28
|
+
label: string;
|
|
29
|
+
/** Classes for the swatch dot, for a palette that lives in classes rather than
|
|
30
|
+
* values (a light/dark pair). An inline `color` would win over a class, so pass
|
|
31
|
+
* one or the other. */
|
|
32
|
+
swatchClassName?: string;
|
|
33
|
+
/** A remark read after the name and shown under it in the bubble ("used by
|
|
34
|
+
* Groceries"). The tile wears a dot while it has one. */
|
|
35
|
+
note?: string;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `onChange` is the picker's own — the chosen VALUE, not a DOM event — so the div's
|
|
41
|
+
* is omitted rather than shadowed, as on {@link ToggleGroup}.
|
|
42
|
+
*/
|
|
43
|
+
export interface SwatchPickerProps<T extends string>
|
|
44
|
+
extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children" | "defaultValue"> {
|
|
45
|
+
options: SwatchOption<T>[];
|
|
46
|
+
/** The chosen colour, or `null` for none. */
|
|
47
|
+
value: T | null;
|
|
48
|
+
onChange: (value: T | null) => void;
|
|
49
|
+
/** Lead with a "no colour" tile whose value is `null`. Clearing a colour has to be
|
|
50
|
+
* as reachable as setting one, and a radio cannot be unchecked by clicking it. */
|
|
51
|
+
allowNone?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* "Some of each" — a bulk edit over rows whose colours differ. Nothing is checked
|
|
54
|
+
* (not even "none", whatever `value` says), and the group is described as mixed.
|
|
55
|
+
* Distinct from `value={null}`, which is an answer: "no colour".
|
|
56
|
+
*/
|
|
57
|
+
mixed?: boolean;
|
|
58
|
+
/** See {@link IconPickerProps.activation}. Default "automatic". */
|
|
59
|
+
activation?: "automatic" | "manual";
|
|
60
|
+
/** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
|
|
61
|
+
size?: TileSize;
|
|
62
|
+
disabled?: boolean;
|
|
63
|
+
labels?: Partial<SwatchPickerLabels>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A row of colour swatches that is one radio group.
|
|
68
|
+
*
|
|
69
|
+
* Name the group with `aria-label` or `aria-labelledby` — usually the visible heading
|
|
70
|
+
* above it. Each swatch is named by its `label` and shows it in a bubble on hover and
|
|
71
|
+
* focus, so the name is not the screen reader's alone.
|
|
72
|
+
*
|
|
73
|
+
* Keyboard (the APG radio group): Tab reaches the checked swatch, or the first one
|
|
74
|
+
* while nothing is checked; the arrow keys move between swatches and, by default,
|
|
75
|
+
* choose as they go; Home and End jump to the ends. Left and right follow the page's
|
|
76
|
+
* direction.
|
|
77
|
+
*/
|
|
78
|
+
export function SwatchPicker<T extends string>({
|
|
79
|
+
options,
|
|
80
|
+
value,
|
|
81
|
+
onChange,
|
|
82
|
+
allowNone = false,
|
|
83
|
+
mixed = false,
|
|
84
|
+
activation = "automatic",
|
|
85
|
+
size = "md",
|
|
86
|
+
disabled = false,
|
|
87
|
+
labels,
|
|
88
|
+
className,
|
|
89
|
+
...rest
|
|
90
|
+
}: SwatchPickerProps<T>) {
|
|
91
|
+
const text = useKitLabels("swatchPicker", DEFAULT_SWATCH_PICKER_LABELS, labels);
|
|
92
|
+
const mixedId = useId();
|
|
93
|
+
const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
|
|
94
|
+
const items: TileItem<T>[] = [
|
|
95
|
+
...(allowNone ? [{ key: null, label: text.none }] : []),
|
|
96
|
+
...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),
|
|
97
|
+
];
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
{...rest}
|
|
101
|
+
role="radiogroup"
|
|
102
|
+
aria-disabled={disabled || undefined}
|
|
103
|
+
aria-describedby={
|
|
104
|
+
[rest["aria-describedby"], mixed && mixedId].filter(Boolean).join(" ") || undefined
|
|
105
|
+
}
|
|
106
|
+
className={cn("flex flex-wrap items-center gap-1.5", className)}
|
|
107
|
+
>
|
|
108
|
+
<TileRadioGroup
|
|
109
|
+
items={items}
|
|
110
|
+
checked={mixed ? undefined : value}
|
|
111
|
+
onSelect={onChange}
|
|
112
|
+
activation={activation}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
size={size}
|
|
115
|
+
renderTile={(item) => {
|
|
116
|
+
const opt = item.key === null ? undefined : byValue.get(item.key);
|
|
117
|
+
return (
|
|
118
|
+
<span
|
|
119
|
+
aria-hidden
|
|
120
|
+
// A hairline round the dot, drawn over whatever colour it is: a pale
|
|
121
|
+
// yellow swatch on a white surface is otherwise a hole in the row.
|
|
122
|
+
className={cn(
|
|
123
|
+
"rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15",
|
|
124
|
+
TILE_SIZE[size].swatch,
|
|
125
|
+
opt?.swatchClassName,
|
|
126
|
+
)}
|
|
127
|
+
style={{ background: opt?.color }}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
{/* `hidden`, not `sr-only`: a description is read from hidden text, and this
|
|
133
|
+
way it takes no room in the row. */}
|
|
134
|
+
{mixed && (
|
|
135
|
+
<span id={mixedId} hidden>
|
|
136
|
+
{text.mixed}
|
|
137
|
+
</span>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -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
|
+
}
|