@eifi1/ui-kit 0.12.0 → 0.13.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 +10 -10
- package/dist/components/amount-input.d.ts +17 -1
- package/dist/components/amount-input.js +46 -11
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/bulk-action-bar.d.ts +10 -2
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/chart.js +14 -8
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.js +20 -15
- package/dist/components/chip.js.map +1 -1
- package/dist/components/combobox-core.d.ts +3 -1
- package/dist/components/combobox-core.js +16 -5
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/copy-button.d.ts +1 -1
- package/dist/components/data-table-filter-popover.js +8 -1
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table.js +28 -20
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/entity-combobox.js +22 -17
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.d.ts +202 -13
- package/dist/components/error-boundary.js +211 -20
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/field-sync.js +90 -83
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/full-bleed-dialog.js +4 -0
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/hover-menu.js +64 -56
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/list.d.ts +14 -2
- package/dist/components/list.js +3 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/modal.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +23 -17
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +1 -1
- package/dist/components/number-input.d.ts +1 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +6 -2
- package/dist/components/numpad-sheet.js +3 -2
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-header.d.ts +35 -6
- package/dist/components/page-header.js +7 -1
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/picker-sheet.js +3 -0
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.js +3 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -1
- package/dist/components/signed-amount.d.ts +2 -1
- package/dist/components/signed-amount.js +1 -1
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/stat-tile.js +1 -1
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swipeable-row.js +4 -3
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/text-link.d.ts +73 -11
- package/dist/components/text-link.js +45 -10
- package/dist/components/text-link.js.map +1 -1
- package/dist/components/text.d.ts +26 -2
- package/dist/components/text.js +16 -2
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/toggle-group.d.ts +22 -0
- package/dist/components/toggle-group.js +8 -8
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +17 -0
- package/dist/components/toggle-legend.js +6 -2
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.d.ts +51 -1
- package/dist/components/tooltip.js +59 -35
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/components/ui.js +166 -28
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.js +4 -6
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.js +5 -3
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.js +86 -75
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +43 -3
- package/dist/feedback/feedback-thread.js +53 -26
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/feedback.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/de-informal.d.ts +1 -1
- package/dist/i18n/locales/de.d.ts +1 -1
- package/dist/i18n/locales/de.js +9 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/es.js +9 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/fr.js +9 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/hu.js +9 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/it.js +9 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/i18n/locales/zh.js +9 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/{kit-labels-v3biUF1L.d.ts → kit-labels-Mz8zZHIF.d.ts} +102 -3
- package/dist/lib/format.d.ts +23 -5
- package/dist/lib/format.js +3 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/rhf/fields.d.ts +57 -15
- package/dist/rhf/fields.js +41 -11
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf.d.ts +2 -2
- package/dist/rhf.js.map +1 -1
- package/dist/search/command-palette.js +20 -14
- package/dist/search/command-palette.js.map +1 -1
- package/dist/shell/app-shell.d.ts +1 -1
- package/dist/shell/app-shell.js +62 -54
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell/topbar-action-menu.d.ts +9 -3
- package/dist/shell/topbar-action-menu.js +2 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/tour/tour.js +10 -4
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard/use-wizard.js +4 -2
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +11 -1
- package/dist/wizard/wizard-step.js +24 -6
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +6 -6
- package/src/components/amount-input.tsx +97 -10
- package/src/components/bulk-action-bar.tsx +16 -3
- package/src/components/chart.tsx +19 -8
- package/src/components/chip.tsx +6 -0
- package/src/components/combobox-core.tsx +28 -9
- package/src/components/combobox.tsx +2 -0
- package/src/components/data-table-filter-popover.tsx +13 -5
- package/src/components/data-table.tsx +6 -0
- package/src/components/entity-combobox.tsx +7 -4
- package/src/components/error-boundary.tsx +453 -29
- package/src/components/field-sync.tsx +48 -40
- package/src/components/full-bleed-dialog.tsx +4 -0
- package/src/components/hover-menu.tsx +26 -17
- package/src/components/list.tsx +17 -3
- package/src/components/modal.tsx +4 -0
- package/src/components/multi-entity-combobox.tsx +8 -4
- package/src/components/number-input.tsx +1 -0
- package/src/components/numpad-sheet.tsx +6 -1
- package/src/components/page-header.tsx +42 -5
- package/src/components/picker-sheet.tsx +3 -0
- package/src/components/popover.tsx +3 -0
- package/src/components/signed-amount.tsx +12 -3
- package/src/components/stat-tile.tsx +3 -1
- package/src/components/swipeable-row.tsx +7 -7
- package/src/components/text-link.tsx +126 -18
- package/src/components/text.tsx +46 -2
- package/src/components/toggle-group.tsx +35 -10
- package/src/components/toggle-legend.tsx +22 -1
- package/src/components/tooltip.tsx +79 -8
- package/src/components/ui.tsx +307 -39
- package/src/feedback/feedback-attachment.tsx +10 -7
- package/src/feedback/feedback-dialog.tsx +7 -4
- package/src/feedback/feedback-inbox.tsx +7 -0
- package/src/feedback/feedback-thread.tsx +109 -33
- package/src/i18n/kit-labels.tsx +16 -0
- package/src/i18n/locales/de.ts +8 -0
- package/src/i18n/locales/es.ts +8 -0
- package/src/i18n/locales/fr.ts +8 -0
- package/src/i18n/locales/hu.ts +8 -0
- package/src/i18n/locales/it.ts +8 -0
- package/src/i18n/locales/zh.ts +8 -0
- package/src/lib/format.ts +29 -6
- package/src/rhf/fields.tsx +97 -16
- package/src/rhf.ts +1 -1
- package/src/search/command-palette.tsx +29 -19
- package/src/shell/app-shell.tsx +6 -1
- package/src/shell/topbar-action-menu.tsx +11 -4
- package/src/tour/tour.tsx +16 -5
- package/src/wizard/use-wizard.ts +8 -4
- package/src/wizard/wizard-step.tsx +38 -6
|
@@ -146,35 +146,40 @@ export function useFieldSync<T>({
|
|
|
146
146
|
};
|
|
147
147
|
}, []);
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
149
|
+
// A named inner function so the settle path can start the next round by calling it
|
|
150
|
+
// again; a `useCallback` cannot refer to its own binding while it is being built.
|
|
151
|
+
const flush = useCallback(() => {
|
|
152
|
+
const run = async (): Promise<void> => {
|
|
153
|
+
if (inFlightRef.current) return; // the settle path below picks the new draft up
|
|
154
|
+
const next = draftRef.current;
|
|
155
|
+
if (equalsRef.current(next, savedRef.current)) return;
|
|
156
|
+
|
|
157
|
+
inFlightRef.current = true;
|
|
158
|
+
setState("pending");
|
|
159
|
+
setError(null);
|
|
160
|
+
try {
|
|
161
|
+
await onSaveRef.current(next);
|
|
162
|
+
savedRef.current = next;
|
|
163
|
+
inFlightRef.current = false;
|
|
164
|
+
if (!mountedRef.current) return;
|
|
165
|
+
// The draft may have moved while this was in the air. Compare against what is
|
|
166
|
+
// on screen NOW, not against what was sent.
|
|
167
|
+
if (equalsRef.current(draftRef.current, next)) {
|
|
168
|
+
setState("synced");
|
|
169
|
+
} else {
|
|
170
|
+
setState("edited");
|
|
171
|
+
void run();
|
|
172
|
+
}
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
inFlightRef.current = false;
|
|
175
|
+
const err = toError(cause);
|
|
176
|
+
onErrorRef.current?.(err);
|
|
177
|
+
if (!mountedRef.current) return;
|
|
178
|
+
setState("error");
|
|
179
|
+
setError(err);
|
|
169
180
|
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const err = toError(cause);
|
|
173
|
-
onErrorRef.current?.(err);
|
|
174
|
-
if (!mountedRef.current) return;
|
|
175
|
-
setState("error");
|
|
176
|
-
setError(err);
|
|
177
|
-
}
|
|
181
|
+
};
|
|
182
|
+
return run();
|
|
178
183
|
}, []);
|
|
179
184
|
|
|
180
185
|
const setValue = useCallback(
|
|
@@ -396,23 +401,26 @@ export function FieldSyncRow<T>({
|
|
|
396
401
|
const retry = onRetry ?? sync.retry;
|
|
397
402
|
|
|
398
403
|
// The saved confirmation: on only for the moment after a save lands. Keyed on the
|
|
399
|
-
// TRANSITION pending → synced, so a field that starts synced shows nothing.
|
|
400
|
-
|
|
404
|
+
// TRANSITION pending → synced, so a field that starts synced shows nothing. The
|
|
405
|
+
// transition is seen while rendering, against the previous state; the effect only
|
|
406
|
+
// owns the timer that turns it off again.
|
|
407
|
+
const [previous, setPrevious] = useState(state);
|
|
401
408
|
const [justSaved, setJustSaved] = useState(false);
|
|
409
|
+
if (state !== previous) {
|
|
410
|
+
setPrevious(state);
|
|
411
|
+
setJustSaved(state === "synced" && previous === "pending");
|
|
412
|
+
}
|
|
402
413
|
useEffect(() => {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
setJustSaved(true);
|
|
408
|
-
const t = setTimeout(() => setJustSaved(false), savedMs);
|
|
409
|
-
return () => clearTimeout(t);
|
|
410
|
-
}
|
|
411
|
-
if (state !== "synced") setJustSaved(false);
|
|
412
|
-
}, [state, savedMs]);
|
|
414
|
+
if (!justSaved) return;
|
|
415
|
+
const t = setTimeout(() => setJustSaved(false), savedMs);
|
|
416
|
+
return () => clearTimeout(t);
|
|
417
|
+
}, [justSaved, savedMs]);
|
|
413
418
|
const shown: FieldSyncState | null = state === "synced" ? (justSaved ? "synced" : null) : state;
|
|
414
419
|
|
|
415
420
|
return (
|
|
421
|
+
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- not an interaction:
|
|
422
|
+
`onBlur` listens for focus leaving the row's own controls (which are the
|
|
423
|
+
interactive elements) to save; the wrapper itself is never focused. */
|
|
416
424
|
<div
|
|
417
425
|
data-field-sync={state}
|
|
418
426
|
className={cn(
|
|
@@ -169,6 +169,10 @@ export function FullBleedDialog({
|
|
|
169
169
|
if (!open || typeof document === "undefined") return null;
|
|
170
170
|
|
|
171
171
|
return createPortal(
|
|
172
|
+
/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the dialog
|
|
173
|
+
container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for whatever
|
|
174
|
+
inside it holds focus, and the mouse handlers are the backdrop press (Escape and the
|
|
175
|
+
close button are its keyboard path). */
|
|
172
176
|
<div
|
|
173
177
|
// `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):
|
|
174
178
|
// the backdrop fades, the panel rises from the bottom edge it is anchored to.
|
|
@@ -103,22 +103,24 @@ export function HoverMenu({
|
|
|
103
103
|
// close what was just opened (feedback #19). Toggling closed on click stays
|
|
104
104
|
// for keyboard/touch activation, which reaches "open" without going through
|
|
105
105
|
// the hover path.
|
|
106
|
-
|
|
106
|
+
//
|
|
107
|
+
// State rather than a ref: `close` is handed to the caller's render prop, and a
|
|
108
|
+
// function that writes a ref must not be reachable from render.
|
|
109
|
+
const [openedByHover, setOpenedByHover] = useState(false);
|
|
107
110
|
|
|
108
111
|
const close = useCallback(() => {
|
|
109
|
-
|
|
112
|
+
setOpenedByHover(false);
|
|
110
113
|
setPhase("closed");
|
|
111
114
|
}, []);
|
|
112
115
|
|
|
113
|
-
const toggle = useCallback(
|
|
114
|
-
()
|
|
115
|
-
setPhase(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
);
|
|
116
|
+
const toggle = useCallback(() => {
|
|
117
|
+
if (open) {
|
|
118
|
+
if (!openedByHover) setPhase("closed");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setOpenedByHover(false);
|
|
122
|
+
setPhase("open");
|
|
123
|
+
}, [open, openedByHover]);
|
|
122
124
|
|
|
123
125
|
const handleMouseEnter = useCallback(
|
|
124
126
|
() => setPhase((p) => (p === "closing" ? "open" : p === "closed" ? "opening" : p)),
|
|
@@ -134,7 +136,7 @@ export function HoverMenu({
|
|
|
134
136
|
useEffect(() => {
|
|
135
137
|
if (phase === "opening") {
|
|
136
138
|
const id = setTimeout(() => {
|
|
137
|
-
|
|
139
|
+
setOpenedByHover(true);
|
|
138
140
|
setPhase("open");
|
|
139
141
|
}, OPEN_DELAY_MS);
|
|
140
142
|
return () => clearTimeout(id);
|
|
@@ -217,7 +219,7 @@ export function HoverMenu({
|
|
|
217
219
|
(edge === "last" ? list[list.length - 1] : list[0])?.focus();
|
|
218
220
|
} else {
|
|
219
221
|
focusOnOpenRef.current = edge;
|
|
220
|
-
|
|
222
|
+
setOpenedByHover(false);
|
|
221
223
|
setPhase("open");
|
|
222
224
|
}
|
|
223
225
|
}
|
|
@@ -247,11 +249,14 @@ export function HoverMenu({
|
|
|
247
249
|
// subtracted first so the measurement is against the natural position.
|
|
248
250
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
249
251
|
const [shiftX, setShiftX] = useState(0);
|
|
252
|
+
// Back to 0 on close, adjusted while rendering against the previous `open`.
|
|
253
|
+
const [wasOpen, setWasOpen] = useState(open);
|
|
254
|
+
if (open !== wasOpen) {
|
|
255
|
+
setWasOpen(open);
|
|
256
|
+
if (!open) setShiftX(0);
|
|
257
|
+
}
|
|
250
258
|
useLayoutEffect(() => {
|
|
251
|
-
if (!open)
|
|
252
|
-
setShiftX(0);
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
259
|
+
if (!open) return;
|
|
255
260
|
const el = panelRef.current;
|
|
256
261
|
if (!el) return;
|
|
257
262
|
const rect = el.getBoundingClientRect();
|
|
@@ -267,6 +272,10 @@ export function HoverMenu({
|
|
|
267
272
|
}, [open]);
|
|
268
273
|
|
|
269
274
|
return (
|
|
275
|
+
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused
|
|
276
|
+
itself: the mouse handlers are the hover-to-open convenience, and `onKeyDown`
|
|
277
|
+
catches keys bubbling from the caller's trigger button and the menuitems, which
|
|
278
|
+
are the keyboard path (Enter/Space/arrows open, arrows move, Escape closes). */
|
|
270
279
|
<div
|
|
271
280
|
// `...rest` first: the hover handlers below are the whole component, and a caller
|
|
272
281
|
// passing one of its own must not silently replace them.
|
package/src/components/list.tsx
CHANGED
|
@@ -106,8 +106,20 @@ const TARGET_RING =
|
|
|
106
106
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
|
|
107
107
|
|
|
108
108
|
interface ListItemBaseProps {
|
|
109
|
-
/** The row's name: one line, truncated. */
|
|
109
|
+
/** The row's name: one line, truncated — unless {@link titleLines} says otherwise. */
|
|
110
110
|
title: ReactNode;
|
|
111
|
+
/**
|
|
112
|
+
* How many lines the title may take. `1` (default) truncates with an ellipsis, which
|
|
113
|
+
* is right for a name the app chose — a unit number, a user, a file — where the
|
|
114
|
+
* cut-off end is still recognisable and every row keeps one height.
|
|
115
|
+
*
|
|
116
|
+
* `2` clamps at two lines; `"all"` shows every line, breaking inside a long word
|
|
117
|
+
* rather than overflowing. For rows whose only name is free text a person typed
|
|
118
|
+
* (kastlan 44): a defect or a note titled by its description, where the first 30
|
|
119
|
+
* characters on a phone say nothing and there is no other field to tell two rows
|
|
120
|
+
* apart. Pair it with `align="start"` so the icon stays by the first line.
|
|
121
|
+
*/
|
|
122
|
+
titleLines?: 1 | 2 | "all";
|
|
111
123
|
/** The line under it: one line, truncated — or two, with `subtitleLines={2}`. */
|
|
112
124
|
subtitle?: ReactNode;
|
|
113
125
|
/** `2` for a line that is a body rather than a label (keksdose's notification text). */
|
|
@@ -256,7 +268,7 @@ export type ListItemProps = ListItemBaseProps &
|
|
|
256
268
|
|
|
257
269
|
/**
|
|
258
270
|
* One row of a {@link List}: leading icon or avatar, title and subtitle (both
|
|
259
|
-
* truncating), a trailing slot, and — beside the row, not inside it — its actions.
|
|
271
|
+
* truncating by default), a trailing slot, and — beside the row, not inside it — its actions.
|
|
260
272
|
*
|
|
261
273
|
* All three apps draw this row by hand, a dozen times: kastlan's units
|
|
262
274
|
* (building-detail-page.tsx:402), open tickets (group-overview-page.tsx:160), activity
|
|
@@ -269,6 +281,7 @@ export type ListItemProps = ListItemBaseProps &
|
|
|
269
281
|
export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(
|
|
270
282
|
{
|
|
271
283
|
title,
|
|
284
|
+
titleLines = 1,
|
|
272
285
|
subtitle,
|
|
273
286
|
subtitleLines = 1,
|
|
274
287
|
meta,
|
|
@@ -329,7 +342,8 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
329
342
|
// Names the body's group; harmless without one.
|
|
330
343
|
id={hasBody ? titleId : undefined}
|
|
331
344
|
className={cn(
|
|
332
|
-
"block
|
|
345
|
+
"block text-sm text-[var(--text-primary)]",
|
|
346
|
+
titleLines === 1 ? "truncate" : titleLines === 2 ? "line-clamp-2 break-words" : "break-words",
|
|
333
347
|
unread ? "font-semibold" : "font-medium",
|
|
334
348
|
)}
|
|
335
349
|
>
|
package/src/components/modal.tsx
CHANGED
|
@@ -370,6 +370,10 @@ function ModalPanel({
|
|
|
370
370
|
)}
|
|
371
371
|
{...backdropClose}
|
|
372
372
|
>
|
|
373
|
+
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- always
|
|
374
|
+
role="dialog" or "alertdialog" (the ternary hides that from the rule): `onKeyDown`
|
|
375
|
+
is Escape-to-close per the APG dialog pattern, and the drag handlers are
|
|
376
|
+
swipe-to-dismiss, whose keyboard path is that same Escape. */}
|
|
373
377
|
<div
|
|
374
378
|
// `...rest` FIRST, everything the dialog needs to be a dialog after it. A
|
|
375
379
|
// wrapper that spreads its own props through to this one cannot then take away
|
|
@@ -127,7 +127,7 @@ export function MultiEntityCombobox<V extends string | number>({
|
|
|
127
127
|
create: createLabel,
|
|
128
128
|
});
|
|
129
129
|
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
130
|
-
const { open, results, resolve, setOpen } = core;
|
|
130
|
+
const { open, results, resolve, setOpen, query, triggerRef } = core;
|
|
131
131
|
// One id per instance, generated here rather than in the core: `aria-controls` on
|
|
132
132
|
// the trigger has to name the list while the list is still closed, so the id
|
|
133
133
|
// belongs to whoever renders both ends of it.
|
|
@@ -154,13 +154,13 @@ export function MultiEntityCombobox<V extends string | number>({
|
|
|
154
154
|
: labels.selectedCount(value.length);
|
|
155
155
|
})();
|
|
156
156
|
|
|
157
|
-
const q =
|
|
157
|
+
const q = query.trim();
|
|
158
158
|
const showCreate =
|
|
159
159
|
Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
160
160
|
const showClear = Boolean(clearable && value.length > 0 && !disabled);
|
|
161
161
|
|
|
162
162
|
const toggle = (o: ComboOption<V>) => {
|
|
163
|
-
core.
|
|
163
|
+
core.rememberOption(o);
|
|
164
164
|
if (valueSet.has(o.value)) onChange(value.filter((v) => v !== o.value));
|
|
165
165
|
else onChange([...value, o.value]);
|
|
166
166
|
// Stay open — multi-select keeps adding.
|
|
@@ -174,7 +174,7 @@ export function MultiEntityCombobox<V extends string | number>({
|
|
|
174
174
|
<div {...rest} className={cn("relative", className)}>
|
|
175
175
|
{label !== undefined && <FieldLabel>{label}</FieldLabel>}
|
|
176
176
|
<button
|
|
177
|
-
ref={
|
|
177
|
+
ref={triggerRef}
|
|
178
178
|
id={id}
|
|
179
179
|
type="button"
|
|
180
180
|
// A combobox, not a button. The distinction is not pedantry: this control
|
|
@@ -234,6 +234,10 @@ export function MultiEntityCombobox<V extends string | number>({
|
|
|
234
234
|
{summary}
|
|
235
235
|
</span>
|
|
236
236
|
{showClear ? (
|
|
237
|
+
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside
|
|
238
|
+
the trigger <button>, so it never holds focus and a key handler here could
|
|
239
|
+
never fire. A pointer shortcut; the keyboard path is unticking the options
|
|
240
|
+
in the open list. */
|
|
237
241
|
<span
|
|
238
242
|
role="button"
|
|
239
243
|
tabIndex={-1}
|
|
@@ -232,6 +232,7 @@ export function NumberInput({
|
|
|
232
232
|
// keeps focus/caret); desktop keeps the native decimal keypad.
|
|
233
233
|
inputMode={isMobile ? "none" : "decimal"}
|
|
234
234
|
autoComplete="off"
|
|
235
|
+
// eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
|
|
235
236
|
autoFocus={autoFocus}
|
|
236
237
|
disabled={disabled}
|
|
237
238
|
// Display mode has no floating label to feed the blank-placeholder trick,
|
|
@@ -99,11 +99,16 @@ export function NumberPadSheet({
|
|
|
99
99
|
onDone,
|
|
100
100
|
label,
|
|
101
101
|
labels,
|
|
102
|
+
decimalMark = ".",
|
|
102
103
|
}: {
|
|
103
104
|
value: string;
|
|
104
105
|
/** Fired with the raw (sanitised) field text on every key — same contract as
|
|
105
106
|
* the host field's own `onChange`. */
|
|
106
107
|
onChange: (value: string) => void;
|
|
108
|
+
/** The glyph on the decimal key — "," where the host shows a comma (AmountInput
|
|
109
|
+
* in fr-CH, kastlan 40). The key still inserts a dot; the host's text is dot-form
|
|
110
|
+
* underneath and `sanitizeLive` reads either. */
|
|
111
|
+
decimalMark?: "." | ",";
|
|
107
112
|
/** Fired on "Done": the host blurs the input, which commits (evaluates) and
|
|
108
113
|
* unmounts the sheet via its existing blur handler. */
|
|
109
114
|
onDone: () => void;
|
|
@@ -231,7 +236,7 @@ export function NumberPadSheet({
|
|
|
231
236
|
onClick={() => insert(key.ins)}
|
|
232
237
|
className={cn(PAD_BTN, key.accent ? PAD_ACCENT : PAD_DIGIT)}
|
|
233
238
|
>
|
|
234
|
-
{key.label}
|
|
239
|
+
{key.kind === "ins" && key.name === "decimal" ? decimalMark : key.label}
|
|
235
240
|
</button>
|
|
236
241
|
),
|
|
237
242
|
)}
|
|
@@ -2,21 +2,44 @@ import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
|
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
3
|
import { SECTION_LABEL_CLASS } from "./text";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a
|
|
7
|
+
* phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx).
|
|
8
|
+
*
|
|
9
|
+
* `compact` (0.13) is keksdose's page title (keksdose F4): `text-xl font-semibold` at
|
|
10
|
+
* EVERY width — budgets, rules, accounts, transactions and budget pages all write
|
|
11
|
+
* exactly that `<h1>` by hand. It is `md`'s phone size without `md`'s step up to
|
|
12
|
+
* `text-2xl` on a wider screen, and without its `tracking-tight`, which keksdose's
|
|
13
|
+
* titles never had. A word and not a letter for the reason `ProgressBar`'s `slim` is
|
|
14
|
+
* one: the letters are taken in order, and there is none between `sm` and `md` — an
|
|
15
|
+
* `lg` or `xl` at `text-xl` would read as LARGER than `md`, which from `sm` up it is
|
|
16
|
+
* not.
|
|
17
|
+
*/
|
|
18
|
+
export type PageHeaderSize = "sm" | "compact" | "md";
|
|
8
19
|
|
|
9
20
|
const TITLE: Record<PageHeaderSize, string> = {
|
|
10
21
|
sm: "text-lg font-semibold",
|
|
22
|
+
compact: "text-xl font-semibold",
|
|
11
23
|
md: "text-xl font-semibold tracking-tight sm:text-2xl",
|
|
12
24
|
};
|
|
13
25
|
|
|
26
|
+
/**
|
|
27
|
+
* How the title block and the actions sit below the `sm` breakpoint. From `sm` up both
|
|
28
|
+
* layouts are one row: title block at the start, actions at the end.
|
|
29
|
+
*/
|
|
30
|
+
export type PageHeaderMobileLayout = "stacked" | "inline";
|
|
31
|
+
|
|
32
|
+
const ROW: Record<PageHeaderMobileLayout, string> = {
|
|
33
|
+
stacked: "flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4",
|
|
34
|
+
inline: "flex-row items-center justify-between gap-2 sm:gap-4",
|
|
35
|
+
};
|
|
36
|
+
|
|
14
37
|
export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
|
|
15
38
|
title: ReactNode;
|
|
16
39
|
/** A sentence under the title. */
|
|
17
40
|
description?: ReactNode;
|
|
18
41
|
/** Buttons for the page — "New", "Export". At the title's end from `sm` up; under it,
|
|
19
|
-
* wrapping, on a phone
|
|
42
|
+
* wrapping, on a phone — unless {@link mobileLayout} is `inline`. */
|
|
20
43
|
actions?: ReactNode;
|
|
21
44
|
/** A `Breadcrumbs` trail, drawn above everything else. */
|
|
22
45
|
breadcrumbs?: ReactNode;
|
|
@@ -27,6 +50,19 @@ export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
27
50
|
* header inside a page that already has its `h1` (a tab's own header). */
|
|
28
51
|
as?: "h1" | "h2" | "h3";
|
|
29
52
|
size?: PageHeaderSize;
|
|
53
|
+
/**
|
|
54
|
+
* `stacked` (default): on a phone the actions drop under the title and wrap, so a
|
|
55
|
+
* row of three buttons never pushes the title off the screen.
|
|
56
|
+
*
|
|
57
|
+
* `inline`: the actions stay beside the title on a phone too, vertically centred on
|
|
58
|
+
* it — keksdose's headers (transactions-page.tsx:515, `flex items-center
|
|
59
|
+
* justify-between gap-2`), which carry one or two icon-sized controls and were tuned
|
|
60
|
+
* for the phone in live #263. Adopting `PageHeader` must not undo that tuning, so the
|
|
61
|
+
* row stays a row (keksdose F4). The actions do NOT wrap or shrink here: `inline` is
|
|
62
|
+
* for a header whose actions are known to fit beside a phone-width title (an icon
|
|
63
|
+
* button, a month picker); a long title wraps to make room for them instead.
|
|
64
|
+
*/
|
|
65
|
+
mobileLayout?: PageHeaderMobileLayout;
|
|
30
66
|
}
|
|
31
67
|
|
|
32
68
|
/**
|
|
@@ -48,6 +84,7 @@ export function PageHeader({
|
|
|
48
84
|
eyebrow,
|
|
49
85
|
as = "h1",
|
|
50
86
|
size = "md",
|
|
87
|
+
mobileLayout = "stacked",
|
|
51
88
|
className,
|
|
52
89
|
...rest
|
|
53
90
|
}: PageHeaderProps) {
|
|
@@ -55,7 +92,7 @@ export function PageHeader({
|
|
|
55
92
|
return (
|
|
56
93
|
<div {...rest} className={cn("flex min-w-0 flex-col gap-2", className)}>
|
|
57
94
|
{breadcrumbs}
|
|
58
|
-
<div className="flex min-w-0
|
|
95
|
+
<div className={cn("flex min-w-0", ROW[mobileLayout])}>
|
|
59
96
|
<div className="min-w-0 flex-1">
|
|
60
97
|
{eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, "mb-1")}>{eyebrow}</p>}
|
|
61
98
|
<Heading className={cn("break-words text-[var(--text-primary)]", TITLE[size])}>{title}</Heading>
|
|
@@ -154,6 +154,9 @@ export function PickerSheet({
|
|
|
154
154
|
|
|
155
155
|
if (!open || typeof document === "undefined") return null;
|
|
156
156
|
return createPortal(
|
|
157
|
+
/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the dialog
|
|
158
|
+
container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for whatever
|
|
159
|
+
inside it holds focus, and `onMouseDown` only stops propagation (live #328). */
|
|
157
160
|
<div
|
|
158
161
|
// Spread FIRST, and every attribute below it deliberately wins: `style`,
|
|
159
162
|
// `onMouseDown` and `onKeyDown` here are not styling choices, they are live
|
|
@@ -169,6 +169,9 @@ export function Popover({
|
|
|
169
169
|
{trigger({ open, toggle, ref: triggerRef })}
|
|
170
170
|
{open && pos &&
|
|
171
171
|
createPortal(
|
|
172
|
+
/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the
|
|
173
|
+
dialog container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for
|
|
174
|
+
whatever inside it holds focus. */
|
|
172
175
|
<div
|
|
173
176
|
// `...rest` first: the role, the id the trigger's `aria-controls` points at,
|
|
174
177
|
// the `tabIndex` the trap needs and the measured position are what make this
|
|
@@ -223,7 +223,8 @@ export interface DeltaProps extends SignedFormatProps, Omit<ComponentPropsWithou
|
|
|
223
223
|
goodDirection?: GoodDirection;
|
|
224
224
|
/** What it is measured against, after it: "vs last month". */
|
|
225
225
|
label?: ReactNode;
|
|
226
|
-
/** Hide the arrow (shown by default, as on a StatTile).
|
|
226
|
+
/** Hide the arrow (shown by default, as on a StatTile). At zero the arrow is a flat
|
|
227
|
+
* dash BESIDE the figure ("– 0.00 %"), never in place of it. */
|
|
227
228
|
arrow?: boolean;
|
|
228
229
|
sensitive?: boolean;
|
|
229
230
|
/** The `statTile` namespace's direction words — a delta says the same as a tile's. */
|
|
@@ -276,8 +277,16 @@ export function Delta({
|
|
|
276
277
|
>
|
|
277
278
|
<span data-private={sensitive ? "" : undefined} className={cn("inline-flex items-center gap-0.5 font-medium", tone)}>
|
|
278
279
|
{arrow && <Icon className="size-[1em] shrink-0" aria-hidden />}
|
|
279
|
-
{/*
|
|
280
|
-
|
|
280
|
+
{/* The figure at zero too, beside the flat dash (kastlan feedback #48). Before
|
|
281
|
+
this a flat delta with an arrow drew the dash ALONE, as a StatTile does — so
|
|
282
|
+
a rent-change column that printed "0.00 %" for an unchanged lease showed a
|
|
283
|
+
bare "–" in its place, and a bare dash is what a table prints for a MISSING
|
|
284
|
+
value: "unchanged" and "not known" looked the same. The Minus icon is the
|
|
285
|
+
neutral indicator, the muted tone the neutral colour, and the figure says
|
|
286
|
+
how much (none) in the column's own digits. A value that is missing never
|
|
287
|
+
reaches here — a non-finite one renders nothing, above. Visible change for
|
|
288
|
+
every `<Delta value={0}>` with the default arrow: "–" became "– 0 %". */}
|
|
289
|
+
<span aria-hidden>{amount}</span>
|
|
281
290
|
<span className="sr-only">{spoken}</span>
|
|
282
291
|
</span>
|
|
283
292
|
{label != null && <span className="text-[var(--text-muted)]">{label}</span>}
|
|
@@ -379,7 +379,9 @@ export function StatTile({
|
|
|
379
379
|
<div className="relative mt-1 flex flex-wrap items-center gap-x-1.5 text-xs tabular-nums">
|
|
380
380
|
<span id={deltaId} data-private={priv} className={cn("inline-flex items-center gap-0.5 font-medium", deltaTone)}>
|
|
381
381
|
<Icon className="size-3 shrink-0" aria-hidden />
|
|
382
|
-
|
|
382
|
+
{/* The figure at zero too, like Delta (kastlan 48): "0 %" says the change was
|
|
383
|
+
measured; a bare dash reads like a missing value. */}
|
|
384
|
+
<span aria-hidden>{amount}</span>
|
|
383
385
|
<span className="sr-only">{spoken}</span>
|
|
384
386
|
</span>
|
|
385
387
|
{d.label != null && <span className="text-[var(--text-muted)]">{d.label}</span>}
|
|
@@ -164,13 +164,13 @@ export function SwipeableRow({
|
|
|
164
164
|
// painted the RIGHT action's colour into the gap. With no left action at all that
|
|
165
165
|
// colour was the only thing the gesture ever showed.
|
|
166
166
|
//
|
|
167
|
-
//
|
|
168
|
-
// later (visibly, at 150ms)
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
const draggingLeft =
|
|
167
|
+
// Adjusted during render rather than in an effect: an effect would repaint a frame
|
|
168
|
+
// later (visibly, at 150ms). State set while rendering re-renders this component
|
|
169
|
+
// before anything is committed, so the released frame already has the right side.
|
|
170
|
+
const [lastSide, setLastSide] = useState<"left" | "right">("right");
|
|
171
|
+
const side = dx === 0 ? null : dx < 0 ? "left" : "right";
|
|
172
|
+
if (side && side !== lastSide) setLastSide(side);
|
|
173
|
+
const draggingLeft = side ? side === "left" : lastSide === "left";
|
|
174
174
|
const shown = draggingLeft
|
|
175
175
|
? leftActions[Math.max(0, swipe.armedLeftIndex)]
|
|
176
176
|
: rightActions[Math.max(0, swipe.armedRightIndex)];
|