@eifi1/ui-kit 0.5.1 → 0.6.1
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 +103 -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/checkbox.d.ts +9 -0
- package/dist/components/checkbox.js +7 -2
- package/dist/components/checkbox.js.map +1 -1
- 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 +243 -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/switch.d.ts +9 -0
- package/dist/components/switch.js +7 -2
- package/dist/components/switch.js.map +1 -1
- 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 +429 -0
- package/src/components/checkbox.tsx +16 -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 +458 -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/switch.tsx +16 -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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useId, useMemo, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, ComponentType } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
5
|
+
import { SearchField } from "./search-field";
|
|
6
|
+
import { TILE_SIZE, TileRadioGroup } from "./tile-radio";
|
|
7
|
+
import type { TileItem, TileSize } from "./tile-radio";
|
|
8
|
+
|
|
9
|
+
export interface IconPickerLabels {
|
|
10
|
+
/** The "no icon" tile's name, when `allowNone` is set. */
|
|
11
|
+
none: string;
|
|
12
|
+
/** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */
|
|
13
|
+
mixed: string;
|
|
14
|
+
/** The search box's accessible name and placeholder, when `searchable` is set. */
|
|
15
|
+
search: string;
|
|
16
|
+
/** Shown in place of the tiles when the search matches nothing. */
|
|
17
|
+
noResults: string;
|
|
18
|
+
/** Announced as the search narrows the grid. Receives the number of matches. */
|
|
19
|
+
resultCount: (count: number) => string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_ICON_PICKER_LABELS: IconPickerLabels = {
|
|
23
|
+
none: "No icon",
|
|
24
|
+
mixed: "Mixed: the selected items have different icons",
|
|
25
|
+
search: "Search icons",
|
|
26
|
+
noResults: "No icons match",
|
|
27
|
+
resultCount: (count) => (count === 1 ? "1 icon" : `${count} icons`),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface IconOption<T extends string> {
|
|
31
|
+
value: T;
|
|
32
|
+
/** A Lucide icon, or any component that takes a `className`. Rendered `aria-hidden`
|
|
33
|
+
* — the tile's name is `label`. */
|
|
34
|
+
icon: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
|
|
35
|
+
/** What the symbol IS, in the user's language ("Game controller") — not the icon
|
|
36
|
+
* library's identifier (`Gamepad2`), which is nobody's word for anything. */
|
|
37
|
+
label: string;
|
|
38
|
+
/** More words the search matches ("games", "play"), besides `label`. */
|
|
39
|
+
keywords?: string[];
|
|
40
|
+
/** A remark read after the name and shown under it in the bubble ("used by
|
|
41
|
+
* Leisure"). The tile wears a dot while it has one. */
|
|
42
|
+
note?: string;
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IconPickerProps<T extends string>
|
|
47
|
+
extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children" | "defaultValue"> {
|
|
48
|
+
options: IconOption<T>[];
|
|
49
|
+
/** The chosen icon, or `null` for none. */
|
|
50
|
+
value: T | null;
|
|
51
|
+
onChange: (value: T | null) => void;
|
|
52
|
+
/** Lead with a "no icon" tile whose value is `null`. */
|
|
53
|
+
allowNone?: boolean;
|
|
54
|
+
/** See {@link SwatchPickerProps.mixed}: nothing is checked, and the group is
|
|
55
|
+
* described as mixed. */
|
|
56
|
+
mixed?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* "automatic" (default): an arrow key moves AND chooses, the APG radio group.
|
|
59
|
+
* "manual": arrows move focus only; Space or Enter chooses. For a picker whose every
|
|
60
|
+
* change is expensive — a bulk edit that saves as it goes.
|
|
61
|
+
*/
|
|
62
|
+
activation?: "automatic" | "manual";
|
|
63
|
+
/** A search box above the grid, matching `label` and `keywords`. For a set large
|
|
64
|
+
* enough that scanning it is slower than typing. */
|
|
65
|
+
searchable?: boolean;
|
|
66
|
+
/** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
|
|
67
|
+
size?: TileSize;
|
|
68
|
+
disabled?: boolean;
|
|
69
|
+
labels?: Partial<IconPickerLabels>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Case- and accent-insensitive, so "cafe" finds "Café". */
|
|
73
|
+
function fold(text: string, locale: string | undefined): string {
|
|
74
|
+
return text.normalize("NFD").replace(/\p{M}/gu, "").toLocaleLowerCase(locale);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A grid of symbols that is one radio group, with an optional search.
|
|
79
|
+
*
|
|
80
|
+
* Name the group with `aria-label` or `aria-labelledby`. The name lands on the
|
|
81
|
+
* radiogroup itself, not on the outer box: with `searchable` the outer box also holds
|
|
82
|
+
* the search field, which has a name of its own.
|
|
83
|
+
*
|
|
84
|
+
* Keyboard is {@link SwatchPicker}'s: one tab stop, arrows to move (flipped in a
|
|
85
|
+
* right-to-left page), Home and End.
|
|
86
|
+
*/
|
|
87
|
+
export function IconPicker<T extends string>({
|
|
88
|
+
options,
|
|
89
|
+
value,
|
|
90
|
+
onChange,
|
|
91
|
+
allowNone = false,
|
|
92
|
+
mixed = false,
|
|
93
|
+
activation = "automatic",
|
|
94
|
+
searchable = false,
|
|
95
|
+
size = "md",
|
|
96
|
+
disabled = false,
|
|
97
|
+
labels,
|
|
98
|
+
className,
|
|
99
|
+
"aria-label": ariaLabel,
|
|
100
|
+
"aria-labelledby": ariaLabelledBy,
|
|
101
|
+
"aria-describedby": ariaDescribedBy,
|
|
102
|
+
...rest
|
|
103
|
+
}: IconPickerProps<T>) {
|
|
104
|
+
const text = useKitLabels("iconPicker", DEFAULT_ICON_PICKER_LABELS, labels);
|
|
105
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
106
|
+
const locale = useKitLocale();
|
|
107
|
+
const mixedId = useId();
|
|
108
|
+
const [query, setQuery] = useState("");
|
|
109
|
+
const needle = fold(query.trim(), locale);
|
|
110
|
+
|
|
111
|
+
const visible = useMemo(
|
|
112
|
+
() =>
|
|
113
|
+
needle
|
|
114
|
+
? options.filter((o) =>
|
|
115
|
+
[o.label, ...(o.keywords ?? [])].some((w) => fold(w, locale).includes(needle)),
|
|
116
|
+
)
|
|
117
|
+
: options,
|
|
118
|
+
[options, needle, locale],
|
|
119
|
+
);
|
|
120
|
+
const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
|
|
121
|
+
const items: TileItem<T>[] = [
|
|
122
|
+
// "None" stays through a search: it is the way back to no icon, and it matches
|
|
123
|
+
// no word anybody would type for it.
|
|
124
|
+
...(allowNone ? [{ key: null, label: text.none }] : []),
|
|
125
|
+
...visible.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div {...rest} className={cn("space-y-2", className)}>
|
|
130
|
+
{searchable && (
|
|
131
|
+
<SearchField
|
|
132
|
+
value={query}
|
|
133
|
+
onChange={setQuery}
|
|
134
|
+
aria-label={text.search}
|
|
135
|
+
clearLabel={common.clear}
|
|
136
|
+
disabled={disabled}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
<div
|
|
140
|
+
role="radiogroup"
|
|
141
|
+
aria-label={ariaLabel}
|
|
142
|
+
aria-labelledby={ariaLabelledBy}
|
|
143
|
+
aria-describedby={[ariaDescribedBy, mixed && mixedId].filter(Boolean).join(" ") || undefined}
|
|
144
|
+
aria-disabled={disabled || undefined}
|
|
145
|
+
className="flex flex-wrap items-center gap-1.5"
|
|
146
|
+
>
|
|
147
|
+
<TileRadioGroup
|
|
148
|
+
items={items}
|
|
149
|
+
checked={mixed ? undefined : value}
|
|
150
|
+
onSelect={onChange}
|
|
151
|
+
activation={activation}
|
|
152
|
+
disabled={disabled}
|
|
153
|
+
size={size}
|
|
154
|
+
renderTile={(item) => {
|
|
155
|
+
const Icon = item.key === null ? undefined : byValue.get(item.key)?.icon;
|
|
156
|
+
return Icon ? <Icon aria-hidden className={TILE_SIZE[size].glyph} /> : null;
|
|
157
|
+
}}
|
|
158
|
+
tileClassName={(_item, selected) =>
|
|
159
|
+
selected ? "text-[var(--text-primary)]" : "text-[var(--text-secondary)]"
|
|
160
|
+
}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
{needle && visible.length === 0 && (
|
|
164
|
+
<p className="text-xs text-[var(--text-muted)]">{text.noResults}</p>
|
|
165
|
+
)}
|
|
166
|
+
{mixed && (
|
|
167
|
+
<span id={mixedId} hidden>
|
|
168
|
+
{text.mixed}
|
|
169
|
+
</span>
|
|
170
|
+
)}
|
|
171
|
+
{searchable && (
|
|
172
|
+
// Typing moves no focus and the grid changes silently, so the count is said.
|
|
173
|
+
// `sr-only-fixed`: this sits inside a consumer's markup and must not extend
|
|
174
|
+
// the document height (see tokens.css).
|
|
175
|
+
<span role="status" aria-live="polite" aria-atomic className="sr-only-fixed">
|
|
176
|
+
{needle ? text.resultCount(visible.length) : ""}
|
|
177
|
+
</span>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -4,7 +4,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
5
|
import { useAnnounce } from "../hooks/use-announce";
|
|
6
6
|
import { parseIsoDate, sameYmd, toLocalIso } from "../lib/dates";
|
|
7
|
-
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
7
|
+
import { useKitLabels, useKitLocale, useKitWeekStart } from "../i18n/kit-labels";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Every string this calendar can speak — the `miniCalendar` namespace of
|
|
@@ -95,6 +95,9 @@ export interface MiniCalendarProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
95
95
|
* Saturday in much of the Arab world — which is what the user's own wall calendar
|
|
96
96
|
* does. It was hard-wired to Monday before, which is wrong for most of the people
|
|
97
97
|
* who use a Sunday-first calendar and invisible to the people who wrote this one.
|
|
98
|
+
*
|
|
99
|
+
* Resolved as: this prop, else `<UiKitProvider weekStartsOn>`, else the locale's
|
|
100
|
+
* week info, else Monday.
|
|
98
101
|
*/
|
|
99
102
|
weekStartsOn?: WeekDay;
|
|
100
103
|
onSelect: (from: string, to: string) => void;
|
|
@@ -198,9 +201,10 @@ export function MiniCalendar({
|
|
|
198
201
|
}: MiniCalendarProps) {
|
|
199
202
|
const labels = useKitLabels("miniCalendar", DEFAULT_MINI_CALENDAR_LABELS, labelsProp);
|
|
200
203
|
const locale = useKitLocale(localeProp);
|
|
204
|
+
const providerWeekStart = useKitWeekStart();
|
|
201
205
|
const weekStart = useMemo(
|
|
202
|
-
() => weekStartsOn ?? localeWeekStart(locale),
|
|
203
|
-
[weekStartsOn, locale],
|
|
206
|
+
() => weekStartsOn ?? providerWeekStart ?? localeWeekStart(locale),
|
|
207
|
+
[weekStartsOn, providerWeekStart, locale],
|
|
204
208
|
);
|
|
205
209
|
// Day numbers through `Intl` too: `getDate()` is always ASCII digits, and a locale
|
|
206
210
|
// that writes its own (Arabic, Persian, Bengali …) would get a grid of foreign
|
package/src/components/modal.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
1
|
+
import { createContext, useRef, useState } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import type {
|
|
4
4
|
ComponentPropsWithoutRef,
|
|
@@ -79,6 +79,14 @@ function useDragOffset(enabled: boolean) {
|
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The panel's own ANIMATED dismissal, for chrome rendered inside it. `DialogFrame`'s X
|
|
84
|
+
* and its `actions(close)` read this, so a Cancel or an X lowers the panel exactly the
|
|
85
|
+
* way Escape does — calling `onClose` from inside unmounts with no exit, which is the
|
|
86
|
+
* documented limit of {@link useCloseTransition}. `null` outside a `Modal`.
|
|
87
|
+
*/
|
|
88
|
+
export const ModalCloseContext = createContext<(() => void) | null>(null);
|
|
89
|
+
|
|
82
90
|
/**
|
|
83
91
|
* Backdrop handlers that close only when a press starts AND ends on the
|
|
84
92
|
* backdrop itself. A plain onClick fires on mouseup, so dragging a text
|
|
@@ -279,7 +287,7 @@ export function Modal({
|
|
|
279
287
|
className,
|
|
280
288
|
)}
|
|
281
289
|
>
|
|
282
|
-
{children}
|
|
290
|
+
<ModalCloseContext.Provider value={requestClose}>{children}</ModalCloseContext.Provider>
|
|
283
291
|
</div>
|
|
284
292
|
</div>,
|
|
285
293
|
document.body,
|
|
@@ -2,7 +2,12 @@ import { useId, useMemo, type ComponentPropsWithoutRef, type ReactNode } from "r
|
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
4
|
import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ComboboxPanel,
|
|
7
|
+
useComboboxCore,
|
|
8
|
+
useComboboxFieldError,
|
|
9
|
+
type ComboOption,
|
|
10
|
+
} from "./combobox-core";
|
|
6
11
|
import {
|
|
7
12
|
DEFAULT_COMBOBOX_LABELS,
|
|
8
13
|
DEFAULT_COMMON_LABELS,
|
|
@@ -19,8 +24,8 @@ export interface MultiEntityComboboxProps<V extends string | number>
|
|
|
19
24
|
onChange: (value: V[]) => void;
|
|
20
25
|
/** Already-loaded options (client-side filtered); also resolves selected labels. */
|
|
21
26
|
options?: ComboOption<V>[];
|
|
22
|
-
/** Async option source, debounced + race-safe
|
|
23
|
-
* labels only. */
|
|
27
|
+
/** Async option source, debounced + race-safe; a rejection shows
|
|
28
|
+
* `loadErrorLabel`. When set, `options` resolves labels only. */
|
|
24
29
|
loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
|
|
25
30
|
loading?: boolean;
|
|
26
31
|
label?: ReactNode;
|
|
@@ -44,6 +49,19 @@ export interface MultiEntityComboboxProps<V extends string | number>
|
|
|
44
49
|
createLabel?: (query: string) => string;
|
|
45
50
|
/** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
|
|
46
51
|
invalid?: boolean;
|
|
52
|
+
/** What is wrong with the value, as {@link Input}'s `error`: rendered under the
|
|
53
|
+
* field, on the trigger's `aria-describedby`, and implies `invalid`. */
|
|
54
|
+
error?: ReactNode;
|
|
55
|
+
/** Narrow `options` client-side by the query. Default `true`; `false` shows them
|
|
56
|
+
* as given (a server-ranked list). */
|
|
57
|
+
filter?: boolean;
|
|
58
|
+
/** Offer nothing, and call no `loadOptions`, below this many characters. Default
|
|
59
|
+
* `0`, i.e. the list loads as the panel opens. */
|
|
60
|
+
minChars?: number;
|
|
61
|
+
/** `loadOptions` debounce. Default 150 ms. */
|
|
62
|
+
debounceMs?: number;
|
|
63
|
+
/** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */
|
|
64
|
+
loadErrorLabel?: string;
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
/**
|
|
@@ -70,10 +88,23 @@ export function MultiEntityCombobox<V extends string | number>({
|
|
|
70
88
|
createLabel,
|
|
71
89
|
className,
|
|
72
90
|
invalid,
|
|
91
|
+
error,
|
|
92
|
+
filter,
|
|
93
|
+
minChars,
|
|
94
|
+
debounceMs,
|
|
95
|
+
loadErrorLabel,
|
|
73
96
|
"aria-label": ariaLabel,
|
|
74
97
|
...rest
|
|
75
98
|
}: MultiEntityComboboxProps<V>) {
|
|
76
|
-
const core = useComboboxCore<V>({
|
|
99
|
+
const core = useComboboxCore<V>({
|
|
100
|
+
options,
|
|
101
|
+
loadOptions,
|
|
102
|
+
loading,
|
|
103
|
+
filter,
|
|
104
|
+
minChars,
|
|
105
|
+
debounceMs,
|
|
106
|
+
});
|
|
107
|
+
const field = useComboboxFieldError(error, invalid);
|
|
77
108
|
// The props are the per-instance overrides, the provider the app-wide ones; a
|
|
78
109
|
// prop left `undefined` falls through to the provider rather than masking it.
|
|
79
110
|
const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
|
|
@@ -155,7 +186,8 @@ export function MultiEntityCombobox<V extends string | number>({
|
|
|
155
186
|
(typeof label === "string" ? common.fieldValue(label, summary) : undefined)
|
|
156
187
|
}
|
|
157
188
|
disabled={disabled}
|
|
158
|
-
aria-invalid={
|
|
189
|
+
aria-invalid={field.isInvalid || undefined}
|
|
190
|
+
aria-describedby={field.describedBy}
|
|
159
191
|
onClick={() => !disabled && setOpen((o) => !o)}
|
|
160
192
|
// Down/Up opens the list from the closed trigger, per the APG. Enter and
|
|
161
193
|
// Space already do it through the button's own click.
|
|
@@ -171,7 +203,7 @@ export function MultiEntityCombobox<V extends string | number>({
|
|
|
171
203
|
"pr-9",
|
|
172
204
|
label !== undefined && FIELD_FLOATING_PAD,
|
|
173
205
|
disabled && "cursor-not-allowed opacity-50",
|
|
174
|
-
|
|
206
|
+
field.isInvalid && FIELD_INVALID,
|
|
175
207
|
)}
|
|
176
208
|
>
|
|
177
209
|
<span
|
|
@@ -212,6 +244,7 @@ export function MultiEntityCombobox<V extends string | number>({
|
|
|
212
244
|
searchPlaceholder={labels.search}
|
|
213
245
|
emptyLabel={labels.noResults}
|
|
214
246
|
closeLabel={closeLabel}
|
|
247
|
+
loadErrorLabel={loadErrorLabel}
|
|
215
248
|
isSelected={(v) => valueSet.has(v)}
|
|
216
249
|
onChoose={toggle}
|
|
217
250
|
showCreate={showCreate}
|
|
@@ -221,6 +254,7 @@ export function MultiEntityCombobox<V extends string | number>({
|
|
|
221
254
|
}}
|
|
222
255
|
createContent={labels.create(q)}
|
|
223
256
|
/>
|
|
257
|
+
{field.errorEl}
|
|
224
258
|
</div>
|
|
225
259
|
);
|
|
226
260
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useId, useState } from "react";
|
|
2
2
|
import type { ComponentProps, ReactNode } from "react";
|
|
3
3
|
import { NumberInput } from "./number-input";
|
|
4
|
-
import { formatResult } from "../lib/calc";
|
|
4
|
+
import { evaluateExpression, formatResult } from "../lib/calc";
|
|
5
5
|
import { useKitLocale } from "../i18n/kit-labels";
|
|
6
6
|
|
|
7
7
|
type NumberInputProps = ComponentProps<typeof NumberInput>;
|
|
@@ -16,6 +16,25 @@ export interface NumberFieldProps
|
|
|
16
16
|
* into `[min, max]`. `null` only when `nullable` and the field was emptied.
|
|
17
17
|
*/
|
|
18
18
|
onCommit: (value: number | null) => void;
|
|
19
|
+
/**
|
|
20
|
+
* LIVE MODE: the number the text reads as, on every keystroke (and every step
|
|
21
|
+
* key) — for a calculator that recomputes as the user types, while `onCommit`
|
|
22
|
+
* stays the one to save on.
|
|
23
|
+
*
|
|
24
|
+
* What it is NOT, until the commit: rounded to `digits` or clamped into
|
|
25
|
+
* `[min, max]` — a half-typed "1" on the way to "150" must not read as the
|
|
26
|
+
* minimum of 100. A calculation reads as its result ("12+5" → 17); an empty field
|
|
27
|
+
* as `null` (whether or not `nullable`); a draft that is no number yet ("-", "12+")
|
|
28
|
+
* fires nothing, so the last number stands. On blur/Enter it fires once more with
|
|
29
|
+
* the committed value if that differs from the last one it reported — the rounded,
|
|
30
|
+
* clamped or snapped-back number — so a live consumer always ends where `onCommit`
|
|
31
|
+
* does.
|
|
32
|
+
*
|
|
33
|
+
* Feeding it back into `value` is safe: a `value` equal to the number this field
|
|
34
|
+
* last reported does not re-render the text, so "1," stays "1," while the rest of
|
|
35
|
+
* the form already shows 1. Not called when `value` changes from outside.
|
|
36
|
+
*/
|
|
37
|
+
onValueChange?: (value: number | null) => void;
|
|
19
38
|
/** Round to this many decimals on commit, and show at most this many (trailing
|
|
20
39
|
* zeros are trimmed: `digits={2}` shows 12.5, not 12.50). Omit for no rounding. */
|
|
21
40
|
digits?: number;
|
|
@@ -59,6 +78,20 @@ function decimalMark(locale: string | undefined): "," | "." {
|
|
|
59
78
|
}
|
|
60
79
|
}
|
|
61
80
|
|
|
81
|
+
/** What the draft reads as while it is being typed — see `onValueChange`. `undefined`
|
|
82
|
+
* is "not a number yet", distinct from `null` (empty). */
|
|
83
|
+
function parseLive(text: string): number | null | undefined {
|
|
84
|
+
const t = text.trim().replace(/,/g, ".");
|
|
85
|
+
if (t === "") return null;
|
|
86
|
+
if (/^-?\d*\.?\d*$/.test(t)) {
|
|
87
|
+
// "-", "." and "-." pass the pattern and are no number yet.
|
|
88
|
+
if (!/\d/.test(t)) return undefined;
|
|
89
|
+
const n = Number(t);
|
|
90
|
+
return Number.isFinite(n) ? n : undefined;
|
|
91
|
+
}
|
|
92
|
+
return evaluateExpression(t) ?? undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
62
95
|
/** `toFixed` rounding rather than `Math.round(n * 10^d)`, which turns 1.005 into
|
|
63
96
|
* 1.00 by way of 100.49999999999999 — the same choice the lenkbank field made. */
|
|
64
97
|
function roundTo(n: number, digits: number | undefined): number {
|
|
@@ -92,6 +125,20 @@ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
|
|
|
92
125
|
* The draft follows `value` when it changes from outside (a reset, a loaded record);
|
|
93
126
|
* a change that came from here arrives back identical and is a no-op.
|
|
94
127
|
*
|
|
128
|
+
* LIVE MODE. A calculator that recomputes per keystroke used to have to pick: this
|
|
129
|
+
* field (limits, rounding, but a number only on blur) or a bare NumberInput (every
|
|
130
|
+
* keystroke, but a string and no limits). `onValueChange` is the per-keystroke
|
|
131
|
+
* channel here, next to the commit rather than instead of it: live numbers for the
|
|
132
|
+
* read-out, `onCommit` for the save. It lives on this field rather than as
|
|
133
|
+
* `min`/`max`/`digits` on NumberInput because parsing, the locale's mark and the
|
|
134
|
+
* limits are what this field already is — a second copy on the string field would be
|
|
135
|
+
* a second, subtly different NumberField.
|
|
136
|
+
*
|
|
137
|
+
* STEPS. `step` (with `min`/`max` as bounds) turns on ArrowUp/ArrowDown and PageUp/
|
|
138
|
+
* PageDown (×10) — see NumberInput's `step`. A step is a draft change like a
|
|
139
|
+
* keystroke: `onValueChange` hears it at once, `onCommit` on blur/Enter. Pick a
|
|
140
|
+
* `step` that `digits` can show (0.25 needs `digits={2}`), or the commit rounds it.
|
|
141
|
+
*
|
|
95
142
|
* LOCALE. The decimal mark is the locale's: a German user sees and types "1,5".
|
|
96
143
|
* NumberInput normalises every keystroke to a dot (so its evaluator reads one
|
|
97
144
|
* alphabet); this field maps the dot back before the draft is shown, which also
|
|
@@ -102,6 +149,7 @@ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
|
|
|
102
149
|
export function NumberField({
|
|
103
150
|
value,
|
|
104
151
|
onCommit,
|
|
152
|
+
onValueChange,
|
|
105
153
|
digits,
|
|
106
154
|
min,
|
|
107
155
|
max,
|
|
@@ -128,34 +176,59 @@ export function NumberField({
|
|
|
128
176
|
const [draft, setDraft] = useState(() => format(value));
|
|
129
177
|
// Everything the displayed text depends on, not just `value`: switching the
|
|
130
178
|
// provider's language or a field's `digits` has to re-render the draft too.
|
|
131
|
-
const
|
|
132
|
-
const [seen, setSeen] = useState(shownFor);
|
|
179
|
+
const [seen, setSeen] = useState({ value, mark, digits });
|
|
133
180
|
// The last number handed to `onCommit`, so Enter-then-blur (both commit) and a
|
|
134
181
|
// parent that does not echo the value back cannot commit the same number twice.
|
|
135
182
|
// State rather than a ref because the external-change branch below resets it
|
|
136
183
|
// during render, and a ref may not be written there.
|
|
137
184
|
const [committed, setCommitted] = useState(value);
|
|
185
|
+
// The last number handed to `onValueChange` — state for the same reason.
|
|
186
|
+
const [reported, setReported] = useState(value);
|
|
138
187
|
|
|
139
188
|
// Adjust state during render rather than in an effect: React re-renders at once
|
|
140
189
|
// with the new draft, before anything is painted, so there is no frame showing
|
|
141
190
|
// the old text.
|
|
142
|
-
if (
|
|
143
|
-
setSeen(
|
|
144
|
-
|
|
145
|
-
|
|
191
|
+
if (seen.value !== value || seen.mark !== mark || seen.digits !== digits) {
|
|
192
|
+
setSeen({ value, mark, digits });
|
|
193
|
+
// Live mode echoing back what it was just told: the draft already says it, in
|
|
194
|
+
// the typist's own spelling ("1," or "12+5"), and reformatting would fight them.
|
|
195
|
+
// Only a changed `value` qualifies — a new mark or `digits` still re-renders.
|
|
196
|
+
const echo =
|
|
197
|
+
onValueChange !== undefined && value === reported && seen.mark === mark && seen.digits === digits;
|
|
198
|
+
if (!echo) {
|
|
199
|
+
setDraft(format(value));
|
|
200
|
+
setCommitted(value);
|
|
201
|
+
setReported(value);
|
|
202
|
+
}
|
|
146
203
|
}
|
|
147
204
|
|
|
205
|
+
const report = (next: number | null) => {
|
|
206
|
+
if (next === reported) return;
|
|
207
|
+
setReported(next);
|
|
208
|
+
onValueChange?.(next);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const onText = (text: string) => {
|
|
212
|
+
setDraft(localize(text));
|
|
213
|
+
const live = parseLive(text);
|
|
214
|
+
if (live !== undefined) report(live);
|
|
215
|
+
};
|
|
216
|
+
|
|
148
217
|
const commit = (text: string) => {
|
|
149
218
|
const trimmed = text.trim();
|
|
150
219
|
let next: number | null;
|
|
220
|
+
const snapBack = () => {
|
|
221
|
+
setDraft(format(committed));
|
|
222
|
+
report(committed);
|
|
223
|
+
};
|
|
151
224
|
if (trimmed === "") {
|
|
152
|
-
if (!nullable) return
|
|
225
|
+
if (!nullable) return snapBack();
|
|
153
226
|
next = null;
|
|
154
227
|
} else {
|
|
155
228
|
// `text` is NumberInput's committed form: an evaluated calculation, dot-decimal.
|
|
156
229
|
// What survives its digits-only fallback can still be "-" or "." alone.
|
|
157
230
|
const parsed = Number(trimmed.replace(/,/g, "."));
|
|
158
|
-
if (!Number.isFinite(parsed)) return
|
|
231
|
+
if (!Number.isFinite(parsed)) return snapBack();
|
|
159
232
|
next = roundTo(parsed, digits);
|
|
160
233
|
if (min !== undefined) next = Math.max(min, next);
|
|
161
234
|
if (max !== undefined) next = Math.min(max, next);
|
|
@@ -163,6 +236,7 @@ export function NumberField({
|
|
|
163
236
|
// Always re-render the draft from the result — "007" becomes "7", a clamped 500
|
|
164
237
|
// shows the 100 it was clamped to — even when there is nothing new to commit.
|
|
165
238
|
setDraft(format(next));
|
|
239
|
+
report(next);
|
|
166
240
|
if (next !== committed) {
|
|
167
241
|
setCommitted(next);
|
|
168
242
|
onCommit(next);
|
|
@@ -205,8 +279,10 @@ export function NumberField({
|
|
|
205
279
|
id={fieldId}
|
|
206
280
|
label={labelWithUnit}
|
|
207
281
|
value={draft}
|
|
208
|
-
onChange={
|
|
282
|
+
onChange={onText}
|
|
209
283
|
onCommit={commit}
|
|
284
|
+
min={min}
|
|
285
|
+
max={max}
|
|
210
286
|
suffix={unitPlacement === "suffix" ? unit : undefined}
|
|
211
287
|
invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === "true"}
|
|
212
288
|
/>
|
|
@@ -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 (
|