@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
|
@@ -63,8 +63,16 @@ export interface TwoFactorSettingLabels {
|
|
|
63
63
|
|
|
64
64
|
export interface PasskeysSettingLabels {
|
|
65
65
|
title: string;
|
|
66
|
-
/** The line under the title saying what a passkey is for
|
|
66
|
+
/** The line under the title saying what a passkey is for — `mode="instead"` (the
|
|
67
|
+
* default): the passkey stands in for the password. */
|
|
67
68
|
description: string;
|
|
69
|
+
/**
|
|
70
|
+
* The same line for `mode="alongside"` (0.18.0): the password keeps working and the
|
|
71
|
+
* passkey is a second way in. OPTIONAL for the reason `TwoFactorSettingLabels.qrAlt`
|
|
72
|
+
* is — this interface is annotated at call sites — and filled from the provider's
|
|
73
|
+
* `accountSettings.passkeys`, then English.
|
|
74
|
+
*/
|
|
75
|
+
descriptionAlongside?: string;
|
|
68
76
|
empty: string;
|
|
69
77
|
loading: string;
|
|
70
78
|
/** Accessible name of the list. */
|
|
@@ -98,8 +106,11 @@ export interface PasskeysSettingLabels {
|
|
|
98
106
|
export interface AccountSettingsLabels {
|
|
99
107
|
profile: ProfileSettingLabels;
|
|
100
108
|
password: PasswordSettingLabels;
|
|
109
|
+
// `Required` here, optional on the section interfaces: the namespace is what a full
|
|
110
|
+
// catalogue (`@eifi1/ui-kit/i18n/<code>`, `missingKitLabels`) is written against, so
|
|
111
|
+
// it must name the late keys; the section interfaces are annotated at call sites.
|
|
101
112
|
twoFactor: Required<TwoFactorSettingLabels>;
|
|
102
|
-
passkeys: PasskeysSettingLabels
|
|
113
|
+
passkeys: Required<PasskeysSettingLabels>;
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
export const DEFAULT_ACCOUNT_SETTINGS_LABELS: AccountSettingsLabels = {
|
|
@@ -139,6 +150,9 @@ export const DEFAULT_ACCOUNT_SETTINGS_LABELS: AccountSettingsLabels = {
|
|
|
139
150
|
passkeys: {
|
|
140
151
|
title: "Passkeys",
|
|
141
152
|
description: "Sign in with your fingerprint, face or device PIN instead of a password.",
|
|
153
|
+
// Kurvenschmiede overrode `description` because "instead of" is false where the
|
|
154
|
+
// password stays; this is the sentence for those apps, picked by `mode`.
|
|
155
|
+
descriptionAlongside: "Sign in with your fingerprint, face or device PIN. Your password keeps working too.",
|
|
142
156
|
empty: "No passkeys yet",
|
|
143
157
|
loading: "Loading passkeys…",
|
|
144
158
|
list: "Your passkeys",
|
|
@@ -175,6 +175,14 @@ interface AmountInputProps {
|
|
|
175
175
|
* 1.789 €/l is stored as 123.46 / 1.79, and no test fails (keksdose G2, a
|
|
176
176
|
* `Numeric(18,6)` holding price and a `Numeric(18,4)` unit price). Pass the column's
|
|
177
177
|
* scale: `digits={4}`, `digits={6}`.
|
|
178
|
+
*
|
|
179
|
+
* **Set `digits` for any sub-currency scale** — not only a price: any field whose
|
|
180
|
+
* column holds more decimals than the currency's minor unit. A split line stored at
|
|
181
|
+
* the ledger's 4dp (`Numeric(18,4)`) and loaded as 12.3456 is SHOWN as 12.35, and
|
|
182
|
+
* committed as 12.35 as soon as the user tabs through it (blur settles) — so the form
|
|
183
|
+
* no longer equals what the server holds: the lines stop summing to the 4dp total,
|
|
184
|
+
* and Save is held (keksdose live #240). With a `currency`, every commit rounds to that currency's
|
|
185
|
+
* minor unit unless `digits` says otherwise: pass the column's scale (`digits={4}`).
|
|
178
186
|
*/
|
|
179
187
|
digits?: number;
|
|
180
188
|
/** The smallest amount the field settles on. A lower figure is raised to it when it
|
package/src/components/chip.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef, isValidElement, useId, useRef, useState } from "react";
|
|
1
|
+
import { forwardRef, isValidElement, useCallback, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
3
3
|
import { Check, X } from "lucide-react";
|
|
4
4
|
import type { LucideIcon } from "lucide-react";
|
|
@@ -282,6 +282,208 @@ const SIZE: Record<
|
|
|
282
282
|
},
|
|
283
283
|
};
|
|
284
284
|
|
|
285
|
+
/* ── Edge snapping (fractional device-pixel ratios) ──────────────────────────
|
|
286
|
+
*
|
|
287
|
+
* WHY. At a device-pixel ratio of 1.25 (a 125 % Windows display, or 125 % browser
|
|
288
|
+
* zoom) Chrome snaps a box to whole CSS pixels and only then scales it, so a 1px
|
|
289
|
+
* border is 1.25 device pixels wide and starts at a quarter-pixel phase that depends
|
|
290
|
+
* on where the edge lands. A chip's left edge follows whatever stands before it, its
|
|
291
|
+
* right edge follows its own text, so the two land on unrelated phases: one rasterises
|
|
292
|
+
* as a crisp pixel, the other as a two-pixel smear at half strength — and a 60 %-alpha
|
|
293
|
+
* border (`--danger-border`) makes the smeared side read as a THINNER line (keksdose
|
|
294
|
+
* dev #576).
|
|
295
|
+
*
|
|
296
|
+
* WHAT FIXES IT. The two edges rasterise as mirror images exactly when their device
|
|
297
|
+
* positions sum to a whole number: `(2·left + width) · dpr` integral, `left` being the
|
|
298
|
+
* edge's snapped CSS x. Rounding the WIDTH alone to the ratio's grid (4px at 1.25)
|
|
299
|
+
* makes the phases EQUAL, which is not the same thing: measured with keksdose's
|
|
300
|
+
* playwright harness across every left phase at 1.25, width-only rounding mirrored the
|
|
301
|
+
* edges at two of the four phases and left the other two as lopsided as before (mean
|
|
302
|
+
* peak asymmetry 16.5, worse than the unsnapped 10.9 a random width gets by luck); the
|
|
303
|
+
* position-aware width below took it to 0 at every phase. Inset rings, whole-pixel
|
|
304
|
+
* starts, compositing hints and fractional border widths were measured by keksdose
|
|
305
|
+
* and do not help — Chrome rounds a sub-pixel border back up to 1px.
|
|
306
|
+
*
|
|
307
|
+
* So the fix needs the chip's position, which is why it is opt-in (`snapEdges`): it
|
|
308
|
+
* reads layout. Cheaply — every snapped chip on the page shares one ResizeObserver,
|
|
309
|
+
* one `resize` listener and one resolution media query, and a pass is batched into a
|
|
310
|
+
* frame as read-all / write-all, not a forced layout per chip. At an integer ratio,
|
|
311
|
+
* and at a half one (1.5, 2.5: grid 2 — measured symmetric already, both edges a
|
|
312
|
+
* pixel and a half on the same pattern), there is nothing to fix and a pass
|
|
313
|
+
* writes nothing.
|
|
314
|
+
*/
|
|
315
|
+
|
|
316
|
+
/** The smallest whole CSS-pixel count `n` with `n × dpr` whole — 4 at 1.25, 2 at 1.5,
|
|
317
|
+
* 1 at an integer ratio. 0 for a ratio no step up to 4px fits (110 % zoom wants 10px;
|
|
318
|
+
* a chip growing by up to 9px is a layout change, not a hairline fix). */
|
|
319
|
+
function chipEdgeGrid(dpr: number): number {
|
|
320
|
+
for (let n = 1; n <= 4; n++) if (Math.abs(n * dpr - Math.round(n * dpr)) < 1e-3) return n;
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The width (whole CSS px, at least `natural`) that mirrors a box's two edges at
|
|
326
|
+
* `dpr`, for a box whose left edge sits at CSS x `left`. `natural` back when the ratio
|
|
327
|
+
* needs nothing.
|
|
328
|
+
*/
|
|
329
|
+
function snappedChipEdgeWidth(natural: number, left: number, dpr: number): number {
|
|
330
|
+
const n = chipEdgeGrid(dpr);
|
|
331
|
+
if (n <= 2) return natural;
|
|
332
|
+
const l = Math.round(left);
|
|
333
|
+
// A hair under a whole pixel is that pixel: 59.999 is a 60px chip, not a 61px one.
|
|
334
|
+
let w = Math.ceil(natural - 0.01);
|
|
335
|
+
while ((((2 * l + w) % n) + n) % n !== 0) w++;
|
|
336
|
+
return w;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const snapped = new Set<HTMLElement>();
|
|
340
|
+
/** The min-width each chip was given by a pass — so a pass only ever clears its own. */
|
|
341
|
+
const written = new WeakMap<HTMLElement, string>();
|
|
342
|
+
let snapFrame = 0;
|
|
343
|
+
let snapObserver: ResizeObserver | null = null;
|
|
344
|
+
let ratioQuery: MediaQueryList | null = null;
|
|
345
|
+
|
|
346
|
+
function clearOwn(el: HTMLElement) {
|
|
347
|
+
const own = written.get(el);
|
|
348
|
+
if (own !== undefined && el.style.minWidth === own) el.style.minWidth = "";
|
|
349
|
+
written.delete(el);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Re-snap every chip rendered with `snapEdges`, now. The kit already re-snaps when a
|
|
354
|
+
* chip renders, resizes, or the window or the ratio changes; call this after moving
|
|
355
|
+
* chips some other way (a side panel sliding in beside a table of status chips),
|
|
356
|
+
* since a move is not something an observer can see.
|
|
357
|
+
*/
|
|
358
|
+
export function refreshChipEdges(): void {
|
|
359
|
+
if (snapFrame) {
|
|
360
|
+
cancelAnimationFrame(snapFrame);
|
|
361
|
+
snapFrame = 0;
|
|
362
|
+
}
|
|
363
|
+
if (typeof window === "undefined" || !snapped.size) return;
|
|
364
|
+
const dpr = window.devicePixelRatio || 1;
|
|
365
|
+
const els = [...snapped];
|
|
366
|
+
// Write: back to the natural width, so what is read below is the chip, not our pin.
|
|
367
|
+
for (const el of els) clearOwn(el);
|
|
368
|
+
const n = chipEdgeGrid(dpr);
|
|
369
|
+
if (n <= 2) return;
|
|
370
|
+
// A chip whose own end decides where it starts (RTL, `justify-end`) moves when it
|
|
371
|
+
// grows, so a pass re-reads what it wrote and steps on by a pixel until the edges
|
|
372
|
+
// agree — `n` steps cover every residue. Each pass is ONE layout for every chip
|
|
373
|
+
// together. A chip that never agrees (a centred one moves both edges at once) is
|
|
374
|
+
// given its natural width back rather than kept wider for nothing.
|
|
375
|
+
let pending = els;
|
|
376
|
+
for (let pass = 0; pass <= n && pending.length; pass++) {
|
|
377
|
+
const reads = pending.map((el) => {
|
|
378
|
+
const r = el.getBoundingClientRect();
|
|
379
|
+
const label = el.querySelector<HTMLElement>(".truncate");
|
|
380
|
+
// Not laid out (a hidden ancestor, jsdom), or truncated at its container's edge,
|
|
381
|
+
// where a wider chip would overflow: leave it alone.
|
|
382
|
+
if (!r.width || (label && label.scrollWidth > label.clientWidth)) return null;
|
|
383
|
+
const width = Math.round(r.width * 64) / 64;
|
|
384
|
+
const target = snappedChipEdgeWidth(width, r.left + window.scrollX, dpr);
|
|
385
|
+
return target === width ? null : target;
|
|
386
|
+
});
|
|
387
|
+
const next: HTMLElement[] = [];
|
|
388
|
+
pending.forEach((el, i) => {
|
|
389
|
+
const target = reads[i];
|
|
390
|
+
if (target == null) return;
|
|
391
|
+
if (pass === n) {
|
|
392
|
+
clearOwn(el);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const own = written.get(el);
|
|
396
|
+
const value = `${pass > 0 && own !== undefined ? parseFloat(own) + 1 : target}px`;
|
|
397
|
+
el.style.minWidth = value;
|
|
398
|
+
written.set(el, value);
|
|
399
|
+
next.push(el);
|
|
400
|
+
});
|
|
401
|
+
pending = next;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function scheduleChipEdges() {
|
|
406
|
+
if (snapFrame || typeof requestAnimationFrame === "undefined") return;
|
|
407
|
+
snapFrame = requestAnimationFrame(() => {
|
|
408
|
+
snapFrame = 0;
|
|
409
|
+
refreshChipEdges();
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The resolution query for the CURRENT ratio: it stops matching when the ratio
|
|
414
|
+
* changes (a window dragged to another display), which a `resize` need not fire. */
|
|
415
|
+
function watchRatio() {
|
|
416
|
+
ratioQuery?.removeEventListener("change", onRatioChange);
|
|
417
|
+
ratioQuery =
|
|
418
|
+
typeof window.matchMedia === "function"
|
|
419
|
+
? window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`)
|
|
420
|
+
: null;
|
|
421
|
+
ratioQuery?.addEventListener("change", onRatioChange);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function onRatioChange() {
|
|
425
|
+
watchRatio();
|
|
426
|
+
scheduleChipEdges();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function registerSnap(el: HTMLElement) {
|
|
430
|
+
if (snapped.has(el)) return;
|
|
431
|
+
if (!snapped.size) {
|
|
432
|
+
if (typeof ResizeObserver !== "undefined") snapObserver = new ResizeObserver(scheduleChipEdges);
|
|
433
|
+
window.addEventListener("resize", scheduleChipEdges);
|
|
434
|
+
watchRatio();
|
|
435
|
+
}
|
|
436
|
+
snapped.add(el);
|
|
437
|
+
snapObserver?.observe(el);
|
|
438
|
+
scheduleChipEdges();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function unregisterSnap(el: HTMLElement) {
|
|
442
|
+
if (!snapped.delete(el)) return;
|
|
443
|
+
snapObserver?.unobserve(el);
|
|
444
|
+
clearOwn(el);
|
|
445
|
+
if (snapped.size) return;
|
|
446
|
+
snapObserver?.disconnect();
|
|
447
|
+
snapObserver = null;
|
|
448
|
+
window.removeEventListener("resize", scheduleChipEdges);
|
|
449
|
+
ratioQuery?.removeEventListener("change", onRatioChange);
|
|
450
|
+
ratioQuery = null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The ref a chip hangs on its visible pill, plus a re-snap after every render (its
|
|
454
|
+
* label or its neighbours may have changed). */
|
|
455
|
+
function useSnapEdges(on: boolean) {
|
|
456
|
+
const node = useRef<HTMLElement | null>(null);
|
|
457
|
+
const ref = useCallback(
|
|
458
|
+
(el: HTMLElement | null) => {
|
|
459
|
+
if (el === node.current) return;
|
|
460
|
+
const prev = node.current;
|
|
461
|
+
node.current = el;
|
|
462
|
+
// React detaches and re-attaches a ref whose callback changed identity (a
|
|
463
|
+
// consumer's inline ref) — the same element, a moment apart. Unregistering on
|
|
464
|
+
// the detach would clear the pin and paint one frame unsnapped, so the drop
|
|
465
|
+
// waits a microtask and is skipped if the element came straight back.
|
|
466
|
+
if (prev) queueMicrotask(() => node.current !== prev && unregisterSnap(prev));
|
|
467
|
+
if (el && on) registerSnap(el);
|
|
468
|
+
},
|
|
469
|
+
[on],
|
|
470
|
+
);
|
|
471
|
+
useLayoutEffect(() => {
|
|
472
|
+
const el = node.current;
|
|
473
|
+
if (!el) return;
|
|
474
|
+
if (on) {
|
|
475
|
+
registerSnap(el);
|
|
476
|
+
scheduleChipEdges();
|
|
477
|
+
} else unregisterSnap(el);
|
|
478
|
+
});
|
|
479
|
+
return ref;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function assignRef<T>(target: Ref<T> | undefined, value: T | null) {
|
|
483
|
+
if (typeof target === "function") target(value);
|
|
484
|
+
else if (target) (target as { current: T | null }).current = value;
|
|
485
|
+
}
|
|
486
|
+
|
|
285
487
|
const CHIP_PILL = "inline-flex max-w-full items-center rounded-full border transition-colors";
|
|
286
488
|
// `focus-visible`, not `focus`: a chip commonly receives focus programmatically (the
|
|
287
489
|
// ChipInput moves focus onto one after a removal) and a ring that appears on a
|
|
@@ -372,6 +574,23 @@ interface ChipBaseProps {
|
|
|
372
574
|
id?: string;
|
|
373
575
|
/** A native tooltip. For anything a user must read, prefer the kit's `Tooltip`. */
|
|
374
576
|
title?: string;
|
|
577
|
+
/**
|
|
578
|
+
* Even hairlines on fractional-ratio displays (0.18.0). At a device-pixel ratio of
|
|
579
|
+
* 1.25 — a 125 % Windows display, or 125 % zoom — Chrome draws a chip's two 1px
|
|
580
|
+
* side borders on different sub-pixel phases, one crisp and one a half-strength
|
|
581
|
+
* smear that reads as a thinner line (worst on the 60 %-alpha `danger` border).
|
|
582
|
+
* `snapEdges` widens the chip by under 4px, from where it sits, so the two edges
|
|
583
|
+
* rasterise as mirror images, and centres its content in the extra.
|
|
584
|
+
*
|
|
585
|
+
* Off by default: it reads the chip's position (batched for every snapped chip into
|
|
586
|
+
* one pass a frame, through one shared observer), and the fix is only as fresh as the
|
|
587
|
+
* last pass — it re-snaps on render, resize, zoom and ratio change, but not when
|
|
588
|
+
* something else moves the chip; call {@link refreshChipEdges} then. At an integer
|
|
589
|
+
* ratio, and at 1.5, it writes nothing. Turn it on for status chips that sit in a
|
|
590
|
+
* column and are compared side by side; a chip with no visible border (`dot`) has
|
|
591
|
+
* nothing to snap.
|
|
592
|
+
*/
|
|
593
|
+
snapEdges?: boolean;
|
|
375
594
|
}
|
|
376
595
|
|
|
377
596
|
/**
|
|
@@ -458,6 +677,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
458
677
|
role,
|
|
459
678
|
onKeyDown,
|
|
460
679
|
onFocus,
|
|
680
|
+
snapEdges = false,
|
|
461
681
|
...rest
|
|
462
682
|
},
|
|
463
683
|
ref,
|
|
@@ -476,6 +696,17 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
476
696
|
surfaceOf(tone, variant, selected),
|
|
477
697
|
disabled && "cursor-default opacity-50",
|
|
478
698
|
);
|
|
699
|
+
const snap = snapEdges && !dot;
|
|
700
|
+
const snapRef = useSnapEdges(snap);
|
|
701
|
+
// The visible pill carries the snap: the whole chip, or the wrapper of a split one
|
|
702
|
+
// (whose body gets the forwarded ref instead).
|
|
703
|
+
const ownRef = useCallback(
|
|
704
|
+
(el: HTMLElement | null) => {
|
|
705
|
+
snapRef(el);
|
|
706
|
+
assignRef(ref, el);
|
|
707
|
+
},
|
|
708
|
+
[snapRef, ref],
|
|
709
|
+
);
|
|
479
710
|
const look = cn(
|
|
480
711
|
CHIP_BASE,
|
|
481
712
|
s.body,
|
|
@@ -485,6 +716,8 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
485
716
|
interactive && !disabled && "cursor-pointer hover:brightness-[0.97] dark:hover:brightness-110",
|
|
486
717
|
// An inert dot chip is text in a column: no inset, so it aligns with the header.
|
|
487
718
|
dot && !interactive && "px-0",
|
|
719
|
+
// The under-4px a snap adds is split between the two sides, not stacked at the end.
|
|
720
|
+
snap && "justify-center",
|
|
488
721
|
className,
|
|
489
722
|
);
|
|
490
723
|
|
|
@@ -568,7 +801,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
568
801
|
|
|
569
802
|
// ── The combined shape: one pill, two interactive siblings inside it.
|
|
570
803
|
const pill = (inner: ReactNode) => (
|
|
571
|
-
<span className={cn(CHIP_PILL, s.tail, radius, surface, className)}>
|
|
804
|
+
<span ref={snap ? snapRef : undefined} className={cn(CHIP_PILL, s.tail, radius, surface, snap && "justify-center", className)}>
|
|
572
805
|
{inner}
|
|
573
806
|
{remove}
|
|
574
807
|
</span>
|
|
@@ -591,7 +824,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
591
824
|
const onLinkClick = onClick as ChipLinkProps["onClick"];
|
|
592
825
|
const linkProps: ChipLinkProps = {
|
|
593
826
|
...rest,
|
|
594
|
-
ref: ref as Ref<HTMLAnchorElement>,
|
|
827
|
+
ref: (remove ? ref : ownRef) as Ref<HTMLAnchorElement>,
|
|
595
828
|
href,
|
|
596
829
|
onClick: onLinkClick,
|
|
597
830
|
"aria-current": selected ? "true" : undefined,
|
|
@@ -605,7 +838,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
605
838
|
<RenderedLink render={render} {...linkProps} />
|
|
606
839
|
) : (
|
|
607
840
|
<a
|
|
608
|
-
ref={ref as React.Ref<HTMLAnchorElement>}
|
|
841
|
+
ref={(remove ? ref : ownRef) as React.Ref<HTMLAnchorElement>}
|
|
609
842
|
href={href}
|
|
610
843
|
aria-current={selected ? "true" : undefined}
|
|
611
844
|
onClick={onLinkClick}
|
|
@@ -624,7 +857,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
624
857
|
const onButtonClick = onClick as (event: MouseEvent<HTMLButtonElement>) => void;
|
|
625
858
|
const button = (
|
|
626
859
|
<button
|
|
627
|
-
ref={ref as React.Ref<HTMLButtonElement>}
|
|
860
|
+
ref={(remove ? ref : ownRef) as React.Ref<HTMLButtonElement>}
|
|
628
861
|
type="button"
|
|
629
862
|
onClick={onButtonClick}
|
|
630
863
|
disabled={disabled}
|
|
@@ -650,7 +883,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
650
883
|
the roving-focus hooks, live only when the container passes `role` and `tabIndex`
|
|
651
884
|
(ChipInput does); the role is a prop, so the rule cannot see it. */
|
|
652
885
|
<span
|
|
653
|
-
ref={
|
|
886
|
+
ref={ownRef as React.Ref<HTMLSpanElement>}
|
|
654
887
|
className={look}
|
|
655
888
|
tabIndex={tabIndex}
|
|
656
889
|
role={role}
|
|
@@ -2,8 +2,10 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
|
+
import { typedMatches } from "./danger-confirm";
|
|
6
|
+
import type { TypedMatch } from "./danger-confirm";
|
|
5
7
|
import { DialogFrame } from "./dialog-frame";
|
|
6
|
-
import { Button } from "./ui";
|
|
8
|
+
import { Button, Input } from "./ui";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* The `confirmDialog` namespace of `<UiKitProvider labels>`: the two buttons' fallback
|
|
@@ -13,12 +15,25 @@ import { Button } from "./ui";
|
|
|
13
15
|
export interface ConfirmDialogLabels {
|
|
14
16
|
confirm: string;
|
|
15
17
|
cancel: string;
|
|
18
|
+
/**
|
|
19
|
+
* 0.18: label of the field a `requireTyped` confirm shows. A FUNCTION of the text to
|
|
20
|
+
* type by default — where the address sits in the sentence moves with the language —
|
|
21
|
+
* or a finished STRING. A call's own `typedLabel` wins over it.
|
|
22
|
+
*
|
|
23
|
+
* Required, like every key of a namespace: the shipped catalogues
|
|
24
|
+
* (`@eifi1/ui-kit/i18n/<code>`) translate it, and an app's own full catalogue should
|
|
25
|
+
* hear about the new string from the compiler rather than show it in English.
|
|
26
|
+
*/
|
|
27
|
+
typed: string | ((text: string) => string);
|
|
16
28
|
}
|
|
17
29
|
|
|
18
|
-
|
|
30
|
+
/** `satisfies` rather than a type annotation, so `.typed` stays known-callable for a
|
|
31
|
+
* caller composing its own label from it. */
|
|
32
|
+
export const DEFAULT_CONFIRM_DIALOG_LABELS = {
|
|
19
33
|
confirm: "Confirm",
|
|
20
34
|
cancel: "Cancel",
|
|
21
|
-
}
|
|
35
|
+
typed: (text: string) => `Type “${text}” to confirm`,
|
|
36
|
+
} satisfies ConfirmDialogLabels;
|
|
22
37
|
|
|
23
38
|
export type ConfirmTone = "danger" | "warning" | "neutral";
|
|
24
39
|
|
|
@@ -37,6 +52,30 @@ export interface ConfirmOptions {
|
|
|
37
52
|
* the confirm. See {@link ConfirmProvider} for why the two differ.
|
|
38
53
|
*/
|
|
39
54
|
tone?: ConfirmTone;
|
|
55
|
+
/**
|
|
56
|
+
* 0.18: make the user TYPE this before confirm is enabled — the target account's
|
|
57
|
+
* e-mail address, a budget's name. For the action whose risk is not "did you mean
|
|
58
|
+
* it" but "is this the right row": an admin who deactivates somebody has already
|
|
59
|
+
* proven who THEY are (they passed the admin gate); typing the address is what
|
|
60
|
+
* proves they read which account they are on. Both keksdose (`TYPE_EMAIL`) and
|
|
61
|
+
* Kurvenschmiede (`TypedConfirm`) built this beside `useConfirm` because it had no
|
|
62
|
+
* field.
|
|
63
|
+
*
|
|
64
|
+
* The dialog then shows one text field under the body and focuses it (whatever the
|
|
65
|
+
* tone: a reflexive Enter cannot confirm a field that does not match yet). Confirm
|
|
66
|
+
* stays disabled until the text matches; Enter in the field confirms once it does.
|
|
67
|
+
* The answer is still `true` / `false` — the text matched what you passed, so send
|
|
68
|
+
* that to the server if it asks for the address too, not the user's spelling.
|
|
69
|
+
*/
|
|
70
|
+
requireTyped?: string;
|
|
71
|
+
/** Label of that field. Default: `confirmDialog.typed` — `Type “a@b.c” to confirm`. */
|
|
72
|
+
typedLabel?: string;
|
|
73
|
+
/**
|
|
74
|
+
* How the typed text is compared. Default `"caseless"`: surrounding spaces and case
|
|
75
|
+
* ignored, the rule both apps applied to an address. `"trim"` keeps case, `"exact"`
|
|
76
|
+
* keeps spaces too — for a phrase like "DELETE" whose contract is "exactly this".
|
|
77
|
+
*/
|
|
78
|
+
typedMatch?: TypedMatch;
|
|
40
79
|
}
|
|
41
80
|
|
|
42
81
|
export type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>;
|
|
@@ -201,15 +240,26 @@ function ConfirmDialogView({
|
|
|
201
240
|
// Set by the confirm button, read once the panel has finished lowering. Everything
|
|
202
241
|
// else that ends in `onClose` — Escape, the backdrop, Back, Cancel — leaves it false.
|
|
203
242
|
const accepted = useRef(false);
|
|
243
|
+
const typedRef = useRef<HTMLInputElement>(null);
|
|
244
|
+
const [typed, setTyped] = useState("");
|
|
245
|
+
const target = request.requireTyped;
|
|
246
|
+
const matches = target === undefined || typedMatches(typed, target, request.typedMatch ?? "caseless");
|
|
204
247
|
|
|
205
248
|
// In THIS component's effect, which runs after `Modal`'s own (a parent's effects run
|
|
206
249
|
// after its children's). `Modal` engages its focus trap on the panel first — and
|
|
207
250
|
// records the page's focused element as the restore target — and only then does
|
|
208
251
|
// focus move to the button. Focusing it from inside the panel would run first and
|
|
209
|
-
// make the button the restore target.
|
|
252
|
+
// make the button the restore target. A typed confirm starts in its field: typing is
|
|
253
|
+
// the one thing the dialog is waiting for.
|
|
210
254
|
useEffect(() => {
|
|
211
|
-
(tone === "danger" ? cancelRef : confirmRef).current?.focus();
|
|
212
|
-
}, [tone]);
|
|
255
|
+
(target !== undefined ? typedRef : tone === "danger" ? cancelRef : confirmRef).current?.focus();
|
|
256
|
+
}, [tone, target]);
|
|
257
|
+
|
|
258
|
+
const typedLabel =
|
|
259
|
+
target === undefined
|
|
260
|
+
? ""
|
|
261
|
+
: (request.typedLabel ??
|
|
262
|
+
(typeof labels.typed === "string" ? labels.typed : labels.typed(target)));
|
|
213
263
|
|
|
214
264
|
return (
|
|
215
265
|
<DialogFrame
|
|
@@ -228,6 +278,7 @@ function ConfirmDialogView({
|
|
|
228
278
|
ref={confirmRef}
|
|
229
279
|
type="button"
|
|
230
280
|
variant={tone === "danger" ? "danger" : "primary"}
|
|
281
|
+
disabled={!matches}
|
|
231
282
|
onClick={() => {
|
|
232
283
|
accepted.current = true;
|
|
233
284
|
close();
|
|
@@ -237,6 +288,30 @@ function ConfirmDialogView({
|
|
|
237
288
|
</Button>
|
|
238
289
|
</>
|
|
239
290
|
)}
|
|
240
|
-
|
|
291
|
+
>
|
|
292
|
+
{/* The field, when there is one, is the body; a plain confirm keeps having none. */}
|
|
293
|
+
{target !== undefined && (
|
|
294
|
+
<form
|
|
295
|
+
noValidate
|
|
296
|
+
onSubmit={(e) => {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
// Through the button, so Enter and a click are one path — and a disabled
|
|
299
|
+
// (not yet matching) confirm ignores the click.
|
|
300
|
+
confirmRef.current?.click();
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
<Input
|
|
304
|
+
ref={typedRef}
|
|
305
|
+
label={typedLabel}
|
|
306
|
+
value={typed}
|
|
307
|
+
autoComplete="off"
|
|
308
|
+
autoCapitalize="off"
|
|
309
|
+
autoCorrect="off"
|
|
310
|
+
spellCheck={false}
|
|
311
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
312
|
+
/>
|
|
313
|
+
</form>
|
|
314
|
+
)}
|
|
315
|
+
</DialogFrame>
|
|
241
316
|
);
|
|
242
317
|
}
|