@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.
- package/README.md +6 -6
- package/dist/chart.d.ts +2 -2
- package/dist/components/account-chips.d.ts +1 -1
- package/dist/components/amount-input.d.ts +2 -2
- package/dist/components/button-group.d.ts +2 -2
- package/dist/components/calculator.d.ts +2 -2
- package/dist/components/column-mapper.d.ts +28 -9
- package/dist/components/column-mapper.js +63 -77
- package/dist/components/column-mapper.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +2 -2
- package/dist/components/copy-button.d.ts +2 -2
- package/dist/components/danger-confirm.d.ts +2 -2
- 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-labels.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +1 -1
- package/dist/components/data-table-pagination.js +6 -1
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +7 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -2
- package/dist/components/file-button.d.ts +2 -2
- package/dist/components/file-dropzone.d.ts +2 -2
- package/dist/components/form-actions.d.ts +2 -2
- package/dist/components/iban-input.d.ts +2 -2
- package/dist/components/language-select.d.ts +2 -2
- package/dist/components/money-field.d.ts +2 -2
- package/dist/components/number-field.d.ts +2 -2
- package/dist/components/number-input.d.ts +2 -2
- package/dist/components/numpad-sheet.d.ts +2 -2
- package/dist/components/phone-input.d.ts +2 -2
- package/dist/components/series-chart.d.ts +2 -2
- package/dist/components/settings-fields.d.ts +2 -2
- package/dist/components/share-card.d.ts +2 -2
- package/dist/components/swipeable-row.js +1 -1
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/table.d.ts +35 -1
- package/dist/components/table.js +4 -0
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +2 -2
- package/dist/components/time-input.d.ts +2 -2
- package/dist/components/tooltip.d.ts +103 -14
- package/dist/components/tooltip.js +194 -57
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/translation-review-editor.d.ts +9 -1
- package/dist/components/translation-review-editor.js +11 -1
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-labels.d.ts +10 -0
- package/dist/components/translation-review-labels.js +5 -1
- package/dist/components/translation-review-labels.js.map +1 -1
- package/dist/components/translation-review.d.ts +145 -8
- package/dist/components/translation-review.js +372 -91
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/ui.d.ts +2 -2
- package/dist/components/ui.js +55 -18
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-labels-B7OdnM0S.d.ts → data-table-labels-Cdn1QS2k.d.ts} +21 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/feedback/feedback-attachment.d.ts +2 -2
- package/dist/feedback/feedback-dialog.d.ts +2 -2
- package/dist/feedback/feedback-dialog.js +6 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +2 -2
- package/dist/feedback/feedback-inbox.js +2 -0
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +2 -2
- package/dist/{feedback-DOwPu-Il.d.ts → feedback-BXKH-ToU.d.ts} +32 -3
- package/dist/feedback.d.ts +2 -2
- package/dist/hooks/use-body-scroll-lock.js +16 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +2 -2
- package/dist/hooks/use-search-param-state.d.ts +58 -3
- package/dist/hooks/use-search-param-state.js +107 -35
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/dist/i18n/defaults.d.ts +2 -2
- package/dist/i18n/german.d.ts +2 -2
- package/dist/i18n/german.js +8 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +2 -2
- package/dist/i18n/languages.d.ts +2 -2
- package/dist/i18n/locales/de-CH.d.ts +2 -2
- package/dist/i18n/locales/en.d.ts +2 -2
- package/dist/i18n/locales/en.js +5 -1
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +2 -2
- package/dist/i18n/locales/es.js +7 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +2 -2
- package/dist/i18n/locales/fr.js +6 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +2 -2
- package/dist/i18n/locales/hu.js +9 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +2 -2
- package/dist/i18n/locales/it.js +6 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +2 -2
- package/dist/i18n/locales/zh.js +6 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +2 -2
- package/dist/i18n/review.js +11 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/strip-fade.d.ts +84 -5
- package/dist/lib/strip-fade.js +109 -11
- package/dist/lib/strip-fade.js.map +1 -1
- package/dist/lib/translation-review.d.ts +45 -1
- package/dist/lib/translation-review.js +38 -1
- package/dist/lib/translation-review.js.map +1 -1
- package/dist/rhf/fields.d.ts +2 -2
- package/dist/rhf/form.d.ts +2 -2
- package/dist/rhf.d.ts +2 -2
- package/dist/shell/app-shell.d.ts +2 -2
- package/dist/shell/top-bar-brand.d.ts +2 -2
- package/dist/shell.d.ts +2 -2
- package/dist/wizard/stepper-nav.d.ts +2 -2
- package/dist/wizard.d.ts +2 -2
- package/package.json +1 -1
- package/src/components/column-mapper.tsx +120 -113
- package/src/components/data-table-pagination.tsx +10 -1
- package/src/components/data-table.tsx +34 -1
- package/src/components/swipeable-row.tsx +1 -1
- package/src/components/table.tsx +39 -0
- package/src/components/tooltip.tsx +487 -104
- package/src/components/translation-review-editor.tsx +21 -1
- package/src/components/translation-review-labels.ts +17 -0
- package/src/components/translation-review.tsx +657 -109
- package/src/components/ui.tsx +104 -4
- package/src/feedback/feedback-dialog.tsx +21 -0
- package/src/feedback/feedback-inbox.tsx +19 -2
- package/src/hooks/use-body-scroll-lock.ts +25 -0
- package/src/hooks/use-search-param-state.ts +283 -41
- package/src/i18n/german.ts +10 -0
- package/src/i18n/locales/en.ts +7 -0
- package/src/i18n/locales/es.ts +9 -0
- package/src/i18n/locales/fr.ts +9 -0
- package/src/i18n/locales/hu.ts +11 -0
- package/src/i18n/locales/it.ts +9 -0
- package/src/i18n/locales/zh.ts +8 -0
- package/src/i18n/review.ts +11 -0
- package/src/index.ts +12 -0
- package/src/lib/strip-fade.ts +220 -15
- package/src/lib/translation-review.ts +85 -0
package/src/components/ui.tsx
CHANGED
|
@@ -1614,7 +1614,10 @@ export function FieldHint({
|
|
|
1614
1614
|
...rest
|
|
1615
1615
|
}: FieldHintProps) {
|
|
1616
1616
|
return (
|
|
1617
|
-
|
|
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
|
-
|
|
2600
|
-
|
|
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 () =>
|
|
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
|
|
564
|
-
*
|
|
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 {
|
|
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.
|
|
53
|
-
*
|
|
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
|
|
62
|
-
const
|
|
63
|
-
const
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
return
|
|
72
|
-
}
|
|
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`:
|