@eifi1/ui-kit 0.24.1 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +6 -6
  2. package/dist/chart.d.ts +2 -2
  3. package/dist/components/account-chips.d.ts +1 -1
  4. package/dist/components/amount-input.d.ts +2 -2
  5. package/dist/components/button-group.d.ts +2 -2
  6. package/dist/components/calculator.d.ts +2 -2
  7. package/dist/components/column-mapper.d.ts +28 -9
  8. package/dist/components/column-mapper.js +63 -77
  9. package/dist/components/column-mapper.js.map +1 -1
  10. package/dist/components/confirm-dialog.d.ts +2 -2
  11. package/dist/components/copy-button.d.ts +2 -2
  12. package/dist/components/danger-confirm.d.ts +2 -2
  13. package/dist/components/data-table-cells.d.ts +1 -1
  14. package/dist/components/data-table-filter-popover.d.ts +1 -1
  15. package/dist/components/data-table-filters.d.ts +1 -1
  16. package/dist/components/data-table-labels.d.ts +1 -1
  17. package/dist/components/data-table-pagination.d.ts +1 -1
  18. package/dist/components/data-table-pagination.js +6 -1
  19. package/dist/components/data-table-pagination.js.map +1 -1
  20. package/dist/components/data-table.d.ts +1 -1
  21. package/dist/components/data-table.js +7 -1
  22. package/dist/components/data-table.js.map +1 -1
  23. package/dist/components/facing-pair.d.ts +2 -2
  24. package/dist/components/file-button.d.ts +2 -2
  25. package/dist/components/file-dropzone.d.ts +2 -2
  26. package/dist/components/form-actions.d.ts +2 -2
  27. package/dist/components/iban-input.d.ts +2 -2
  28. package/dist/components/language-select.d.ts +2 -2
  29. package/dist/components/money-field.d.ts +2 -2
  30. package/dist/components/number-field.d.ts +2 -2
  31. package/dist/components/number-input.d.ts +2 -2
  32. package/dist/components/numpad-sheet.d.ts +2 -2
  33. package/dist/components/phone-input.d.ts +2 -2
  34. package/dist/components/series-chart.d.ts +2 -2
  35. package/dist/components/settings-fields.d.ts +2 -2
  36. package/dist/components/share-card.d.ts +2 -2
  37. package/dist/components/swipeable-row.js +1 -1
  38. package/dist/components/swipeable-row.js.map +1 -1
  39. package/dist/components/table.d.ts +35 -1
  40. package/dist/components/table.js +4 -0
  41. package/dist/components/table.js.map +1 -1
  42. package/dist/components/text-link.d.ts +2 -2
  43. package/dist/components/time-input.d.ts +2 -2
  44. package/dist/components/tooltip.d.ts +103 -14
  45. package/dist/components/tooltip.js +194 -57
  46. package/dist/components/tooltip.js.map +1 -1
  47. package/dist/components/translation-review-editor.d.ts +9 -1
  48. package/dist/components/translation-review-editor.js +11 -1
  49. package/dist/components/translation-review-editor.js.map +1 -1
  50. package/dist/components/translation-review-labels.d.ts +10 -0
  51. package/dist/components/translation-review-labels.js +5 -1
  52. package/dist/components/translation-review-labels.js.map +1 -1
  53. package/dist/components/translation-review.d.ts +145 -8
  54. package/dist/components/translation-review.js +372 -91
  55. package/dist/components/translation-review.js.map +1 -1
  56. package/dist/components/ui.d.ts +2 -2
  57. package/dist/components/ui.js +55 -18
  58. package/dist/components/ui.js.map +1 -1
  59. package/dist/components/use-table-state.d.ts +1 -1
  60. package/dist/{data-table-labels-B7OdnM0S.d.ts → data-table-labels-Cdn1QS2k.d.ts} +21 -1
  61. package/dist/data-table.d.ts +1 -1
  62. package/dist/feedback/feedback-attachment.d.ts +2 -2
  63. package/dist/feedback/feedback-dialog.d.ts +2 -2
  64. package/dist/feedback/feedback-dialog.js +6 -1
  65. package/dist/feedback/feedback-dialog.js.map +1 -1
  66. package/dist/feedback/feedback-inbox.d.ts +2 -2
  67. package/dist/feedback/feedback-inbox.js +2 -0
  68. package/dist/feedback/feedback-inbox.js.map +1 -1
  69. package/dist/feedback/feedback-thread.d.ts +2 -2
  70. package/dist/{feedback-DOwPu-Il.d.ts → feedback-BXKH-ToU.d.ts} +32 -3
  71. package/dist/feedback.d.ts +2 -2
  72. package/dist/hooks/use-body-scroll-lock.js +16 -0
  73. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  74. package/dist/hooks/use-file-drop.d.ts +2 -2
  75. package/dist/hooks/use-search-param-state.d.ts +58 -3
  76. package/dist/hooks/use-search-param-state.js +107 -35
  77. package/dist/hooks/use-search-param-state.js.map +1 -1
  78. package/dist/i18n/defaults.d.ts +2 -2
  79. package/dist/i18n/german.d.ts +2 -2
  80. package/dist/i18n/german.js +8 -1
  81. package/dist/i18n/german.js.map +1 -1
  82. package/dist/i18n/kit-labels.d.ts +2 -2
  83. package/dist/i18n/languages.d.ts +2 -2
  84. package/dist/i18n/locales/de-CH.d.ts +2 -2
  85. package/dist/i18n/locales/en.d.ts +2 -2
  86. package/dist/i18n/locales/en.js +5 -1
  87. package/dist/i18n/locales/en.js.map +1 -1
  88. package/dist/i18n/locales/es.d.ts +2 -2
  89. package/dist/i18n/locales/es.js +7 -1
  90. package/dist/i18n/locales/es.js.map +1 -1
  91. package/dist/i18n/locales/fr.d.ts +2 -2
  92. package/dist/i18n/locales/fr.js +6 -1
  93. package/dist/i18n/locales/fr.js.map +1 -1
  94. package/dist/i18n/locales/hu.d.ts +2 -2
  95. package/dist/i18n/locales/hu.js +9 -1
  96. package/dist/i18n/locales/hu.js.map +1 -1
  97. package/dist/i18n/locales/it.d.ts +2 -2
  98. package/dist/i18n/locales/it.js +6 -1
  99. package/dist/i18n/locales/it.js.map +1 -1
  100. package/dist/i18n/locales/zh.d.ts +2 -2
  101. package/dist/i18n/locales/zh.js +6 -1
  102. package/dist/i18n/locales/zh.js.map +1 -1
  103. package/dist/i18n/review.d.ts +2 -2
  104. package/dist/i18n/review.js +11 -0
  105. package/dist/i18n/review.js.map +1 -1
  106. package/dist/index.d.ts +6 -6
  107. package/dist/index.js +9 -1
  108. package/dist/index.js.map +1 -1
  109. package/dist/lib/strip-fade.d.ts +84 -5
  110. package/dist/lib/strip-fade.js +109 -11
  111. package/dist/lib/strip-fade.js.map +1 -1
  112. package/dist/lib/translation-review.d.ts +45 -1
  113. package/dist/lib/translation-review.js +38 -1
  114. package/dist/lib/translation-review.js.map +1 -1
  115. package/dist/rhf/fields.d.ts +2 -2
  116. package/dist/rhf/form.d.ts +2 -2
  117. package/dist/rhf.d.ts +2 -2
  118. package/dist/shell/app-shell.d.ts +2 -2
  119. package/dist/shell/top-bar-brand.d.ts +2 -2
  120. package/dist/shell.d.ts +2 -2
  121. package/dist/wizard/stepper-nav.d.ts +2 -2
  122. package/dist/wizard.d.ts +2 -2
  123. package/package.json +1 -1
  124. package/src/components/column-mapper.tsx +120 -113
  125. package/src/components/data-table-pagination.tsx +10 -1
  126. package/src/components/data-table.tsx +34 -1
  127. package/src/components/swipeable-row.tsx +1 -1
  128. package/src/components/table.tsx +39 -0
  129. package/src/components/tooltip.tsx +487 -104
  130. package/src/components/translation-review-editor.tsx +21 -1
  131. package/src/components/translation-review-labels.ts +17 -0
  132. package/src/components/translation-review.tsx +657 -109
  133. package/src/components/ui.tsx +104 -4
  134. package/src/feedback/feedback-dialog.tsx +21 -0
  135. package/src/feedback/feedback-inbox.tsx +19 -2
  136. package/src/hooks/use-body-scroll-lock.ts +25 -0
  137. package/src/hooks/use-search-param-state.ts +283 -41
  138. package/src/i18n/german.ts +10 -0
  139. package/src/i18n/locales/en.ts +7 -0
  140. package/src/i18n/locales/es.ts +9 -0
  141. package/src/i18n/locales/fr.ts +9 -0
  142. package/src/i18n/locales/hu.ts +11 -0
  143. package/src/i18n/locales/it.ts +9 -0
  144. package/src/i18n/locales/zh.ts +8 -0
  145. package/src/i18n/review.ts +11 -0
  146. package/src/index.ts +12 -0
  147. package/src/lib/strip-fade.ts +220 -15
  148. package/src/lib/translation-review.ts +85 -0
@@ -1614,7 +1614,10 @@ export function FieldHint({
1614
1614
  ...rest
1615
1615
  }: FieldHintProps) {
1616
1616
  return (
1617
- <Tooltip label={label} side={side} portal>
1617
+ // `tap="toggle"` (0.25): the "?" exists only to explain, so a tap on a phone shows
1618
+ // the bubble and the next tap hides it — the touch rule (no bubble left behind by a
1619
+ // tap) would otherwise make it show nothing at all.
1620
+ <Tooltip label={label} side={side} portal tap="toggle">
1618
1621
  <button
1619
1622
  {...rest}
1620
1623
  // `type` after the spread, not before. These render inside forms — that is the
@@ -2501,6 +2504,72 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2501
2504
  });
2502
2505
  Select.displayName = "Select";
2503
2506
 
2507
+ /**
2508
+ * The lower edge of {@link TEXTAREA_LABEL_STRIP} (0.25, Kurvenschmiede).
2509
+ *
2510
+ * The 0.24 strip ends on a hard line, and a line of text scrolled most of the way under
2511
+ * it left its lower edge showing below that line: in a pasted table, a row of comma
2512
+ * tails between the strip and the first whole line (`0,5;1,9` cut above its baseline is
2513
+ * `, ,`). This layer, hung under the strip, closes that gap. What it draws depends on
2514
+ * how much of the line on its way out is still below the strip ({@link labelEdge}):
2515
+ *
2516
+ * - **Half a line or less** — its descenders, the feet of its letters: covered, solid,
2517
+ * down to exactly where that line's box ends. The strip's bottom snaps to the line
2518
+ * box, so the row of tails is gone and the next line, which starts there, is not
2519
+ * touched.
2520
+ * - **More than half** — a line you can still read: a soft edge, the strip's surface
2521
+ * fading into it over {@link LABEL_EDGE_SOFT_PX}, where 0.24 cut it hard. Never taller
2522
+ * than the part of that line already gone, so it grows from nothing as a line starts
2523
+ * to move and never reaches the next one.
2524
+ * - **Nothing to cover** — at rest, or a line boundary right on the strip's edge:
2525
+ * nothing drawn. An unscrolled first line is never covered (at rest the strip covers
2526
+ * the top padding and nothing else, as in 0.24), and neither is any whole line.
2527
+ *
2528
+ * Why not one of the two alone. A plain fade under the strip, as tall as the tails,
2529
+ * dims the top of the first WHOLE line whenever it sits right under the strip (its
2530
+ * capitals start about 4px into a 20px line), and only half-hides the tails, which sit
2531
+ * in its fading part. A plain snap to the line box would hide a whole line the moment
2532
+ * one starts to scroll. The half-line threshold keeps what is shown legible: a line is
2533
+ * either hidden or shown with most of its letters, never as a row of stray marks.
2534
+ *
2535
+ * Its height and solid part are `--label-strip-edge` / `--label-strip-edge-solid`,
2536
+ * written by {@link TextareaLabelStrip} from the textarea's `scrollTop` and line height
2537
+ * on every scroll; unset (no layout, no script yet) the height is 0 and nothing shows.
2538
+ * A pseudo-element of the strip, so it inherits everything the strip already gets
2539
+ * right: shown only while the label is floated, the same horizontal insets (stopped
2540
+ * short of a classic scrollbar, on either side — RTL included), painted under the
2541
+ * label. Its colour is INHERITED from the strip (`bg-inherit`): `--bg-surface`, and the
2542
+ * disabled / `[readonly]` `--bg-surface-2`, in both themes, with no second copy of
2543
+ * those selectors to keep in step. The soft part is a mask over that colour (alpha
2544
+ * only — the `#000` is the mask's opacity, not a colour on screen).
2545
+ */
2546
+ const TEXTAREA_LABEL_EDGE = cn(
2547
+ "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-[var(--label-strip-edge,0px)] after:content-['']",
2548
+ "after:bg-inherit after:[mask-image:linear-gradient(#000_var(--label-strip-edge-solid,0px),transparent)]",
2549
+ );
2550
+
2551
+ /** The soft edge over a line still more than half in view, in px: "a few pixels" —
2552
+ * enough to take the cut off its letters, little enough to leave them legible. */
2553
+ const LABEL_EDGE_SOFT_PX = 3;
2554
+
2555
+ /**
2556
+ * {@link TEXTAREA_LABEL_EDGE}'s height and solid part, in px, for a field scrolled
2557
+ * `scrollTop` with lines `lineHeight` tall.
2558
+ *
2559
+ * The strip covers the top padding exactly, so line boxes meet its lower edge whenever
2560
+ * `scrollTop` is a whole number of lines; `gone` is how far the line now on its way out
2561
+ * has passed under it, `left` how much of it is still below. A line height that is not
2562
+ * a length (`normal`) gives no line boxes to snap to: only the soft edge then.
2563
+ */
2564
+ function labelEdge(scrollTop: number, lineHeight: number): { height: number; solid: number } {
2565
+ if (!(scrollTop > 0)) return { height: 0, solid: 0 };
2566
+ if (!(lineHeight > 0)) return { height: Math.min(scrollTop, LABEL_EDGE_SOFT_PX), solid: 0 };
2567
+ const gone = scrollTop % lineHeight;
2568
+ const left = gone === 0 ? 0 : lineHeight - gone;
2569
+ if (left > 0 && left <= lineHeight / 2) return { height: left, solid: left };
2570
+ return { height: Math.min(gone, LABEL_EDGE_SOFT_PX), solid: 0 };
2571
+ }
2572
+
2504
2573
  /**
2505
2574
  * The backdrop of a labelled {@link Textarea}'s label strip (Kurvenschmiede, 0.24).
2506
2575
  *
@@ -2542,11 +2611,15 @@ Select.displayName = "Select";
2542
2611
  *
2543
2612
  * The one thing CSS cannot know is a classic scrollbar's width, and a strip across the
2544
2613
  * whole inner width would hide the scrollbar's top arrow — see {@link TextareaLabelStrip}.
2614
+ *
2615
+ * Its lower edge, where a line scrolled half under it used to leave its tails showing,
2616
+ * is {@link TEXTAREA_LABEL_EDGE} (0.25).
2545
2617
  */
2546
2618
  const TEXTAREA_LABEL_STRIP = cn(
2547
2619
  "pointer-events-none absolute inset-x-px top-px hidden h-4 rounded-t-[5px] bg-[var(--bg-surface)]",
2548
2620
  "peer-focus:block peer-[:not(:placeholder-shown)]:block",
2549
2621
  "peer-disabled:bg-[var(--bg-surface-2)] peer-[[readonly]]:bg-[var(--bg-surface-2)]",
2622
+ TEXTAREA_LABEL_EDGE,
2550
2623
  );
2551
2624
 
2552
2625
  /**
@@ -2567,6 +2640,10 @@ const TEXTAREA_LABEL_STRIP = cn(
2567
2640
  * scrollbar comes or goes (it takes the content box with it) and when the field is
2568
2641
  * resized by hand. Not laid out — hidden in a closed panel, or jsdom — the CSS insets
2569
2642
  * stand until it is.
2643
+ *
2644
+ * It also writes the strip's lower edge (0.25, {@link TEXTAREA_LABEL_EDGE}) from the
2645
+ * textarea's scroll position: a passive `scroll` listener, plus the same observer, since
2646
+ * a resize can move the scroll without the user scrolling.
2570
2647
  */
2571
2648
  function TextareaLabelStrip() {
2572
2649
  const ref = useRef<HTMLSpanElement>(null);
@@ -2595,11 +2672,34 @@ function TextareaLabelStrip() {
2595
2672
  strip.style.borderStartEndRadius = bar && atEnd ? "0" : "";
2596
2673
  strip.style.borderStartStartRadius = bar && !atEnd ? "0" : "";
2597
2674
  };
2675
+ // The edge under the strip follows the scroll — see TEXTAREA_LABEL_EDGE. The line
2676
+ // height is read each time, since a caller's class can change the type. Nothing to
2677
+ // draw is no property at all (the CSS default is a 0px edge), so a field at rest
2678
+ // carries exactly the strip it did in 0.24.
2679
+ const follow = () => {
2680
+ const edge = labelEdge(field.scrollTop, parseFloat(getComputedStyle(field).lineHeight));
2681
+ if (edge.height > 0) {
2682
+ strip.style.setProperty("--label-strip-edge", `${edge.height}px`);
2683
+ strip.style.setProperty("--label-strip-edge-solid", `${edge.solid}px`);
2684
+ } else {
2685
+ strip.style.removeProperty("--label-strip-edge");
2686
+ strip.style.removeProperty("--label-strip-edge-solid");
2687
+ }
2688
+ };
2598
2689
  fit();
2599
- if (typeof ResizeObserver === "undefined") return;
2600
- const observer = new ResizeObserver(fit);
2690
+ follow();
2691
+ field.addEventListener("scroll", follow, { passive: true });
2692
+ if (typeof ResizeObserver === "undefined") return () => field.removeEventListener("scroll", follow);
2693
+ // A resize can move the scroll too (a taller field clamps it).
2694
+ const observer = new ResizeObserver(() => {
2695
+ fit();
2696
+ follow();
2697
+ });
2601
2698
  observer.observe(field);
2602
- return () => observer.disconnect();
2699
+ return () => {
2700
+ field.removeEventListener("scroll", follow);
2701
+ observer.disconnect();
2702
+ };
2603
2703
  }, []);
2604
2704
  return <span ref={ref} aria-hidden data-slot="label-strip" className={TEXTAREA_LABEL_STRIP} />;
2605
2705
  }
@@ -1,6 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import { Button, Input, PHONE_QUERY, Select, Textarea } from "../components/ui";
4
+ import type { ButtonSize, ButtonVariant } from "../components/ui";
4
5
  import { Modal } from "../components/modal";
5
6
  import { useMediaQuery } from "../hooks/use-media-query";
6
7
  import { useKitLabels } from "../i18n/kit-labels";
@@ -151,6 +152,21 @@ interface FeedbackDialogBaseProps {
151
152
  * dialog's behaviour until 0.14.2 (keksdose K1: its backend takes title-only reports).
152
153
  */
153
154
  requireBody?: boolean;
155
+ /**
156
+ * The attachment field's add and capture buttons: its `buttonVariant` (0.25.0),
157
+ * Button's own `variant`. Default `"secondary"`, the look they always had.
158
+ *
159
+ * The field took `buttonVariant` / `buttonSize` in 0.24.0 (keksdose's support chat),
160
+ * but the dialog builds its field itself and passed neither, so a host that wanted
161
+ * quieter buttons here — a dialog that already ends in a row of Cancel / Send — had
162
+ * no way to say so short of rebuilding the dialog. Named after the dialog's other
163
+ * attachment options (`attachmentAccept`, `onAttachmentError`) and handed through
164
+ * unchanged, in both `attachments` modes.
165
+ */
166
+ attachmentButtonVariant?: ButtonVariant;
167
+ /** The attachment field's `buttonSize` (0.25.0) — with {@link attachmentButtonVariant}.
168
+ * Default `"md"`; `"sm"` draws the paperclip and camera at 14px. */
169
+ attachmentButtonSize?: ButtonSize;
154
170
  }
155
171
 
156
172
  /** One attachment — a screenshot, a picked or a pasted image; a second replaces
@@ -216,6 +232,8 @@ export function FeedbackDialog(props: FeedbackDialogProps) {
216
232
  maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
217
233
  onCaptureScreenshot,
218
234
  requireBody = true,
235
+ attachmentButtonVariant,
236
+ attachmentButtonSize,
219
237
  } = props;
220
238
  const [title, setTitle] = useState("");
221
239
  const [body, setBody] = useState("");
@@ -299,6 +317,9 @@ export function FeedbackDialog(props: FeedbackDialogProps) {
299
317
  accept: attachmentAccept,
300
318
  maxBytes: maxAttachmentBytes,
301
319
  onCaptureScreenshot,
320
+ // Left undefined they are the field's own defaults (secondary / md).
321
+ buttonVariant: attachmentButtonVariant,
322
+ buttonSize: attachmentButtonSize,
302
323
  // On `document`, not on the panel: the Modal focuses its own panel on open
303
324
  // and traps Tab inside it, so while this dialog is up every paste in the
304
325
  // page is meant for it — including the one made with nothing in
@@ -17,6 +17,7 @@ import {
17
17
  import type { LucideIcon } from "lucide-react";
18
18
  import { cn } from "../lib/cn";
19
19
  import { Button, Textarea } from "../components/ui";
20
+ import type { ButtonSize, ButtonVariant } from "../components/ui";
20
21
  import { Tooltip } from "../components/tooltip";
21
22
  import { FeedbackAttachmentField, type FeedbackAttachmentErrorInfo } from "./feedback-attachment";
22
23
  import type { FeedbackAttachmentLabels } from "./feedback-dialog";
@@ -539,6 +540,8 @@ export function FeedbackNoteEditor({
539
540
  maxBytes={attachment.maxBytes}
540
541
  onError={attachment.onError}
541
542
  onCaptureScreenshot={attachment.onCaptureScreenshot}
543
+ buttonVariant={attachment.buttonVariant}
544
+ buttonSize={attachment.buttonSize}
542
545
  // Within THIS editor's subtree, not on `document`: the editor sits
543
546
  // inline on a page that has other fields, so a paste made in one of
544
547
  // them is meant for that one. The textarea above is where the caret
@@ -560,8 +563,9 @@ export function FeedbackNoteEditor({
560
563
  }
561
564
 
562
565
  /** What {@link FeedbackNoteEditor} needs in order to offer a picture with the
563
- * note: the same four things {@link FeedbackAttachmentField} takes, so the
564
- * reply path is held to the app's own limits rather than to the defaults. */
566
+ * note: the same things {@link FeedbackAttachmentField} takes, so the reply path is
567
+ * held to the app's own limits — and drawn in its own look — rather than the
568
+ * defaults. */
565
569
  export interface FeedbackNoteAttachment {
566
570
  labels: FeedbackAttachmentLabels;
567
571
  accept?: string[];
@@ -569,6 +573,19 @@ export interface FeedbackNoteAttachment {
569
573
  /** `info` (0.23.0) names the refused file and the limit. */
570
574
  onError?: (kind: "type" | "size", info: FeedbackAttachmentErrorInfo) => void;
571
575
  onCaptureScreenshot?: () => Promise<File | null>;
576
+ /**
577
+ * The add and capture buttons' look (0.25.0) — the field's `buttonVariant` (0.24.0),
578
+ * Button's own `variant`. Default `"secondary"`, as before.
579
+ *
580
+ * The note editor sits inline in a triage panel, and its buttons end in a ghost
581
+ * Cancel and a brand Save: two bordered full-size attachment buttons above them read
582
+ * as a second form. The field could already be told otherwise; the editor builds it
583
+ * itself and had nowhere to say so.
584
+ */
585
+ buttonVariant?: ButtonVariant;
586
+ /** The add and capture buttons' size (0.25.0) — the field's `buttonSize`. Default
587
+ * `"md"`; `"sm"` draws their icons at 14px. */
588
+ buttonSize?: ButtonSize;
572
589
  }
573
590
 
574
591
  /**
@@ -24,6 +24,19 @@ import { useEffect } from "react";
24
24
  let lockCount = 0;
25
25
  let previousOverflow = "";
26
26
  let previousPaddingRight = "";
27
+ let previousRootOverflow: { x: string; y: string } | null = null;
28
+
29
+ /**
30
+ * Whether `overflow` on <body> reaches the viewport. It does only while <html>'s own
31
+ * overflow is `visible` on both axes; once an app clips or hides <html> (keksdose's
32
+ * `html, body { overflow-x: clip }` guard, 0.25), the body's `hidden` stays on the
33
+ * body and the page behind a "locked" dialog scrolls on (measured: the wheel moved
34
+ * 800px). Then <html> is locked too.
35
+ */
36
+ function rootTakesOverflow(): boolean {
37
+ const style = getComputedStyle(document.documentElement);
38
+ return style.overflowX !== "visible" || style.overflowY !== "visible";
39
+ }
27
40
 
28
41
  /**
29
42
  * How much width the scrollbar of the DOCUMENT is currently taking.
@@ -58,6 +71,13 @@ function acquire(): void {
58
71
  previousOverflow = document.body.style.overflow;
59
72
  previousPaddingRight = document.body.style.paddingRight;
60
73
  document.body.style.overflow = "hidden";
74
+ if (rootTakesOverflow()) {
75
+ // Per axis, so an app's own inline `overflow-x` comes back as it was.
76
+ const root = document.documentElement.style;
77
+ previousRootOverflow = { x: root.overflowX, y: root.overflowY };
78
+ root.overflowX = "hidden";
79
+ root.overflowY = "hidden";
80
+ }
61
81
  if (gap > 0) {
62
82
  // Added to whatever the body already had rather than assigned, so a consumer
63
83
  // that sets its own padding keeps it.
@@ -74,8 +94,13 @@ function release(): void {
74
94
  if (lockCount === 0) {
75
95
  document.body.style.overflow = previousOverflow;
76
96
  document.body.style.paddingRight = previousPaddingRight;
97
+ if (previousRootOverflow !== null) {
98
+ document.documentElement.style.overflowX = previousRootOverflow.x;
99
+ document.documentElement.style.overflowY = previousRootOverflow.y;
100
+ }
77
101
  previousOverflow = "";
78
102
  previousPaddingRight = "";
103
+ previousRootOverflow = null;
79
104
  }
80
105
  }
81
106
 
@@ -1,4 +1,6 @@
1
- import { useLocation, useNavigate, useSearchParams } from "react-router";
1
+ import { createContext, useContext, useMemo, type Context } from "react";
2
+ import * as ReactRouter from "react-router";
3
+ import { useLocation, useNavigate, useSearchParams, type Location, type NavigateFunction } from "react-router";
2
4
 
3
5
  type Updater<T> = T | ((prev: T) => T);
4
6
 
@@ -24,6 +26,9 @@ export interface SearchParamStateOptions<T> {
24
26
  * which are not steps anyone wants Back to walk through.
25
27
  * - `"replace-on-clear"`: push when a value is set, replace when it is cleared (the
26
28
  * "open a form, close it" pattern).
29
+ *
30
+ * Several writes in one tick (one handler) add at most ONE entry — see
31
+ * {@link useSearchParamState}.
27
32
  */
28
33
  history?: "push" | "replace" | "replace-on-clear";
29
34
  }
@@ -37,6 +42,186 @@ function carriedState(state: unknown): Record<string, unknown> | undefined {
37
42
  return state && typeof state === "object" ? (state as Record<string, unknown>) : undefined;
38
43
  }
39
44
 
45
+ /* ── One param: read and write ───────────────────────────────────────────── */
46
+
47
+ /** One param's rules — the single hook's arguments, or one field of the multi-key hook. */
48
+ interface ParamRule<T> {
49
+ key: string;
50
+ defaultValue: T;
51
+ parse?: (raw: string) => T | undefined;
52
+ serialize: (value: T) => string | null;
53
+ history: "push" | "replace" | "replace-on-clear";
54
+ }
55
+
56
+ function paramRule<T>(key: string, defaultValue: T, options: SearchParamStateOptions<T>): ParamRule<T> {
57
+ return {
58
+ key,
59
+ defaultValue,
60
+ parse: options.parse,
61
+ serialize: options.serialize ?? (defaultSerialize as (value: T) => string | null),
62
+ history: options.history ?? (options.replace ? "replace" : "push"),
63
+ };
64
+ }
65
+
66
+ /** An absent or unparseable param reads as the default. */
67
+ function readParam<T>(params: URLSearchParams, rule: ParamRule<T>): T {
68
+ const raw = params.get(rule.key);
69
+ if (raw === null) return rule.defaultValue;
70
+ if (!rule.parse) return raw as unknown as T;
71
+ try {
72
+ const parsed = rule.parse(raw);
73
+ return parsed === undefined ? rule.defaultValue : parsed;
74
+ } catch {
75
+ return rule.defaultValue;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Writes `value` into `params` — the default removes the param. Returns `null` when
81
+ * the param already says that (a write that changes nothing has no say in the history),
82
+ * otherwise whether this change asks for an entry of its own.
83
+ */
84
+ function putParam<T>(params: URLSearchParams, rule: ParamRule<T>, value: T): boolean | null {
85
+ const serialized = rule.serialize(value);
86
+ const next = serialized === rule.serialize(rule.defaultValue) ? null : serialized;
87
+ const current = params.getAll(rule.key);
88
+ if (next === null ? current.length === 0 : current.length === 1 && current[0] === next) return null;
89
+ if (next === null) params.delete(rule.key);
90
+ else params.set(rule.key, next);
91
+ return rule.history === "push" || (rule.history === "replace-on-clear" && next !== null);
92
+ }
93
+
94
+ /* ── Writes that compose ─────────────────────────────────────────────────── */
95
+
96
+ /**
97
+ * The query as the writes made against ONE rendered location leave it — shared by every
98
+ * hook on that router, so a write builds on the one before it instead of on the render.
99
+ *
100
+ * Why this exists (keksdose live #378): react-router's `setSearchParams(updater)` hands
101
+ * its updater `new URLSearchParams(searchParams)` — the params of the RENDER the setter
102
+ * came from, in react-router 7 and 8 alike — not the previous call's result. keksdose
103
+ * wired TranslationReviewPanel's `onFilterChange` to four of these setters (status,
104
+ * source, ns, q); each started from the rendered URL, so the last wrote the old values
105
+ * of the other three back and no filter select had any effect.
106
+ *
107
+ * Keyed by react-router's location object, which is one object per committed location
108
+ * per router: every hook under a router reads the same one, a second router (another
109
+ * test) has its own, and when react-router renders the next location this record is
110
+ * unreachable — nothing queued outlives the navigation it belongs to. Until then a
111
+ * later write builds on it even a tick on (a transition or a data router's loaders still
112
+ * pending), which is what keeps two quick clicks from clobbering each other either. The
113
+ * one cost: a navigation a data router's blocker stops leaves its writes here, and the
114
+ * page's next write carries them again — the blocker asks again.
115
+ */
116
+ interface QueuedQuery {
117
+ /** The query with every write against this location so far. */
118
+ params: URLSearchParams;
119
+ /** The route state of the entry those writes now sit on, carried over a replace. */
120
+ state: unknown;
121
+ /** A write in the current tick pushed; the tick's later writes rewrite that entry. */
122
+ pushedThisTick: boolean;
123
+ /** The last navigation sent was a push. */
124
+ lastPushed: boolean;
125
+ }
126
+
127
+ const queuedByLocation = new WeakMap<Location, QueuedQuery>();
128
+
129
+ /**
130
+ * The data router's own state, read for ONE question: is a navigation still loading?
131
+ * A data router whose loaders run commits a navigation only when they finish, and a
132
+ * second `navigate` before then cancels the first — so a push followed by a replace
133
+ * in the same tick would land as a replace alone. `UNSAFE_DataRouterContext` is in
134
+ * react-router 6.4 through 8; read off the namespace with a stand-in, a release
135
+ * without it costs only that refinement, not the import.
136
+ */
137
+ type DataRouterProbe = { router?: { state?: { navigation?: { state?: string } } } } | null;
138
+ const NO_DATA_ROUTER = createContext<DataRouterProbe>(null);
139
+ const DataRouterContext: Context<DataRouterProbe> = readDataRouterContext();
140
+
141
+ /**
142
+ * A static member read (so a bundler still tree-shakes the namespace), inside a `try`:
143
+ * a test's `vi.mock("react-router", () => ({ … }))` without the original module throws
144
+ * on ANY export it does not define, and it threw here at import (keksdose, 0.25.0) —
145
+ * the hook then runs without the refinement, as on a router without the context.
146
+ */
147
+ function readDataRouterContext(): Context<DataRouterProbe> {
148
+ try {
149
+ return (
150
+ (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??
151
+ NO_DATA_ROUTER
152
+ );
153
+ } catch {
154
+ return NO_DATA_ROUTER;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * One write: `change` edits the queued query in place and says whether a param it
160
+ * changed asks for its own entry. The navigation goes out at once — a lone write
161
+ * behaves exactly as before, and a test can assert right after `act` — carrying the
162
+ * whole queued query, so it includes every write before it.
163
+ *
164
+ * History, per tick (the writes one handler makes before a microtask runs):
165
+ * - the FIRST write that asks to push pushes; every later write in the tick REPLACES
166
+ * that entry, so a handler that sets four params adds one entry, and Back undoes
167
+ * all four at once — they were one change;
168
+ * - a write that asks to replace, before any push, rewrites the entry it found;
169
+ * - so mixed modes combine as "push if any write asked to push", which keeps both
170
+ * promises: a push-mode param's change is undone by Back, and a replace-mode param
171
+ * never adds an entry of its own (it rides on the one the push made). A replace
172
+ * write ahead of a push stays on the entry it rewrote, as it would one tick apart.
173
+ * - a write that changes nothing has no say, and sends nothing.
174
+ */
175
+ function writeQuery(
176
+ location: Location,
177
+ navigate: NavigateFunction,
178
+ dataRouter: DataRouterProbe,
179
+ change: (params: URLSearchParams) => boolean,
180
+ ): void {
181
+ let queued = queuedByLocation.get(location);
182
+ if (!queued) {
183
+ queued = {
184
+ params: new URLSearchParams(location.search),
185
+ state: location.state,
186
+ pushedThisTick: false,
187
+ lastPushed: false,
188
+ };
189
+ queuedByLocation.set(location, queued);
190
+ }
191
+ const before = queued.params.toString();
192
+ const wantsPush = change(queued.params);
193
+ const search = queued.params.toString();
194
+ if (search === before) return;
195
+
196
+ // A data router still loading our push has not written it to the history yet: a
197
+ // replace now would cancel it. Push again — the new navigation takes its place.
198
+ const loading = (dataRouter?.router?.state?.navigation?.state ?? "idle") !== "idle";
199
+ const push = (queued.lastPushed && loading) || (wantsPush && !queued.pushedThisTick);
200
+ if (push) {
201
+ queued.state = null;
202
+ if (!queued.pushedThisTick) {
203
+ queued.pushedThisTick = true;
204
+ const record = queued;
205
+ queueMicrotask(() => {
206
+ record.pushedThisTick = false;
207
+ });
208
+ }
209
+ }
210
+ queued.lastPushed = push;
211
+ void navigate(`?${search}`, push ? undefined : { replace: true, state: carriedState(queued.state) });
212
+ }
213
+
214
+ /** The rendered query, and a write that composes with every other one on the router. */
215
+ function useQueryWriter(): [URLSearchParams, (change: (params: URLSearchParams) => boolean) => void] {
216
+ const location = useLocation();
217
+ const navigate = useNavigate();
218
+ const dataRouter = useContext(DataRouterContext);
219
+ const params = useMemo(() => new URLSearchParams(location.search), [location.search]);
220
+ return [params, (change) => writeQuery(location, navigate, dataRouter, change)];
221
+ }
222
+
223
+ /* ── The hooks ───────────────────────────────────────────────────────────── */
224
+
40
225
  /**
41
226
  * One URL search param as React state: `const [view, setView] = useSearchParamState("view", "list")`.
42
227
  *
@@ -49,8 +234,17 @@ function carriedState(state: unknown): Record<string, unknown> | undefined {
49
234
  * * an absent or unparseable param reads as the default, and
50
235
  * * setting the default removes the param — the default view is the clean URL.
51
236
  *
52
- * Every other param in the query string is left as it is. Two updates in one tick do
53
- * not compose — react-router resolves each against the params of the last render.
237
+ * Every other param in the query string is left as it is.
238
+ *
239
+ * **Setters compose** (0.25, keksdose live #378): setters called in a row — of one hook,
240
+ * or of several hooks under the same router — each build on the write before them, so
241
+ * `setStatus("open"); setNs("legal")` in one handler leaves both in the URL. react-
242
+ * router's own `setSearchParams(updater)` does not: it hands every updater the params of
243
+ * the render, so the last call used to write the others' old values back. The tick's
244
+ * writes add at most one history entry; see `history`. When the params belong
245
+ * together, {@link useSearchParamsState} writes them with one setter. A functional
246
+ * update receives the value as the writes before it left it.
247
+ *
54
248
  * Requires a react-router Router.
55
249
  */
56
250
  export function useSearchParamState<T = string>(
@@ -58,49 +252,97 @@ export function useSearchParamState<T = string>(
58
252
  defaultValue: T,
59
253
  options: SearchParamStateOptions<T> = {},
60
254
  ): [T, (next: Updater<T>) => void] {
61
- const { parse, serialize = defaultSerialize as (value: T) => string | null } = options;
62
- const history = options.history ?? (options.replace ? "replace" : "push");
63
- const [params, setParams] = useSearchParams();
64
- const location = useLocation();
255
+ const [params, write] = useQueryWriter();
256
+ const rule = paramRule(key, defaultValue, options);
257
+ const value = readParam(params, rule);
65
258
 
66
- const read = (raw: string | null): T => {
67
- if (raw === null) return defaultValue;
68
- if (!parse) return raw as unknown as T;
69
- try {
70
- const parsed = parse(raw);
71
- return parsed === undefined ? defaultValue : parsed;
72
- } catch {
73
- return defaultValue;
74
- }
75
- };
76
- const value = read(params.get(key));
77
- const defaultSerialized = serialize(defaultValue);
78
- const toParam = (v: T): string | null => {
79
- const s = serialize(v);
80
- return s === defaultSerialized ? null : s;
81
- };
82
-
83
- const setValue = (next: Updater<T>) => {
84
- // Resolved against the rendered value only to choose the history mode; the write
85
- // resolves again inside react-router's updater, against the params it hands over.
86
- const preview = typeof next === "function" ? (next as (prev: T) => T)(value) : next;
87
- const replace = history === "replace" || (history === "replace-on-clear" && toParam(preview) === null);
88
- setParams(
89
- (prev) => {
90
- const p = new URLSearchParams(prev);
91
- const resolved = typeof next === "function" ? (next as (prev: T) => T)(read(p.get(key))) : next;
92
- const serialized = toParam(resolved);
93
- if (serialized === null) p.delete(key);
94
- else p.set(key, serialized);
95
- return p;
96
- },
97
- replace ? { replace, state: carriedState(location.state) } : undefined,
98
- );
99
- };
259
+ const setValue = (next: Updater<T>) =>
260
+ write((query) => {
261
+ // Resolved against the queued query, not the render: a second update in the same
262
+ // handler sees the first one's result, as React's own functional updates do.
263
+ const resolved = typeof next === "function" ? (next as (prev: T) => T)(readParam(query, rule)) : next;
264
+ return putParam(query, rule, resolved) === true;
265
+ });
100
266
 
101
267
  return [value, setValue];
102
268
  }
103
269
 
270
+ /** One field of {@link useSearchParamsState}: its default, and the single hook's options. */
271
+ export interface SearchParamField<T> extends SearchParamStateOptions<T> {
272
+ /** The value when the param is absent or does not parse; writing it removes the param. */
273
+ default: T;
274
+ /** The param's name in the URL. Default: the field's name (`namespace: { param: "ns" }`). */
275
+ param?: string;
276
+ }
277
+
278
+ /** The fields of {@link useSearchParamsState}, one per key of the value object. */
279
+ export type SearchParamFields<V> = { [K in keyof V]: SearchParamField<V[K]> };
280
+
281
+ /** A partial value (fields left out — or `undefined` — stay as they are), or a function
282
+ * of the current value returning one. */
283
+ export type SearchParamsUpdate<V> = Partial<V> | ((prev: V) => Partial<V>);
284
+
285
+ /**
286
+ * Several URL search params as ONE state object, with one setter that writes them in
287
+ * one navigation:
288
+ *
289
+ * const [filter, setFilter] = useSearchParamsState({
290
+ * status: { default: "all" as ReviewStatus | "all", parse: toStatus },
291
+ * namespace: { param: "ns", default: "all" },
292
+ * query: { param: "q", default: "", history: "replace" },
293
+ * });
294
+ * <TranslationReviewPanel filter={filter} onFilterChange={setFilter} />
295
+ *
296
+ * keksdose live #378: a filter bar reports all its filters on every change, and four
297
+ * single-param setters in a row clobbered each other (see {@link useSearchParamState},
298
+ * whose setters now compose too). This is the shape that never could: one setter, one
299
+ * write, every field's rules in one place.
300
+ *
301
+ * Each field takes what the single hook takes — `parse`, `serialize`, `history` /
302
+ * `replace` — and its `default` (the clean URL, as there) and optional `param` name.
303
+ * The setter takes a partial object or a function of the current one; fields it leaves
304
+ * out, or sets to `undefined`, stay as they are, and keys that are not fields are
305
+ * ignored, so a component's whole filter object can be handed over as it is. Its
306
+ * history is the single hook's rule over the fields that change: one entry, pushed if
307
+ * any of them asks to push, else the current entry rewritten. Setting what the URL
308
+ * already says navigates nowhere.
309
+ *
310
+ * The value object keeps its identity while its params do not change, so it can sit in
311
+ * a dependency array. Requires a react-router Router.
312
+ */
313
+ export function useSearchParamsState<V extends object>(
314
+ fields: SearchParamFields<V>,
315
+ ): [V, (next: SearchParamsUpdate<V>) => void] {
316
+ const [params, write] = useQueryWriter();
317
+ const rules = (Object.keys(fields) as Array<keyof V & string>).map((name) => {
318
+ const field = fields[name];
319
+ return [name, paramRule(field.param ?? name, field.default, field)] as const;
320
+ });
321
+ const readAll = (query: URLSearchParams): V =>
322
+ Object.fromEntries(rules.map(([name, rule]) => [name, readParam(query, rule)])) as V;
323
+
324
+ // The fields' own params, not the whole query: another param changing (a tab, a
325
+ // dialog) leaves this object as it was. `fields` is a fresh literal every render;
326
+ // what it reads is the same while the params and the field names are.
327
+ const ownParams = JSON.stringify(rules.map(([name, rule]) => [name, params.getAll(rule.key)]));
328
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on what the value is read from; see above
329
+ const values = useMemo(() => readAll(params), [ownParams]);
330
+
331
+ const setValues = (next: SearchParamsUpdate<V>) =>
332
+ write((query) => {
333
+ const patch = typeof next === "function" ? next(readAll(query)) : next;
334
+ let push = false;
335
+ for (const [name, rule] of rules) {
336
+ const value = patch[name];
337
+ if (value === undefined) continue;
338
+ if (putParam(query, rule as ParamRule<unknown>, value) === true) push = true;
339
+ }
340
+ return push;
341
+ });
342
+
343
+ return [values, setValues];
344
+ }
345
+
104
346
  /**
105
347
  * The active tab in `?tab=` (or `key`), with the default tab as the clean URL, for the
106
348
  * kit's `Tabs`: