@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
|
@@ -4,7 +4,7 @@ import { CalculatorButton, type CalculatorButtonLabels } from "./calculator";
|
|
|
4
4
|
import { NumberPadSheet, type NumberPadSheetLabels } from "./numpad-sheet";
|
|
5
5
|
import { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from "./ui";
|
|
6
6
|
import { cn } from "../lib/cn";
|
|
7
|
-
import { commitExpression, sanitizeLive } from "../lib/calc";
|
|
7
|
+
import { commitExpression, formatResult, sanitizeLive } from "../lib/calc";
|
|
8
8
|
import { useMediaQuery } from "../hooks/use-media-query";
|
|
9
9
|
|
|
10
10
|
interface NumberInputProps {
|
|
@@ -73,6 +73,64 @@ interface NumberInputProps {
|
|
|
73
73
|
* every value reads as part of the number.
|
|
74
74
|
*/
|
|
75
75
|
suffix?: ReactNode;
|
|
76
|
+
/**
|
|
77
|
+
* Turns on the step keys: ArrowUp / ArrowDown add or subtract `step`, PageUp /
|
|
78
|
+
* PageDown ten of them. The result lands on the grid `min + k × step` (or `0 + k ×
|
|
79
|
+
* step` without a `min`), so 3.1 with `step={0.25}` goes up to 3.25, not 3.35 —
|
|
80
|
+
* what a native number input does. Decimal-exact: `0.1 + 0.2` steps to "0.3".
|
|
81
|
+
*
|
|
82
|
+
* A step changes the text through `onChange` like a keystroke would; `onCommit`
|
|
83
|
+
* still fires on blur/Enter. Without `step` the arrow keys move the caret as before.
|
|
84
|
+
*/
|
|
85
|
+
step?: number;
|
|
86
|
+
/** Bounds for the step keys only — a step never leaves `[min, max]`. This field's
|
|
87
|
+
* value is a STRING, so typed text is not clamped: {@link NumberField} is the
|
|
88
|
+
* numeric field that clamps what is typed as well. */
|
|
89
|
+
min?: number;
|
|
90
|
+
max?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Decimal places of `n` as written ("0.25" → 2, "1e-7" → 7), for decimal-exact
|
|
94
|
+
* stepping. */
|
|
95
|
+
function decimalsOf(n: number): number {
|
|
96
|
+
const text = formatResult(n);
|
|
97
|
+
const dot = text.indexOf(".");
|
|
98
|
+
return dot === -1 ? 0 : text.length - dot - 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `current` moved by `count` steps (negative = down), on the grid `origin + k × step`
|
|
103
|
+
* and clamped into `[min, max]`. An off-grid value moves to the NEXT grid point in
|
|
104
|
+
* that direction first, so one press is never more than one step.
|
|
105
|
+
*
|
|
106
|
+
* The grid index is found by division and the result rebuilt as `origin + k × step`,
|
|
107
|
+
* then cut to the decimals `step` and `origin` are written with — which is where
|
|
108
|
+
* binary noise goes: `0.1 × 3` is 0.30000000000000004, and `toFixed(1)` of it is
|
|
109
|
+
* "0.3". Summing steps would accumulate that noise press by press instead.
|
|
110
|
+
*/
|
|
111
|
+
export function stepNumber(
|
|
112
|
+
current: number | null,
|
|
113
|
+
count: number,
|
|
114
|
+
step: number,
|
|
115
|
+
min?: number,
|
|
116
|
+
max?: number,
|
|
117
|
+
): number {
|
|
118
|
+
const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n));
|
|
119
|
+
// Nothing to step from: land on the value nearest zero the bounds allow, rather
|
|
120
|
+
// than on ±step — a room count with `min={1}` goes to 1, a rate to 0.
|
|
121
|
+
if (current === null || !Number.isFinite(current)) return clamp(0);
|
|
122
|
+
const origin = min !== undefined && Number.isFinite(min) ? min : 0;
|
|
123
|
+
const raw = (current - origin) / step;
|
|
124
|
+
const nearest = Math.round(raw);
|
|
125
|
+
// "On the grid" within float tolerance: (0.3 - 0) / 0.1 is 2.9999999999999996.
|
|
126
|
+
const onGrid = Math.abs(raw - nearest) < 1e-9;
|
|
127
|
+
const k = onGrid
|
|
128
|
+
? nearest + count
|
|
129
|
+
: count > 0
|
|
130
|
+
? Math.ceil(raw) + count - 1
|
|
131
|
+
: Math.floor(raw) + count + 1;
|
|
132
|
+
const places = Math.min(Math.max(decimalsOf(step), decimalsOf(origin)), 20);
|
|
133
|
+
return clamp(Number((origin + k * step).toFixed(places)));
|
|
76
134
|
}
|
|
77
135
|
|
|
78
136
|
/**
|
|
@@ -100,6 +158,9 @@ export function NumberInput({
|
|
|
100
158
|
hint,
|
|
101
159
|
invalid,
|
|
102
160
|
suffix,
|
|
161
|
+
step,
|
|
162
|
+
min,
|
|
163
|
+
max,
|
|
103
164
|
}: NumberInputProps) {
|
|
104
165
|
const generatedId = useId();
|
|
105
166
|
const fieldId = id ?? generatedId;
|
|
@@ -121,8 +182,24 @@ export function NumberInput({
|
|
|
121
182
|
onCommit?.(next);
|
|
122
183
|
};
|
|
123
184
|
|
|
185
|
+
const stepBy = (count: number) => {
|
|
186
|
+
// The step starts from the text as a commit would read it, so "12+5" steps from 17.
|
|
187
|
+
const resolved = commitExpression(value).trim();
|
|
188
|
+
const n = resolved === "" ? NaN : Number(resolved);
|
|
189
|
+
const next = formatResult(stepNumber(Number.isFinite(n) ? n : null, count, step as number, min, max));
|
|
190
|
+
if (next !== value) onChange(next);
|
|
191
|
+
};
|
|
192
|
+
|
|
124
193
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
125
|
-
if (e.key === "Enter") commit();
|
|
194
|
+
if (e.key === "Enter") return commit();
|
|
195
|
+
// A zero, negative or non-finite step is no step: the keys stay the caret's.
|
|
196
|
+
if (!step || !(step > 0) || !Number.isFinite(step) || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
197
|
+
const count =
|
|
198
|
+
e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : e.key === "PageUp" ? 10 : e.key === "PageDown" ? -10 : 0;
|
|
199
|
+
if (count === 0) return;
|
|
200
|
+
// Otherwise ArrowUp/Down also jump the caret to the start/end of the text.
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
stepBy(count);
|
|
126
203
|
};
|
|
127
204
|
|
|
128
205
|
return (
|
|
@@ -19,7 +19,10 @@ export interface PopoverLabels {
|
|
|
19
19
|
// Exported since the kit grew one label tree (`UiKitLabels`, src/i18n): the complete
|
|
20
20
|
// English reference a translator works from has to be able to name every namespace.
|
|
21
21
|
export const DEFAULT_POPOVER_LABELS: PopoverLabels = {
|
|
22
|
-
|
|
22
|
+
// "Pop-up", not "Popover": the panel's accessible name is read to USERS, and
|
|
23
|
+
// "popover" is a developer's word for it (reported by keksdose, 0.5.0). Only a
|
|
24
|
+
// bare Popover falls back to this — the kit's own pickers name their panels.
|
|
25
|
+
panel: "Pop-up",
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
/**
|
|
@@ -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
|
+
}
|