@eifi1/ui-kit 0.16.1 → 0.18.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 +34 -9
- package/dist/chart.d.ts +10 -1
- package/dist/components/account-chips.d.ts +153 -0
- package/dist/components/account-chips.js +135 -0
- package/dist/components/account-chips.js.map +1 -0
- package/dist/components/account-settings-labels.d.ts +10 -2
- package/dist/components/account-settings-labels.js +3 -0
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/amount-input.d.ts +18 -1
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/button-group.d.ts +10 -1
- package/dist/components/calculator.d.ts +10 -1
- package/dist/components/chip.d.ts +25 -1
- package/dist/components/chip.js +143 -6
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +42 -1
- package/dist/components/confirm-dialog.js +36 -5
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +10 -1
- package/dist/components/danger-confirm.d.ts +55 -4
- package/dist/components/danger-confirm.js +67 -30
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +93 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +10 -1
- package/dist/components/file-button.d.ts +10 -1
- package/dist/components/file-dropzone.d.ts +10 -1
- package/dist/components/form-actions.d.ts +10 -1
- package/dist/components/form-actions.js +35 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/line-items.d.ts +30 -6
- package/dist/components/line-items.js +77 -33
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +13 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/money-field.d.ts +10 -1
- package/dist/components/number-field.d.ts +11 -2
- package/dist/components/number-field.js +23 -22
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +10 -1
- package/dist/components/numpad-sheet.d.ts +10 -1
- package/dist/components/passkeys-setting.d.ts +27 -1
- package/dist/components/passkeys-setting.js +31 -5
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +25 -1
- package/dist/components/progress-bar.js +21 -4
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +90 -0
- package/dist/components/reauth-dialog.js +158 -0
- package/dist/components/reauth-dialog.js.map +1 -0
- package/dist/components/series-chart.d.ts +10 -1
- package/dist/components/server-wake.d.ts +58 -0
- package/dist/components/server-wake.js +58 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +10 -1
- package/dist/components/share-card.d.ts +61 -0
- package/dist/components/share-card.js +350 -0
- package/dist/components/share-card.js.map +1 -0
- package/dist/components/signed-amount.d.ts +12 -3
- package/dist/components/signed-amount.js +6 -4
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/status-dot.d.ts +2 -0
- package/dist/components/text-link.d.ts +10 -1
- package/dist/components/time-input.d.ts +10 -1
- package/dist/components/toast.d.ts +10 -1
- package/dist/components/toast.js +42 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +5 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/ui.d.ts +10 -1
- package/dist/components/ui.js +67 -30
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/user-avatar.d.ts +2 -0
- package/dist/components/write-lock.d.ts +68 -0
- package/dist/components/write-lock.js +33 -0
- package/dist/components/write-lock.js.map +1 -0
- package/dist/{data-table-CfVXtCeG.d.ts → data-table-3mi2R3so.d.ts} +59 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +10 -1
- package/dist/hooks/use-overlay-history.js +16 -0
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +10 -1
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +78 -0
- package/dist/i18n/{locales/de.js → german.js} +59 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +10 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH.d.ts +23 -8
- package/dist/i18n/locales/de-CH.js +7 -3
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/{de.d.ts → en.d.ts} +24 -10
- package/dist/i18n/locales/en.js +95 -0
- package/dist/i18n/locales/en.js.map +1 -0
- package/dist/i18n/locales/es.d.ts +10 -1
- package/dist/i18n/locales/es.js +51 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +10 -1
- package/dist/i18n/locales/fr.js +51 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +10 -1
- package/dist/i18n/locales/hu.js +54 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +10 -1
- package/dist/i18n/locales/it.js +51 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +10 -1
- package/dist/i18n/locales/zh.js +50 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.js +33 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-CPbsjlXr.d.ts} +282 -10
- package/dist/lib/server-wake.d.ts +130 -0
- package/dist/lib/server-wake.js +121 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/rhf/fields.d.ts +10 -1
- package/dist/rhf/form.d.ts +10 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +10 -1
- package/dist/search/global-search.d.ts +24 -4
- package/dist/search/global-search.js +31 -3
- package/dist/search/global-search.js.map +1 -1
- package/dist/search.d.ts +1 -1
- package/dist/shell/app-shell.d.ts +10 -1
- package/dist/shell/top-bar-brand.d.ts +10 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +10 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard.d.ts +10 -1
- package/package.json +1 -1
- package/src/components/account-chips.tsx +302 -0
- package/src/components/account-settings-labels.ts +16 -2
- package/src/components/amount-input.tsx +8 -0
- package/src/components/chip.tsx +239 -6
- package/src/components/confirm-dialog.tsx +82 -7
- package/src/components/danger-confirm.tsx +118 -33
- package/src/components/data-table.tsx +185 -3
- package/src/components/form-actions.tsx +93 -4
- package/src/components/line-items.tsx +116 -40
- package/src/components/list.tsx +13 -1
- package/src/components/number-field.tsx +32 -26
- package/src/components/passkeys-setting.tsx +68 -7
- package/src/components/progress-bar.tsx +40 -4
- package/src/components/reauth-dialog.tsx +273 -0
- package/src/components/server-wake.tsx +124 -0
- package/src/components/share-card.tsx +615 -0
- package/src/components/signed-amount.tsx +16 -5
- package/src/components/toast.tsx +72 -2
- package/src/components/toggle-group.tsx +5 -2
- package/src/components/ui.tsx +145 -34
- package/src/components/write-lock.tsx +96 -0
- package/src/hooks/use-overlay-history.ts +51 -0
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/{locales/de.ts → german.ts} +68 -13
- package/src/i18n/kit-labels.tsx +22 -2
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/en.ts +116 -0
- package/src/i18n/locales/es.ts +51 -0
- package/src/i18n/locales/fr.ts +52 -0
- package/src/i18n/locales/hu.ts +55 -0
- package/src/i18n/locales/it.ts +51 -0
- package/src/i18n/locales/zh.ts +50 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +23 -1
- package/src/lib/server-wake.ts +270 -0
- package/src/rhf/line-items.tsx +2 -1
- package/src/search/global-search.tsx +59 -6
- package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
- package/dist/i18n/locales/de-CH-informal.js +0 -8
- package/dist/i18n/locales/de-CH-informal.js.map +0 -1
- package/dist/i18n/locales/de-informal.d.ts +0 -82
- package/dist/i18n/locales/de-informal.js +0 -37
- package/dist/i18n/locales/de-informal.js.map +0 -1
- package/dist/i18n/locales/de.js.map +0 -1
- package/src/i18n/locales/de-CH-informal.ts +0 -10
- package/src/i18n/locales/de-informal.ts +0 -61
package/src/components/toast.tsx
CHANGED
|
@@ -135,9 +135,33 @@ type Kind = "default" | "success" | "error" | "warning" | "info" | "loading";
|
|
|
135
135
|
|
|
136
136
|
const privately = (node: ReactNode) => <span data-private="">{node}</span>;
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The toast's id, written onto its `<li>` as a class — the one per-toast attribute
|
|
140
|
+
* sonner passes through untouched besides `testId`, which is the app's (sonner keeps the
|
|
141
|
+
* id in React state only). `<Toaster dismissOnMiddleClick>` reads it back to dismiss
|
|
142
|
+
* the toast under the pointer, close button or not. The type is kept (`s`/`n`) because
|
|
143
|
+
* sonner matches ids with `===`, and the value is URI-encoded so any string id is one
|
|
144
|
+
* whitespace-free class token.
|
|
145
|
+
*/
|
|
146
|
+
const ID_CLASS = "kit-toast-id-";
|
|
147
|
+
const idClass = (id: ToastId) =>
|
|
148
|
+
`${ID_CLASS}${typeof id === "number" ? "n" : "s"}-${encodeURIComponent(String(id))}`;
|
|
149
|
+
|
|
150
|
+
/** The id {@link idClass} stamped on a toast element, or null for a toast the kit did
|
|
151
|
+
* not make (an app still calling sonner directly). */
|
|
152
|
+
function readToastId(el: Element): ToastId | null {
|
|
153
|
+
for (const token of el.classList) {
|
|
154
|
+
if (!token.startsWith(ID_CLASS)) continue;
|
|
155
|
+
const kind = token.charAt(ID_CLASS.length);
|
|
156
|
+
const raw = decodeURIComponent(token.slice(ID_CLASS.length + 2));
|
|
157
|
+
return kind === "n" ? Number(raw) : raw;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
138
162
|
function toSonnerData(opts: ToastOptions | undefined, id: ToastId): SonnerData {
|
|
139
|
-
const { redact, description, duration, action, ...rest } = opts ?? {};
|
|
140
|
-
const data: SonnerData = { ...rest, id };
|
|
163
|
+
const { redact, description, duration, action, className, ...rest } = opts ?? {};
|
|
164
|
+
const data: SonnerData = { ...rest, id, className: className ? `${idClass(id)} ${className}` : idClass(id) };
|
|
141
165
|
if (description !== undefined) data.description = redact ? privately(description) : description;
|
|
142
166
|
if (action) data.action = action;
|
|
143
167
|
const resolved = duration ?? (action ? TOAST_ACTION_DURATION : undefined);
|
|
@@ -312,6 +336,15 @@ export interface ToasterProps {
|
|
|
312
336
|
hotkey?: string[];
|
|
313
337
|
gap?: number;
|
|
314
338
|
dir?: "ltr" | "rtl" | "auto";
|
|
339
|
+
/**
|
|
340
|
+
* A middle click on a toast dismisses it (0.18.0) — the gesture that closes a tab,
|
|
341
|
+
* which is what keksdose's users reached for (dev #583). By id, so it works on a
|
|
342
|
+
* toast with `closeButton: false` too; it counts as the user closing it, so
|
|
343
|
+
* `onDismiss` runs. Not on a link or a button inside the toast — a middle click on a
|
|
344
|
+
* link still opens it in a new tab — and not on a `dismissible: false` toast.
|
|
345
|
+
* Default true.
|
|
346
|
+
*/
|
|
347
|
+
dismissOnMiddleClick?: boolean;
|
|
315
348
|
className?: string;
|
|
316
349
|
style?: CSSProperties;
|
|
317
350
|
}
|
|
@@ -385,6 +418,7 @@ export function Toaster({
|
|
|
385
418
|
swipeDirections = ALL_DIRECTIONS,
|
|
386
419
|
offset,
|
|
387
420
|
mobileOffset,
|
|
421
|
+
dismissOnMiddleClick = true,
|
|
388
422
|
className,
|
|
389
423
|
style,
|
|
390
424
|
...rest
|
|
@@ -409,6 +443,42 @@ export function Toaster({
|
|
|
409
443
|
};
|
|
410
444
|
}, [labels]);
|
|
411
445
|
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
if (!dismissOnMiddleClick || !mod) return;
|
|
448
|
+
// On the document rather than the toaster: sonner owns the `<ol>`, and a listener
|
|
449
|
+
// there would have to be re-attached whenever it remounts it.
|
|
450
|
+
const toastUnder = (e: globalThis.MouseEvent) => {
|
|
451
|
+
if (e.button !== 1 || !(e.target instanceof Element)) return null;
|
|
452
|
+
const li = e.target.closest<HTMLElement>("[data-sonner-toast]");
|
|
453
|
+
if (!li || li.dataset.dismissible === "false") return null;
|
|
454
|
+
// A link keeps its middle click (open in a new tab); a button or a field inside
|
|
455
|
+
// the toast is the app's, not a background to click.
|
|
456
|
+
const control = e.target.closest("a[href], button, input, select, textarea, [role='button'], [role='link']");
|
|
457
|
+
if (control && li.contains(control)) return null;
|
|
458
|
+
return li;
|
|
459
|
+
};
|
|
460
|
+
// `mousedown`: the middle button starts autoscroll (Windows) before any click.
|
|
461
|
+
const onDown = (e: globalThis.MouseEvent) => {
|
|
462
|
+
if (toastUnder(e)) e.preventDefault();
|
|
463
|
+
};
|
|
464
|
+
const onAux = (e: globalThis.MouseEvent) => {
|
|
465
|
+
const li = toastUnder(e);
|
|
466
|
+
if (!li) return;
|
|
467
|
+
// No paste of the primary selection on Linux, no other handler's idea of it.
|
|
468
|
+
e.preventDefault();
|
|
469
|
+
const id = readToastId(li);
|
|
470
|
+
if (id !== null) mod.toast.dismiss(id);
|
|
471
|
+
// A toast made by sonner directly carries no id: its close button, if any.
|
|
472
|
+
else li.querySelector<HTMLButtonElement>("[data-close-button]")?.click();
|
|
473
|
+
};
|
|
474
|
+
document.addEventListener("mousedown", onDown);
|
|
475
|
+
document.addEventListener("auxclick", onAux);
|
|
476
|
+
return () => {
|
|
477
|
+
document.removeEventListener("mousedown", onDown);
|
|
478
|
+
document.removeEventListener("auxclick", onAux);
|
|
479
|
+
};
|
|
480
|
+
}, [dismissOnMiddleClick, mod]);
|
|
481
|
+
|
|
412
482
|
const documentTheme = useSyncExternalStore(subscribeDocumentTheme, readDocumentTheme, () => "light" as const);
|
|
413
483
|
const phone = useMediaQuery(PHONE_QUERY, false);
|
|
414
484
|
const resolved: ToastPosition = position ?? (phone ? "bottom-center" : "top-center");
|
|
@@ -340,8 +340,11 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
340
340
|
? "bg-[var(--bg-inverse)] text-[var(--text-inverse)]"
|
|
341
341
|
: "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
|
|
342
342
|
// No hover fill on a group that cannot be changed — a segment that
|
|
343
|
-
// lights up under the pointer is an offer, and there is none here.
|
|
344
|
-
|
|
343
|
+
// lights up under the pointer is an offer, and there is none here. Not on
|
|
344
|
+
// the chosen segment: its fill IS its state, and its inverse text would
|
|
345
|
+
// vanish on a transparent hover.
|
|
346
|
+
disabled && "cursor-not-allowed",
|
|
347
|
+
disabled && !active && "hover:bg-transparent dark:hover:bg-transparent",
|
|
345
348
|
optionClassName,
|
|
346
349
|
opt.className,
|
|
347
350
|
)}
|
package/src/components/ui.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { Tooltip, type TooltipSide } from "./tooltip";
|
|
|
9
9
|
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
|
|
10
10
|
import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
|
|
11
11
|
import { pickLinkRenderer, replacingClick, routerLinkNavigation } from "./text-link";
|
|
12
|
+
import { useCommitReason } from "./write-lock";
|
|
12
13
|
|
|
13
14
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
|
|
14
15
|
|
|
@@ -196,6 +197,13 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
196
197
|
* DangerConfirm keeps its own.
|
|
197
198
|
*/
|
|
198
199
|
disabledReason?: ReactNode;
|
|
200
|
+
/**
|
|
201
|
+
* This button COMMITS — it saves, creates, deletes. Under a locked
|
|
202
|
+
* {@link WriteLockProvider} it is disabled the `disabledReason` way, with the lock's
|
|
203
|
+
* reason (which wins over a `disabledReason` of its own). No provider, or an
|
|
204
|
+
* unlocked one: no effect. See {@link WriteLockProvider}.
|
|
205
|
+
*/
|
|
206
|
+
commit?: boolean;
|
|
199
207
|
/**
|
|
200
208
|
* Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.
|
|
201
209
|
*
|
|
@@ -271,6 +279,7 @@ export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorEle
|
|
|
271
279
|
pending?: never;
|
|
272
280
|
pressed?: never;
|
|
273
281
|
disabledReason?: never;
|
|
282
|
+
commit?: never;
|
|
274
283
|
}
|
|
275
284
|
|
|
276
285
|
function hasContent(node: ReactNode): boolean {
|
|
@@ -396,7 +405,8 @@ function ButtonElement({
|
|
|
396
405
|
stretch,
|
|
397
406
|
tone = "default",
|
|
398
407
|
pressed,
|
|
399
|
-
disabledReason,
|
|
408
|
+
disabledReason: ownDisabledReason,
|
|
409
|
+
commit,
|
|
400
410
|
pending,
|
|
401
411
|
className,
|
|
402
412
|
onClick,
|
|
@@ -409,6 +419,7 @@ function ButtonElement({
|
|
|
409
419
|
...rest
|
|
410
420
|
}: ButtonProps) {
|
|
411
421
|
const reasonId = useId();
|
|
422
|
+
const disabledReason = useCommitReason(commit, ownDisabledReason);
|
|
412
423
|
const locked = hasContent(disabledReason);
|
|
413
424
|
const inert = locked || Boolean(pending);
|
|
414
425
|
const ownDescribedBy = rest["aria-describedby"];
|
|
@@ -874,6 +885,10 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
874
885
|
* reason nobody can see is not one. Wins over `disabled`, as on Button.
|
|
875
886
|
*/
|
|
876
887
|
disabledReason?: ReactNode;
|
|
888
|
+
/** This action COMMITS — {@link Button}'s `commit`: under a locked
|
|
889
|
+
* {@link WriteLockProvider} it takes the `disabledReason` path with the lock's
|
|
890
|
+
* reason. No effect without a lock. */
|
|
891
|
+
commit?: boolean;
|
|
877
892
|
/** The `<button>` element — a prop in React 19, as on {@link Button}. */
|
|
878
893
|
ref?: Ref<HTMLButtonElement>;
|
|
879
894
|
/** Only on the link form — see {@link IconButtonLinkProps}. */
|
|
@@ -937,6 +952,7 @@ export interface IconButtonLinkProps
|
|
|
937
952
|
form?: never;
|
|
938
953
|
pressed?: never;
|
|
939
954
|
disabledReason?: never;
|
|
955
|
+
commit?: never;
|
|
940
956
|
disabledStyle?: never;
|
|
941
957
|
pending?: never;
|
|
942
958
|
}
|
|
@@ -1164,7 +1180,8 @@ function IconButtonElement(
|
|
|
1164
1180
|
tooltipSide,
|
|
1165
1181
|
tooltipPortal,
|
|
1166
1182
|
tooltipLazy = true,
|
|
1167
|
-
disabledReason,
|
|
1183
|
+
disabledReason: ownDisabledReason,
|
|
1184
|
+
commit,
|
|
1168
1185
|
pending,
|
|
1169
1186
|
glyphSize,
|
|
1170
1187
|
badge,
|
|
@@ -1186,6 +1203,7 @@ function IconButtonElement(
|
|
|
1186
1203
|
const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
|
|
1187
1204
|
const keep = disabledStyle === "keep";
|
|
1188
1205
|
const reasonId = useId();
|
|
1206
|
+
const disabledReason = useCommitReason(commit, ownDisabledReason);
|
|
1189
1207
|
const locked = hasContent(disabledReason);
|
|
1190
1208
|
// Pending is inert like a locked button — focusable, every activation swallowed —
|
|
1191
1209
|
// without the locked look: the spinner is the state.
|
|
@@ -1750,15 +1768,48 @@ function useFieldError(
|
|
|
1750
1768
|
* down out of the field, between the value and the text. So the field keeps its box
|
|
1751
1769
|
* and this wraps the pair.
|
|
1752
1770
|
*
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
1755
|
-
* down to the bare `<input>` an unlabelled {@link Input} drops straight into a
|
|
1756
|
-
* caller's flex row.
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1771
|
+
* WHEN IT WRAPS. A field that never takes `error` renders nothing of its own here,
|
|
1772
|
+
* which is what keeps the prop additive: it is exactly the DOM it was before the prop
|
|
1773
|
+
* existed, down to the bare `<input>` an unlabelled {@link Input} drops straight into a
|
|
1774
|
+
* caller's flex row. A field that DOES take `error` — the key is passed, whatever its
|
|
1775
|
+
* value — is wrapped for as long as it is mounted, message or not (`reserve`).
|
|
1776
|
+
*
|
|
1777
|
+
* It used to wrap only while a message showed, and that cost the control its
|
|
1778
|
+
* identity: a fragment and a `<div>` are different elements at the same spot in the
|
|
1779
|
+
* tree, so React threw the `<input>` away and mounted a new one every time the message
|
|
1780
|
+
* came or went. Focus fell to `<body>`, the caret went with it, and an uncontrolled
|
|
1781
|
+
* field lost what had been typed — Kurvenschmiede's confirm-password dialog clears its
|
|
1782
|
+
* "Wrong password" on change, so the first keystroke of the retry dropped the user out
|
|
1783
|
+
* of the field. Keeping the box stops that; the field is never reparented.
|
|
1784
|
+
*
|
|
1785
|
+
* The other ways to keep it stable were worse. A box on EVERY field changes what every
|
|
1786
|
+
* flex row and grid in three apps lays out (`flex-1`, `w-56`, `col-span-2` on the
|
|
1787
|
+
* field would land on a child of the item instead of the item). A `display: contents`
|
|
1788
|
+
* box would keep the flex/grid item, but a contents box takes no margin, so a parent's
|
|
1789
|
+
* `space-y-*` stops spacing the field — the shape of Kurvenschmiede's reset-password
|
|
1790
|
+
* form and gear wizard, both `space-y-*` stacks of fields with `error` — and it moves
|
|
1791
|
+
* `first:`/`last:`/`> *` matches. A field passing `error` already took this plain box
|
|
1792
|
+
* whenever its message showed, so what changes is only that it keeps it while none
|
|
1793
|
+
* does; every such call site in the three apps sits in a block or a `space-y`/grid
|
|
1794
|
+
* stack where a full-width block box is the field's own size. The case it does change
|
|
1795
|
+
* is a field passing `error` whose `className` sizes it as a flex item (`flex-1` in a
|
|
1796
|
+
* row): that caller already lost the sizing while a message showed, and now sees the
|
|
1797
|
+
* same box without one. Pass `error` only where a message can actually appear.
|
|
1798
|
+
*
|
|
1799
|
+
* `className` is NOT moved out here, for the same reason — it goes on the field, as it
|
|
1800
|
+
* always has, so the box cannot silently change what that prop styles.
|
|
1759
1801
|
*/
|
|
1760
|
-
function FieldGroup({
|
|
1761
|
-
|
|
1802
|
+
function FieldGroup({
|
|
1803
|
+
errorEl,
|
|
1804
|
+
reserve = false,
|
|
1805
|
+
children,
|
|
1806
|
+
}: {
|
|
1807
|
+
errorEl: ReactNode;
|
|
1808
|
+
/** The caller passed `error`: keep the box even while there is no message. */
|
|
1809
|
+
reserve?: boolean;
|
|
1810
|
+
children: ReactNode;
|
|
1811
|
+
}) {
|
|
1812
|
+
if (errorEl === null && !reserve) return <>{children}</>;
|
|
1762
1813
|
return (
|
|
1763
1814
|
<div>
|
|
1764
1815
|
{children}
|
|
@@ -1829,8 +1880,8 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
|
1829
1880
|
passwordLabels?: Partial<PasswordRevealLabels>;
|
|
1830
1881
|
}
|
|
1831
1882
|
|
|
1832
|
-
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
1833
|
-
{
|
|
1883
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
|
|
1884
|
+
const {
|
|
1834
1885
|
className,
|
|
1835
1886
|
inputClassName,
|
|
1836
1887
|
label,
|
|
@@ -1842,9 +1893,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
1842
1893
|
error,
|
|
1843
1894
|
passwordLabels,
|
|
1844
1895
|
...rest
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
|
|
1896
|
+
} = props;
|
|
1897
|
+
// Passed at all — even as `undefined` — the field keeps its box; see FieldGroup.
|
|
1898
|
+
const reserve = "error" in props;
|
|
1848
1899
|
const generated = useId();
|
|
1849
1900
|
const fieldId = id ?? generated;
|
|
1850
1901
|
const { isInvalid, describedBy, errorEl } = useFieldError(
|
|
@@ -1894,7 +1945,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
1894
1945
|
if (label === undefined) {
|
|
1895
1946
|
if (!isPassword) {
|
|
1896
1947
|
return (
|
|
1897
|
-
<FieldGroup errorEl={errorEl}>
|
|
1948
|
+
<FieldGroup errorEl={errorEl} reserve={reserve}>
|
|
1898
1949
|
<input
|
|
1899
1950
|
ref={ref}
|
|
1900
1951
|
id={id}
|
|
@@ -1915,7 +1966,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
1915
1966
|
);
|
|
1916
1967
|
}
|
|
1917
1968
|
return (
|
|
1918
|
-
<FieldGroup errorEl={errorEl}>
|
|
1969
|
+
<FieldGroup errorEl={errorEl} reserve={reserve}>
|
|
1919
1970
|
<div className={cn("relative", className)}>
|
|
1920
1971
|
<input
|
|
1921
1972
|
ref={ref}
|
|
@@ -1933,7 +1984,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
1933
1984
|
);
|
|
1934
1985
|
}
|
|
1935
1986
|
return (
|
|
1936
|
-
<FieldGroup errorEl={errorEl}>
|
|
1987
|
+
<FieldGroup errorEl={errorEl} reserve={reserve}>
|
|
1937
1988
|
<FloatingField className={className} htmlFor={fieldId} label={label} srOnlyLabel={asDisplay}>
|
|
1938
1989
|
<input
|
|
1939
1990
|
ref={ref}
|
|
@@ -2001,10 +2052,11 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
|
|
|
2001
2052
|
// height the caller's type brings with it.
|
|
2002
2053
|
const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
|
|
2003
2054
|
|
|
2004
|
-
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
|
2005
|
-
{ className, label, id, children, invalid, error, hint, size, selectClassName, ...rest }
|
|
2006
|
-
|
|
2007
|
-
|
|
2055
|
+
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(props, ref) {
|
|
2056
|
+
const { className, label, id, children, invalid, error, hint, size, selectClassName, ...rest } =
|
|
2057
|
+
props;
|
|
2058
|
+
// See Input's `reserve`.
|
|
2059
|
+
const reserve = "error" in props;
|
|
2008
2060
|
const generated = useId();
|
|
2009
2061
|
// Only a number reaches the DOM; the two words are this component's own.
|
|
2010
2062
|
const nativeSize = typeof size === "number" ? size : undefined;
|
|
@@ -2100,7 +2152,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2100
2152
|
// box, as the label line's own hint goes at the end of the label.
|
|
2101
2153
|
if (hasContent(hint) && !textHint) {
|
|
2102
2154
|
return (
|
|
2103
|
-
<FieldGroup errorEl={below}>
|
|
2155
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2104
2156
|
<div className={cn("flex items-center gap-1.5", className)}>
|
|
2105
2157
|
<div className="relative min-w-0 flex-1">
|
|
2106
2158
|
{select}
|
|
@@ -2112,7 +2164,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2112
2164
|
);
|
|
2113
2165
|
}
|
|
2114
2166
|
return (
|
|
2115
|
-
<FieldGroup errorEl={below}>
|
|
2167
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2116
2168
|
<div className={cn("relative", className)}>
|
|
2117
2169
|
{select}
|
|
2118
2170
|
{chevron}
|
|
@@ -2121,7 +2173,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
2121
2173
|
);
|
|
2122
2174
|
}
|
|
2123
2175
|
return (
|
|
2124
|
-
<FieldGroup errorEl={below}>
|
|
2176
|
+
<FieldGroup errorEl={below} reserve={reserve}>
|
|
2125
2177
|
<FloatingField className={className} htmlFor={fieldId} label={label} staticLabel hint={textHint ? undefined : hint}>
|
|
2126
2178
|
<select
|
|
2127
2179
|
ref={ref}
|
|
@@ -2159,10 +2211,10 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
|
|
2159
2211
|
error?: ReactNode;
|
|
2160
2212
|
}
|
|
2161
2213
|
|
|
2162
|
-
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
|
2163
|
-
{ className, label, id, placeholder, invalid, error, ...rest }
|
|
2164
|
-
|
|
2165
|
-
|
|
2214
|
+
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(props, ref) {
|
|
2215
|
+
const { className, label, id, placeholder, invalid, error, ...rest } = props;
|
|
2216
|
+
// See Input's `reserve`.
|
|
2217
|
+
const reserve = "error" in props;
|
|
2166
2218
|
const generated = useId();
|
|
2167
2219
|
const fieldId = id ?? generated;
|
|
2168
2220
|
const { isInvalid, describedBy, errorEl } = useFieldError(
|
|
@@ -2173,7 +2225,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
|
|
|
2173
2225
|
);
|
|
2174
2226
|
if (label === undefined) {
|
|
2175
2227
|
return (
|
|
2176
|
-
<FieldGroup errorEl={errorEl}>
|
|
2228
|
+
<FieldGroup errorEl={errorEl} reserve={reserve}>
|
|
2177
2229
|
<textarea
|
|
2178
2230
|
ref={ref}
|
|
2179
2231
|
id={id}
|
|
@@ -2187,7 +2239,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
|
|
|
2187
2239
|
);
|
|
2188
2240
|
}
|
|
2189
2241
|
return (
|
|
2190
|
-
<FieldGroup errorEl={errorEl}>
|
|
2242
|
+
<FieldGroup errorEl={errorEl} reserve={reserve}>
|
|
2191
2243
|
<FloatingField className={className} htmlFor={fieldId} label={label}>
|
|
2192
2244
|
<textarea
|
|
2193
2245
|
ref={ref}
|
|
@@ -2269,8 +2321,33 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
2269
2321
|
* nor on `inset`, which has no border to make louder (its tone is a fill).
|
|
2270
2322
|
*/
|
|
2271
2323
|
toneStrength?: "soft" | "strong";
|
|
2324
|
+
/**
|
|
2325
|
+
* Wash the card in the tone's quiet `-bg` surface as well as framing it. A toned
|
|
2326
|
+
* card is a border and a title colour, at either strength — the wash belongs to
|
|
2327
|
+
* `AlertBanner`, the message, not to a card that holds a form — so it stays
|
|
2328
|
+
* off by default. Some tiles want both: keksdose's password / delete-account tile
|
|
2329
|
+
* was `toneFrameClass(tone)` (border AND wash) before 0.16, and after moving to
|
|
2330
|
+
* `toneStrength="strong"` it re-added `bg-[var(--danger-bg)]` by hand. The wash is
|
|
2331
|
+
* layered OVER the card's own `--bg-surface` (as AlertBanner's `elevated` does), so
|
|
2332
|
+
* the dark theme's translucent tints stay a card, not a hole to the page below.
|
|
2333
|
+
*
|
|
2334
|
+
* No effect without `tone`, nor on `inset`, whose tone is already the fill.
|
|
2335
|
+
*/
|
|
2336
|
+
toneFill?: boolean;
|
|
2337
|
+
/**
|
|
2338
|
+
* The parts' type scale. `"compact"`: CardTitle `text-sm font-medium`,
|
|
2339
|
+
* CardDescription `text-xs` and the CardHeader's title-to-description gap
|
|
2340
|
+
* `gap-0.5` — the small settings / admin card. keksdose wrote those three classes by
|
|
2341
|
+
* hand on ~25 cards. Set here, they reach the parts through context; a class on a
|
|
2342
|
+
* part still wins, and a Card nested inside starts again from its own density.
|
|
2343
|
+
*
|
|
2344
|
+
* Default `"comfortable"`: the parts as they have always been.
|
|
2345
|
+
*/
|
|
2346
|
+
density?: CardDensity;
|
|
2272
2347
|
}
|
|
2273
2348
|
|
|
2349
|
+
export type CardDensity = "comfortable" | "compact";
|
|
2350
|
+
|
|
2274
2351
|
export type CardTone = "warning" | "danger" | "info" | "success";
|
|
2275
2352
|
|
|
2276
2353
|
// The title is reached through its `data-slot`, so the tone needs no context and a
|
|
@@ -2312,6 +2389,16 @@ const CARD_PADDING_STRONG: Record<NonNullable<CardProps["padding"]>, string> = {
|
|
|
2312
2389
|
md: "p-[15px]",
|
|
2313
2390
|
};
|
|
2314
2391
|
|
|
2392
|
+
// The wash as a background IMAGE over the card's opaque `--bg-surface` — see
|
|
2393
|
+
// `toneFill`. A plain `bg-[var(--danger-bg)]` would REPLACE the surface, and the dark
|
|
2394
|
+
// theme's washes are a translucent tint.
|
|
2395
|
+
const CARD_TONE_WASH: Record<CardTone, string> = {
|
|
2396
|
+
warning: "bg-[image:linear-gradient(var(--warning-bg),var(--warning-bg))]",
|
|
2397
|
+
danger: "bg-[image:linear-gradient(var(--danger-bg),var(--danger-bg))]",
|
|
2398
|
+
info: "bg-[image:linear-gradient(var(--info-bg),var(--info-bg))]",
|
|
2399
|
+
success: "bg-[image:linear-gradient(var(--success-bg),var(--success-bg))]",
|
|
2400
|
+
};
|
|
2401
|
+
|
|
2315
2402
|
const CARD_TONES_STRONG: Record<CardTone, string> = {
|
|
2316
2403
|
warning: "border-2 border-[var(--warning)]",
|
|
2317
2404
|
danger: "border-2 border-[var(--danger-border-strong)]",
|
|
@@ -2326,6 +2413,9 @@ const CARD_TONES_STRONG: Record<CardTone, string> = {
|
|
|
2326
2413
|
*/
|
|
2327
2414
|
const CardPaddedContext = createContext(false);
|
|
2328
2415
|
|
|
2416
|
+
/** The {@link CardProps.density} of the nearest Card — the parts' type scale. */
|
|
2417
|
+
const CardDensityContext = createContext<CardDensity>("comfortable");
|
|
2418
|
+
|
|
2329
2419
|
export function Card({
|
|
2330
2420
|
className,
|
|
2331
2421
|
children,
|
|
@@ -2333,6 +2423,8 @@ export function Card({
|
|
|
2333
2423
|
variant = "default",
|
|
2334
2424
|
tone,
|
|
2335
2425
|
toneStrength = "soft",
|
|
2426
|
+
toneFill = false,
|
|
2427
|
+
density = "comfortable",
|
|
2336
2428
|
padding,
|
|
2337
2429
|
...rest
|
|
2338
2430
|
}: CardProps) {
|
|
@@ -2347,6 +2439,7 @@ export function Card({
|
|
|
2347
2439
|
<div
|
|
2348
2440
|
data-tone={tone}
|
|
2349
2441
|
data-tone-strength={strong ? "strong" : undefined}
|
|
2442
|
+
data-density={density === "compact" ? "compact" : undefined}
|
|
2350
2443
|
{...rest}
|
|
2351
2444
|
className={cn(
|
|
2352
2445
|
variant === "inset"
|
|
@@ -2365,12 +2458,15 @@ export function Card({
|
|
|
2365
2458
|
),
|
|
2366
2459
|
toned && (bordered ? toned.border : toned.fill),
|
|
2367
2460
|
strong && tone && CARD_TONES_STRONG[tone],
|
|
2461
|
+
toneFill && bordered && tone && CARD_TONE_WASH[tone],
|
|
2368
2462
|
toned?.title,
|
|
2369
2463
|
ownPadding && (strong ? CARD_PADDING_STRONG : CARD_PADDING)[ownPadding],
|
|
2370
2464
|
className,
|
|
2371
2465
|
)}
|
|
2372
2466
|
>
|
|
2373
|
-
<CardPaddedContext.Provider value={ownPadding !== undefined}>
|
|
2467
|
+
<CardPaddedContext.Provider value={ownPadding !== undefined}>
|
|
2468
|
+
<CardDensityContext.Provider value={density}>{children}</CardDensityContext.Provider>
|
|
2469
|
+
</CardPaddedContext.Provider>
|
|
2374
2470
|
</div>
|
|
2375
2471
|
);
|
|
2376
2472
|
}
|
|
@@ -2415,6 +2511,7 @@ export type CardFooterProps = ComponentPropsWithoutRef<"div">;
|
|
|
2415
2511
|
|
|
2416
2512
|
export function CardHeader({ className, stackAction = false, ...props }: CardHeaderProps) {
|
|
2417
2513
|
const padded = useContext(CardPaddedContext);
|
|
2514
|
+
const compact = useContext(CardDensityContext) === "compact";
|
|
2418
2515
|
return (
|
|
2419
2516
|
<div
|
|
2420
2517
|
data-slot="card-header"
|
|
@@ -2433,6 +2530,7 @@ export function CardHeader({ className, stackAction = false, ...props }: CardHea
|
|
|
2433
2530
|
// never inherits an ancestor header's two columns.
|
|
2434
2531
|
"grid auto-rows-min items-start gap-1.5 [--card-header-cols:1fr] has-data-[slot=card-action]:[--card-header-cols:1fr_auto] grid-cols-[var(--card-header-cols,1fr)]",
|
|
2435
2532
|
padded ? "px-0 pt-0" : "px-6 pt-6",
|
|
2533
|
+
compact && "gap-0.5",
|
|
2436
2534
|
// One column under `sm`, and the action back into the flow (DOM order, so
|
|
2437
2535
|
// after the description when it is written after it), at the start edge.
|
|
2438
2536
|
// The descendant selector outranks CardAction's own placement classes.
|
|
@@ -2446,10 +2544,23 @@ export function CardHeader({ className, stackAction = false, ...props }: CardHea
|
|
|
2446
2544
|
}
|
|
2447
2545
|
|
|
2448
2546
|
export function CardTitle({ className, as: Tag = "div", ...props }: CardTitleProps) {
|
|
2449
|
-
|
|
2547
|
+
const compact = useContext(CardDensityContext) === "compact";
|
|
2548
|
+
return (
|
|
2549
|
+
<Tag
|
|
2550
|
+
data-slot="card-title"
|
|
2551
|
+
// Compact is `leading-snug`, not `leading-none`: there the title sits on a
|
|
2552
|
+
// 12px description `gap-0.5` below, and a line box exactly the cap height left
|
|
2553
|
+
// the hint hugging the title's descenders (every keksdose admin card added
|
|
2554
|
+
// `leading-normal` by hand). Comfortable keeps `leading-none` — its 14px
|
|
2555
|
+
// description is `gap-1.5` away and has always read right.
|
|
2556
|
+
className={cn(compact ? "text-sm font-medium leading-snug" : "font-semibold leading-none", className)}
|
|
2557
|
+
{...props}
|
|
2558
|
+
/>
|
|
2559
|
+
);
|
|
2450
2560
|
}
|
|
2451
2561
|
|
|
2452
2562
|
export function CardDescription({ className, ...props }: CardDescriptionProps) {
|
|
2563
|
+
const compact = useContext(CardDensityContext) === "compact";
|
|
2453
2564
|
return (
|
|
2454
2565
|
<div
|
|
2455
2566
|
data-slot="card-description"
|
|
@@ -2457,7 +2568,7 @@ export function CardDescription({ className, ...props }: CardDescriptionProps) {
|
|
|
2457
2568
|
// field captions). It was `--money-neutral`, a MONEY token that happened to be
|
|
2458
2569
|
// a similar grey — and a palette that re-tunes the money colours moved every
|
|
2459
2570
|
// card's description with them.
|
|
2460
|
-
className={cn("text-sm text-[var(--text-muted)]", className)}
|
|
2571
|
+
className={cn(compact ? "text-xs" : "text-sm", "text-[var(--text-muted)]", className)}
|
|
2461
2572
|
{...props}
|
|
2462
2573
|
/>
|
|
2463
2574
|
);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
4
|
+
|
|
5
|
+
/** The words {@link WriteLockProvider} renders on its own behalf. */
|
|
6
|
+
export interface WriteLockLabels {
|
|
7
|
+
/** The tooltip of a locked commit when the provider gives no `reason`. */
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_WRITE_LOCK_LABELS: WriteLockLabels = {
|
|
12
|
+
reason: "You can view this but not change it.",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** What {@link useWriteLock} returns: whether commits are locked here, and why. */
|
|
16
|
+
export interface WriteLock {
|
|
17
|
+
locked: boolean;
|
|
18
|
+
/** The one sentence a locked commit shows in its tooltip. Always set while
|
|
19
|
+
* `locked`; `undefined` when not. */
|
|
20
|
+
reason: ReactNode | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const UNLOCKED: WriteLock = { locked: false, reason: undefined };
|
|
24
|
+
|
|
25
|
+
const WriteLockContext = createContext<WriteLock>(UNLOCKED);
|
|
26
|
+
|
|
27
|
+
export interface WriteLockProviderProps {
|
|
28
|
+
/** Commits below are locked. `false` unlocks a subtree under an outer lock. */
|
|
29
|
+
locked: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The one sentence that says why — "Shared with you to read", "Read-only demo —
|
|
32
|
+
* saving is disabled", "A customer account cannot create curves". The app's policy,
|
|
33
|
+
* already translated. Left out: `labels.reason`.
|
|
34
|
+
*/
|
|
35
|
+
reason?: ReactNode;
|
|
36
|
+
labels?: Partial<WriteLockLabels>;
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The read-only lock — **everything renders; only the commit is locked.**
|
|
42
|
+
*
|
|
43
|
+
* keksdose (features/budgets/write-lock.tsx, the read-only demo budget) and
|
|
44
|
+
* Kurvenschmiede (features/sharing/write-lock.tsx, a curve shared to view, a customer
|
|
45
|
+
* account that owns nothing) each built the same `SaveGuard`: the page, its tabs,
|
|
46
|
+
* forms and dialogs render exactly as for an owner, because a viewer looking at half
|
|
47
|
+
* a page is not looking at what they were sent; what changes is that the control
|
|
48
|
+
* that SAVES is off and says why on hover or focus. Both wrapped each Save in a
|
|
49
|
+
* Tooltip and forced a native `disabled` on it — out of the tab order, so the reason
|
|
50
|
+
* never reached a keyboard — and both had to remember it at thirty call sites.
|
|
51
|
+
*
|
|
52
|
+
* Here the page mounts one provider and each commit control opts in with `commit`
|
|
53
|
+
* ({@link Button}, {@link IconButton}, {@link FormActions}): under a lock it takes the
|
|
54
|
+
* kit's `disabledReason` path with this reason — `aria-disabled` and focusable,
|
|
55
|
+
* every activation swallowed, the reason in the kit Tooltip and its description.
|
|
56
|
+
*
|
|
57
|
+
* Who is locked is the app's: the provider takes a boolean, never a role. Fields stay
|
|
58
|
+
* editable — nothing in them reaches the server until the commit is pressed, and a
|
|
59
|
+
* form you cannot fill in is not a demo of the form (keksdose accounts-page). A
|
|
60
|
+
* control that IS its own commit (a toggle that saves on change) reads
|
|
61
|
+
* {@link useWriteLock} and disables itself. The lock is a courtesy, not a guard: the
|
|
62
|
+
* server still refuses the write.
|
|
63
|
+
*
|
|
64
|
+
* Nested providers: the nearest wins, so an inner `locked={false}` reopens a section
|
|
65
|
+
* the caller may write (a viewer's own notes on a shared curve), and an inner lock
|
|
66
|
+
* with its own reason (Kurvenschmiede's owner lock beside the viewer lock) overrides
|
|
67
|
+
* the outer one's sentence.
|
|
68
|
+
*/
|
|
69
|
+
export function WriteLockProvider({ locked, reason, labels, children }: WriteLockProviderProps) {
|
|
70
|
+
// The `writeLock` namespace: prop > `<UiKitProvider labels>` > English.
|
|
71
|
+
const fallback = useKitLabels("writeLock", DEFAULT_WRITE_LOCK_LABELS, labels).reason;
|
|
72
|
+
// `null`/`""`/`false` count as "no reason given": a lock whose tooltip is empty
|
|
73
|
+
// would be a disabled button that cannot say why, the thing this replaces.
|
|
74
|
+
const given = reason !== undefined && reason !== null && reason !== false && reason !== "";
|
|
75
|
+
const resolved = locked ? (given ? reason : fallback) : undefined;
|
|
76
|
+
const value = useMemo<WriteLock>(
|
|
77
|
+
() => (locked ? { locked: true, reason: resolved } : UNLOCKED),
|
|
78
|
+
[locked, resolved],
|
|
79
|
+
);
|
|
80
|
+
return <WriteLockContext.Provider value={value}>{children}</WriteLockContext.Provider>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The nearest {@link WriteLockProvider}'s lock; unlocked with no provider. */
|
|
84
|
+
export function useWriteLock(): WriteLock {
|
|
85
|
+
return useContext(WriteLockContext);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The `disabledReason` a control with `commit` should use: the lock's reason while
|
|
90
|
+
* locked — it wins over the control's own, since it is the answer to the question the
|
|
91
|
+
* hover is asking — else the control's own. Internal to the kit's commit controls.
|
|
92
|
+
*/
|
|
93
|
+
export function useCommitReason(commit: boolean | undefined, own: ReactNode): ReactNode {
|
|
94
|
+
const lock = useWriteLock();
|
|
95
|
+
return commit && lock.locked ? lock.reason : own;
|
|
96
|
+
}
|