@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.
Files changed (186) hide show
  1. package/README.md +34 -9
  2. package/dist/chart.d.ts +10 -1
  3. package/dist/components/account-chips.d.ts +153 -0
  4. package/dist/components/account-chips.js +135 -0
  5. package/dist/components/account-chips.js.map +1 -0
  6. package/dist/components/account-settings-labels.d.ts +10 -2
  7. package/dist/components/account-settings-labels.js +3 -0
  8. package/dist/components/account-settings-labels.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +18 -1
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/button-group.d.ts +10 -1
  12. package/dist/components/calculator.d.ts +10 -1
  13. package/dist/components/chip.d.ts +25 -1
  14. package/dist/components/chip.js +143 -6
  15. package/dist/components/chip.js.map +1 -1
  16. package/dist/components/confirm-dialog.d.ts +42 -1
  17. package/dist/components/confirm-dialog.js +36 -5
  18. package/dist/components/confirm-dialog.js.map +1 -1
  19. package/dist/components/copy-button.d.ts +10 -1
  20. package/dist/components/danger-confirm.d.ts +55 -4
  21. package/dist/components/danger-confirm.js +67 -30
  22. package/dist/components/danger-confirm.js.map +1 -1
  23. package/dist/components/data-table-cells.d.ts +1 -1
  24. package/dist/components/data-table-filter-popover.d.ts +1 -1
  25. package/dist/components/data-table-filters.d.ts +1 -1
  26. package/dist/components/data-table.d.ts +1 -1
  27. package/dist/components/data-table.js +93 -3
  28. package/dist/components/data-table.js.map +1 -1
  29. package/dist/components/facing-pair.d.ts +10 -1
  30. package/dist/components/file-button.d.ts +10 -1
  31. package/dist/components/file-dropzone.d.ts +10 -1
  32. package/dist/components/form-actions.d.ts +10 -1
  33. package/dist/components/form-actions.js +35 -1
  34. package/dist/components/form-actions.js.map +1 -1
  35. package/dist/components/line-items.d.ts +30 -6
  36. package/dist/components/line-items.js +77 -33
  37. package/dist/components/line-items.js.map +1 -1
  38. package/dist/components/list.d.ts +13 -1
  39. package/dist/components/list.js.map +1 -1
  40. package/dist/components/money-field.d.ts +10 -1
  41. package/dist/components/number-field.d.ts +11 -2
  42. package/dist/components/number-field.js +23 -22
  43. package/dist/components/number-field.js.map +1 -1
  44. package/dist/components/number-input.d.ts +10 -1
  45. package/dist/components/numpad-sheet.d.ts +10 -1
  46. package/dist/components/passkeys-setting.d.ts +27 -1
  47. package/dist/components/passkeys-setting.js +31 -5
  48. package/dist/components/passkeys-setting.js.map +1 -1
  49. package/dist/components/progress-bar.d.ts +25 -1
  50. package/dist/components/progress-bar.js +21 -4
  51. package/dist/components/progress-bar.js.map +1 -1
  52. package/dist/components/reauth-dialog.d.ts +90 -0
  53. package/dist/components/reauth-dialog.js +158 -0
  54. package/dist/components/reauth-dialog.js.map +1 -0
  55. package/dist/components/series-chart.d.ts +10 -1
  56. package/dist/components/server-wake.d.ts +58 -0
  57. package/dist/components/server-wake.js +58 -0
  58. package/dist/components/server-wake.js.map +1 -0
  59. package/dist/components/settings-fields.d.ts +10 -1
  60. package/dist/components/share-card.d.ts +61 -0
  61. package/dist/components/share-card.js +350 -0
  62. package/dist/components/share-card.js.map +1 -0
  63. package/dist/components/signed-amount.d.ts +12 -3
  64. package/dist/components/signed-amount.js +6 -4
  65. package/dist/components/signed-amount.js.map +1 -1
  66. package/dist/components/status-dot.d.ts +2 -0
  67. package/dist/components/text-link.d.ts +10 -1
  68. package/dist/components/time-input.d.ts +10 -1
  69. package/dist/components/toast.d.ts +10 -1
  70. package/dist/components/toast.js +42 -2
  71. package/dist/components/toast.js.map +1 -1
  72. package/dist/components/toggle-group.js +5 -2
  73. package/dist/components/toggle-group.js.map +1 -1
  74. package/dist/components/ui.d.ts +10 -1
  75. package/dist/components/ui.js +67 -30
  76. package/dist/components/ui.js.map +1 -1
  77. package/dist/components/use-table-state.d.ts +1 -1
  78. package/dist/components/user-avatar.d.ts +2 -0
  79. package/dist/components/write-lock.d.ts +68 -0
  80. package/dist/components/write-lock.js +33 -0
  81. package/dist/components/write-lock.js.map +1 -0
  82. package/dist/{data-table-CfVXtCeG.d.ts → data-table-3mi2R3so.d.ts} +59 -1
  83. package/dist/data-table.d.ts +1 -1
  84. package/dist/hooks/use-file-drop.d.ts +10 -1
  85. package/dist/hooks/use-overlay-history.js +16 -0
  86. package/dist/hooks/use-overlay-history.js.map +1 -1
  87. package/dist/i18n/defaults.d.ts +10 -1
  88. package/dist/i18n/defaults.js +11 -1
  89. package/dist/i18n/defaults.js.map +1 -1
  90. package/dist/i18n/german.d.ts +78 -0
  91. package/dist/i18n/{locales/de.js → german.js} +59 -9
  92. package/dist/i18n/german.js.map +1 -0
  93. package/dist/i18n/kit-labels.d.ts +10 -1
  94. package/dist/i18n/kit-labels.js +5 -0
  95. package/dist/i18n/kit-labels.js.map +1 -1
  96. package/dist/i18n/locales/de-CH.d.ts +23 -8
  97. package/dist/i18n/locales/de-CH.js +7 -3
  98. package/dist/i18n/locales/de-CH.js.map +1 -1
  99. package/dist/i18n/locales/{de.d.ts → en.d.ts} +24 -10
  100. package/dist/i18n/locales/en.js +95 -0
  101. package/dist/i18n/locales/en.js.map +1 -0
  102. package/dist/i18n/locales/es.d.ts +10 -1
  103. package/dist/i18n/locales/es.js +51 -1
  104. package/dist/i18n/locales/es.js.map +1 -1
  105. package/dist/i18n/locales/fr.d.ts +10 -1
  106. package/dist/i18n/locales/fr.js +51 -1
  107. package/dist/i18n/locales/fr.js.map +1 -1
  108. package/dist/i18n/locales/hu.d.ts +10 -1
  109. package/dist/i18n/locales/hu.js +54 -1
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.d.ts +10 -1
  112. package/dist/i18n/locales/it.js +51 -1
  113. package/dist/i18n/locales/it.js.map +1 -1
  114. package/dist/i18n/locales/zh.d.ts +10 -1
  115. package/dist/i18n/locales/zh.js +50 -1
  116. package/dist/i18n/locales/zh.js.map +1 -1
  117. package/dist/i18n/swiss.d.ts +3 -3
  118. package/dist/i18n/swiss.js.map +1 -1
  119. package/dist/index.d.ts +11 -6
  120. package/dist/index.js +33 -1
  121. package/dist/index.js.map +1 -1
  122. package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-CPbsjlXr.d.ts} +282 -10
  123. package/dist/lib/server-wake.d.ts +130 -0
  124. package/dist/lib/server-wake.js +121 -0
  125. package/dist/lib/server-wake.js.map +1 -0
  126. package/dist/rhf/fields.d.ts +10 -1
  127. package/dist/rhf/form.d.ts +10 -1
  128. package/dist/rhf/line-items.js.map +1 -1
  129. package/dist/rhf.d.ts +10 -1
  130. package/dist/search/global-search.d.ts +24 -4
  131. package/dist/search/global-search.js +31 -3
  132. package/dist/search/global-search.js.map +1 -1
  133. package/dist/search.d.ts +1 -1
  134. package/dist/shell/app-shell.d.ts +10 -1
  135. package/dist/shell/top-bar-brand.d.ts +10 -1
  136. package/dist/shell/topbar-action-menu.d.ts +2 -0
  137. package/dist/shell.d.ts +10 -1
  138. package/dist/wizard/stepper-nav.d.ts +10 -1
  139. package/dist/wizard.d.ts +10 -1
  140. package/package.json +1 -1
  141. package/src/components/account-chips.tsx +302 -0
  142. package/src/components/account-settings-labels.ts +16 -2
  143. package/src/components/amount-input.tsx +8 -0
  144. package/src/components/chip.tsx +239 -6
  145. package/src/components/confirm-dialog.tsx +82 -7
  146. package/src/components/danger-confirm.tsx +118 -33
  147. package/src/components/data-table.tsx +185 -3
  148. package/src/components/form-actions.tsx +93 -4
  149. package/src/components/line-items.tsx +116 -40
  150. package/src/components/list.tsx +13 -1
  151. package/src/components/number-field.tsx +32 -26
  152. package/src/components/passkeys-setting.tsx +68 -7
  153. package/src/components/progress-bar.tsx +40 -4
  154. package/src/components/reauth-dialog.tsx +273 -0
  155. package/src/components/server-wake.tsx +124 -0
  156. package/src/components/share-card.tsx +615 -0
  157. package/src/components/signed-amount.tsx +16 -5
  158. package/src/components/toast.tsx +72 -2
  159. package/src/components/toggle-group.tsx +5 -2
  160. package/src/components/ui.tsx +145 -34
  161. package/src/components/write-lock.tsx +96 -0
  162. package/src/hooks/use-overlay-history.ts +51 -0
  163. package/src/i18n/defaults.ts +10 -0
  164. package/src/i18n/{locales/de.ts → german.ts} +68 -13
  165. package/src/i18n/kit-labels.tsx +22 -2
  166. package/src/i18n/locales/de-CH.ts +17 -8
  167. package/src/i18n/locales/en.ts +116 -0
  168. package/src/i18n/locales/es.ts +51 -0
  169. package/src/i18n/locales/fr.ts +52 -0
  170. package/src/i18n/locales/hu.ts +55 -0
  171. package/src/i18n/locales/it.ts +51 -0
  172. package/src/i18n/locales/zh.ts +50 -0
  173. package/src/i18n/swiss.ts +3 -3
  174. package/src/index.ts +23 -1
  175. package/src/lib/server-wake.ts +270 -0
  176. package/src/rhf/line-items.tsx +2 -1
  177. package/src/search/global-search.tsx +59 -6
  178. package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
  179. package/dist/i18n/locales/de-CH-informal.js +0 -8
  180. package/dist/i18n/locales/de-CH-informal.js.map +0 -1
  181. package/dist/i18n/locales/de-informal.d.ts +0 -82
  182. package/dist/i18n/locales/de-informal.js +0 -37
  183. package/dist/i18n/locales/de-informal.js.map +0 -1
  184. package/dist/i18n/locales/de.js.map +0 -1
  185. package/src/i18n/locales/de-CH-informal.ts +0 -10
  186. 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
@@ -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={ref as React.Ref<HTMLSpanElement>}
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
- export const DEFAULT_CONFIRM_DIALOG_LABELS: ConfirmDialogLabels = {
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
  }