@trackunit/react-components 3.12.4-alpha-10f2d9d2ab3.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.cjs.js +104 -959
- package/index.esm.js +102 -952
- package/migrations/assets/react-router-components/package.json +23 -0
- package/migrations/utils/move-named-imports.js +409 -0
- package/migrations/utils/package-json-utils.js +73 -0
- package/migrations/v4-0-0/move-to-router-components.js +71 -0
- package/migrations.json +5 -0
- package/package.json +6 -7
- package/src/components/ExternalLink/ExternalLink.d.ts +2 -2
- package/src/index.d.ts +0 -4
- package/translation.cjs.js +0 -1
- package/translation.esm.js +0 -1
- package/translation10.cjs.js +0 -1
- package/translation10.esm.js +0 -1
- package/translation11.cjs.js +0 -1
- package/translation11.esm.js +0 -1
- package/translation12.cjs.js +0 -1
- package/translation12.esm.js +0 -1
- package/translation13.cjs.js +0 -1
- package/translation13.esm.js +0 -1
- package/translation14.cjs.js +0 -1
- package/translation14.esm.js +0 -1
- package/translation15.cjs.js +0 -1
- package/translation15.esm.js +0 -1
- package/translation16.cjs.js +0 -1
- package/translation16.esm.js +0 -1
- package/translation17.cjs.js +0 -1
- package/translation17.esm.js +0 -1
- package/translation2.cjs.js +0 -1
- package/translation2.esm.js +0 -1
- package/translation3.cjs.js +0 -1
- package/translation3.esm.js +0 -1
- package/translation4.cjs.js +0 -1
- package/translation4.esm.js +0 -1
- package/translation5.cjs.js +0 -1
- package/translation5.esm.js +0 -1
- package/translation6.cjs.js +0 -1
- package/translation6.esm.js +0 -1
- package/translation7.cjs.js +0 -1
- package/translation7.esm.js +0 -1
- package/translation8.cjs.js +0 -1
- package/translation8.esm.js +0 -1
- package/translation9.cjs.js +0 -1
- package/translation9.esm.js +0 -1
- package/src/components/Breadcrumb/Breadcrumb.d.ts +0 -39
- package/src/components/Breadcrumb/Breadcrumb.variant.d.ts +0 -45
- package/src/components/Breadcrumb/BreadcrumbContainer.d.ts +0 -8
- package/src/components/Breadcrumb/BreadcrumbForLargeScreen.d.ts +0 -6
- package/src/components/Breadcrumb/BreadcrumbForMediumScreen.d.ts +0 -6
- package/src/components/Breadcrumb/BreadcrumbForSmallScreen.d.ts +0 -6
- package/src/components/Breadcrumb/BreadcrumbItem.d.ts +0 -6
- package/src/components/Breadcrumb/utils/types.d.ts +0 -28
- package/src/components/Breadcrumb/utils/useBreadcrumbItemsToRender.d.ts +0 -6
- package/src/hooks/persistence/hashFragment.d.ts +0 -70
- package/src/hooks/persistence/readPrimitiveSearchParam.d.ts +0 -8
- package/src/hooks/persistence/urlIdentity.d.ts +0 -64
- package/src/hooks/persistence/usePersistedState.d.ts +0 -117
- package/src/hooks/persistence/useSearchParamSync.d.ts +0 -17
- package/src/hooks/persistence/useUrlParamSync.d.ts +0 -71
- package/src/hooks/useCursorUrlSync.d.ts +0 -23
package/index.cjs.js
CHANGED
|
@@ -22,12 +22,9 @@ var utils = require('@floating-ui/react/utils');
|
|
|
22
22
|
var reactHelmetAsync = require('react-helmet-async');
|
|
23
23
|
var reactTabs = require('@radix-ui/react-tabs');
|
|
24
24
|
var fflate = require('fflate');
|
|
25
|
-
var dequal = require('dequal');
|
|
26
|
-
var zod = require('zod');
|
|
27
25
|
|
|
28
26
|
var defaultTranslations = {
|
|
29
27
|
"breadcrumb.backButton": "Back",
|
|
30
|
-
"breadcrumb.expandButton": "expand",
|
|
31
28
|
"copyableValue.copyValue": "Copy {{value}}",
|
|
32
29
|
"copyButton.copied": "Copied!",
|
|
33
30
|
"copyButton.copy": "Copy",
|
|
@@ -3879,235 +3876,6 @@ const Badge = ({ color = "primary", size = "default", compact = false, className
|
|
|
3879
3876
|
return (jsxRuntime.jsx("span", { className: cvaBadge({ color, size, className, compact, isSingleChar }), "data-testid": dataTestId, ref: ref, style: style, children: compact ? null : displayedCount }));
|
|
3880
3877
|
};
|
|
3881
3878
|
|
|
3882
|
-
const cvaBreadcrumb = cssClassVarianceUtilities.cva({ base: ["my-4", "flex", "place-items-center"] });
|
|
3883
|
-
const cvaBreadcrumbItem = cssClassVarianceUtilities.cva({ base: ["flex", "items-center"] });
|
|
3884
|
-
const cvaBreadcrumbForLargeScreen = cssClassVarianceUtilities.cva({ base: ["flex", "flex-nowrap", "items-center"] });
|
|
3885
|
-
const cvaBreadcrumbForMediumScreen = cssClassVarianceUtilities.cva({
|
|
3886
|
-
base: ["flex", "items-center"],
|
|
3887
|
-
variants: {
|
|
3888
|
-
expanded: {
|
|
3889
|
-
true: "flex-wrap",
|
|
3890
|
-
false: "flex-nowrap",
|
|
3891
|
-
},
|
|
3892
|
-
},
|
|
3893
|
-
defaultVariants: { expanded: false },
|
|
3894
|
-
});
|
|
3895
|
-
|
|
3896
|
-
/**
|
|
3897
|
-
* Maps size keys to their corresponding state property names.
|
|
3898
|
-
*/
|
|
3899
|
-
const breakpointPropsMap = {
|
|
3900
|
-
xs: "isXs",
|
|
3901
|
-
sm: "isSm",
|
|
3902
|
-
md: "isMd",
|
|
3903
|
-
lg: "isLg",
|
|
3904
|
-
xl: "isXl",
|
|
3905
|
-
"2xl": "is2xl",
|
|
3906
|
-
"3xl": "is3xl",
|
|
3907
|
-
};
|
|
3908
|
-
/**
|
|
3909
|
-
* The default state for sizes, with all values set to false.
|
|
3910
|
-
*/
|
|
3911
|
-
const defaultBreakpointState = {
|
|
3912
|
-
isXs: false,
|
|
3913
|
-
isSm: false,
|
|
3914
|
-
isMd: false,
|
|
3915
|
-
isLg: false,
|
|
3916
|
-
isXl: false,
|
|
3917
|
-
is2xl: false,
|
|
3918
|
-
is3xl: false,
|
|
3919
|
-
};
|
|
3920
|
-
/**
|
|
3921
|
-
* Creates a breakpoint state object based on a width value
|
|
3922
|
-
*/
|
|
3923
|
-
const createBreakpointState = ({ width }) => {
|
|
3924
|
-
return sharedUtils.objectEntries(uiDesignTokens.themeScreenSizeAsNumber).reduce((acc, [size, minWidth]) => ({
|
|
3925
|
-
...acc,
|
|
3926
|
-
[breakpointPropsMap[size]]: width >= minWidth,
|
|
3927
|
-
}), { ...defaultBreakpointState });
|
|
3928
|
-
};
|
|
3929
|
-
|
|
3930
|
-
const canUseMatchMedia = () => typeof window !== "undefined" && typeof window.matchMedia === "function";
|
|
3931
|
-
const getViewportBreakpointState = () => {
|
|
3932
|
-
if (!canUseMatchMedia())
|
|
3933
|
-
return { ...defaultBreakpointState };
|
|
3934
|
-
return sharedUtils.objectEntries(uiDesignTokens.themeScreenSizeAsNumber).reduce((acc, [size, minWidth]) => ({
|
|
3935
|
-
...acc,
|
|
3936
|
-
[breakpointPropsMap[size]]: window.matchMedia(`(min-width: ${minWidth}px)`).matches,
|
|
3937
|
-
}), { ...defaultBreakpointState });
|
|
3938
|
-
};
|
|
3939
|
-
/**
|
|
3940
|
-
* Returns real-time boolean flags for each design-token breakpoint based on viewport width.
|
|
3941
|
-
*
|
|
3942
|
-
* **When to use:**
|
|
3943
|
-
* - Use `useViewportBreakpoints` when you need to respond to the **full viewport width**
|
|
3944
|
-
* — for example, toggling a mobile navigation drawer or switching a global layout shell.
|
|
3945
|
-
* - **Prefer `useContainerBreakpoints`** for component-level responsiveness, as it
|
|
3946
|
-
* responds to the parent container's width and works correctly when a component appears
|
|
3947
|
-
* in different layout contexts (sidebar, main content, widget).
|
|
3948
|
-
* - Pass `{ skip: true }` when you only need the initial value and don't want ongoing
|
|
3949
|
-
* `matchMedia` listeners.
|
|
3950
|
-
*
|
|
3951
|
-
* @param {object} [options] - Configuration options
|
|
3952
|
-
* @param {boolean} [options.skip] - Whether to skip observing for viewport changes (default: false)
|
|
3953
|
-
* @returns {BreakpointState} An object containing boolean values for each viewport size breakpoint.
|
|
3954
|
-
* @example
|
|
3955
|
-
* const MyComponent = () => {
|
|
3956
|
-
* const viewportSize = useViewportBreakpoints();
|
|
3957
|
-
*
|
|
3958
|
-
* if (viewportSize.isLg) {
|
|
3959
|
-
* return <LargeScreenLayout />;
|
|
3960
|
-
* }
|
|
3961
|
-
*
|
|
3962
|
-
* return viewportSize.isMd ? <MediumScreenLayout /> : <SmallLayout />;
|
|
3963
|
-
* }
|
|
3964
|
-
*/
|
|
3965
|
-
const useViewportBreakpoints = (options = {}) => {
|
|
3966
|
-
const { skip = false } = options;
|
|
3967
|
-
const [viewportSize, setViewportSize] = react.useState(getViewportBreakpointState);
|
|
3968
|
-
const updateViewportSize = react.useCallback(() => {
|
|
3969
|
-
setViewportSize(getViewportBreakpointState());
|
|
3970
|
-
}, []);
|
|
3971
|
-
const updateViewportSizeRef = react.useRef(updateViewportSize);
|
|
3972
|
-
react.useEffect(() => {
|
|
3973
|
-
updateViewportSizeRef.current = updateViewportSize;
|
|
3974
|
-
}, [updateViewportSize]);
|
|
3975
|
-
react.useEffect(() => {
|
|
3976
|
-
if (skip || !canUseMatchMedia()) {
|
|
3977
|
-
return;
|
|
3978
|
-
}
|
|
3979
|
-
updateViewportSizeRef.current();
|
|
3980
|
-
const mediaQueryLists = sharedUtils.objectEntries(uiDesignTokens.themeScreenSizeAsNumber).map(([_, minWidth]) => window.matchMedia(`(min-width: ${minWidth}px)`));
|
|
3981
|
-
mediaQueryLists.forEach(mql => {
|
|
3982
|
-
mql.addEventListener("change", updateViewportSizeRef.current);
|
|
3983
|
-
});
|
|
3984
|
-
return () => {
|
|
3985
|
-
mediaQueryLists.forEach(mql => {
|
|
3986
|
-
mql.removeEventListener("change", updateViewportSizeRef.current);
|
|
3987
|
-
});
|
|
3988
|
-
};
|
|
3989
|
-
}, [skip]);
|
|
3990
|
-
return viewportSize;
|
|
3991
|
-
};
|
|
3992
|
-
|
|
3993
|
-
/**
|
|
3994
|
-
* BreadcrumbItem is a helper component that renders the individual breadcrumb item.
|
|
3995
|
-
*/
|
|
3996
|
-
const BreadcrumbItem = ({ item, "data-testid": dataTestId, className, style, }) => {
|
|
3997
|
-
const id = sharedUtils.uuidv4();
|
|
3998
|
-
return (jsxRuntime.jsx("div", { className: cvaBreadcrumbItem({ className }), "data-testid": dataTestId, id: `${item.props.label}-${id}`, style: style, children: item }));
|
|
3999
|
-
};
|
|
4000
|
-
|
|
4001
|
-
/**
|
|
4002
|
-
* BreadcrumbForLargeScreen is a helper component that renders the breadcrumb items for large screens.
|
|
4003
|
-
*/
|
|
4004
|
-
const BreadcrumbForLargeScreen = ({ "data-testid": dataTestId, breadcrumbItems, className, style, }) => {
|
|
4005
|
-
return (jsxRuntime.jsx("div", { className: cvaBreadcrumbForLargeScreen({ className }), "data-testid": dataTestId, style: style, children: breadcrumbItems.map((item, index) => (jsxRuntime.jsx(BreadcrumbItem, { "data-testid": `BreadcrumbItem-${dataTestId}`, item: item }, `breadcrumbItem-${index}`))) }));
|
|
4006
|
-
};
|
|
4007
|
-
|
|
4008
|
-
/**
|
|
4009
|
-
* BreadcrumbForMediumScreen is a helper component that renders the breadcrumb items for medium screens.
|
|
4010
|
-
*/
|
|
4011
|
-
const BreadcrumbForMediumScreen = ({ "data-testid": dataTestId, breadcrumbItems, className, style, }) => {
|
|
4012
|
-
const [t] = useTranslation();
|
|
4013
|
-
const [expanded, setExpanded] = react.useState(false);
|
|
4014
|
-
const getReducedArray = react.useCallback(() => {
|
|
4015
|
-
let reducedArrayElements = [];
|
|
4016
|
-
const collapsibleButton = (jsxRuntime.jsxs("div", { className: cvaBreadcrumbItem(), children: [jsxRuntime.jsx(IconButton, { "data-testid": `collapsibleButton-${dataTestId}`, icon: jsxRuntime.jsx(Icon, { name: "EllipsisHorizontal" }), onClick: () => setExpanded(true), size: "small", title: t("breadcrumb.expandButton"), variant: "ghost-neutral" }), jsxRuntime.jsx(Icon, { className: "text-neutral-300", name: "Slash", size: "small" })] }));
|
|
4017
|
-
const firstBreadcrumbItem = breadcrumbItems[0];
|
|
4018
|
-
if (firstBreadcrumbItem && breadcrumbItems.length > 3) {
|
|
4019
|
-
const lastTwoBreadcrumbItem = breadcrumbItems.slice(-2);
|
|
4020
|
-
reducedArrayElements = [firstBreadcrumbItem, collapsibleButton, ...lastTwoBreadcrumbItem];
|
|
4021
|
-
}
|
|
4022
|
-
return reducedArrayElements;
|
|
4023
|
-
}, [breadcrumbItems, dataTestId, t]);
|
|
4024
|
-
const reducedArray = getReducedArray();
|
|
4025
|
-
return (jsxRuntime.jsx("div", { className: cvaBreadcrumbForMediumScreen({ className, expanded }), "data-testid": dataTestId, style: style, children: (!expanded && reducedArray.length !== 0 ? reducedArray : breadcrumbItems).map(item => (jsxRuntime.jsx(BreadcrumbItem, { "data-testid": `breadcrumbItem-${dataTestId}`, item: item }, `${item.props.label}-${sharedUtils.uuidv4()}`))) }));
|
|
4026
|
-
};
|
|
4027
|
-
|
|
4028
|
-
/**
|
|
4029
|
-
* BreadcrumbForSmallScreen is a helper component that renders the breadcrumb items for small screens.
|
|
4030
|
-
*/
|
|
4031
|
-
const BreadcrumbForSmallScreen = ({ "data-testid": dataTestId, breadcrumbItems, className, style, }) => {
|
|
4032
|
-
const lastBreadcrumbItem = breadcrumbItems.slice(-1);
|
|
4033
|
-
return (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId, style: style, children: lastBreadcrumbItem.map((item, index) => (jsxRuntime.jsx(BreadcrumbItem, { "data-testid": `breadcrumbItem-${dataTestId}`, item: item }, `breadcrumbItem-${index}`))) }));
|
|
4034
|
-
};
|
|
4035
|
-
|
|
4036
|
-
/**
|
|
4037
|
-
* BreadcrumbContainer is a helper component that renders the breadcrumb items based on the screen size.
|
|
4038
|
-
*
|
|
4039
|
-
* @param {BreadcrumbContainerProps} props - The props for the BreadcrumbContainer component
|
|
4040
|
-
*/
|
|
4041
|
-
const BreadcrumbContainer = ({ className, "data-testid": dataTestId, breadcrumbItems, style, ref, }) => {
|
|
4042
|
-
const { isLg: isLargeScreen, isMd: isMediumScreen, isXs: isSmallScreen } = useViewportBreakpoints();
|
|
4043
|
-
const sharedContainerProps = { className, "data-testid": dataTestId, ref, style };
|
|
4044
|
-
if (isLargeScreen) {
|
|
4045
|
-
return (jsxRuntime.jsx("div", { ...sharedContainerProps, children: jsxRuntime.jsx(BreadcrumbForLargeScreen, { breadcrumbItems: breadcrumbItems, "data-testid": `largeScreen-${dataTestId}` }) }));
|
|
4046
|
-
}
|
|
4047
|
-
if (isMediumScreen) {
|
|
4048
|
-
return (jsxRuntime.jsx("div", { ...sharedContainerProps, children: jsxRuntime.jsx(BreadcrumbForMediumScreen, { breadcrumbItems: breadcrumbItems, "data-testid": `mediumScreen-${dataTestId}` }) }));
|
|
4049
|
-
}
|
|
4050
|
-
if (isSmallScreen) {
|
|
4051
|
-
return (jsxRuntime.jsx("div", { ...sharedContainerProps, children: jsxRuntime.jsx(BreadcrumbForSmallScreen, { breadcrumbItems: breadcrumbItems, "data-testid": `smallScreen-${dataTestId}` }) }));
|
|
4052
|
-
}
|
|
4053
|
-
return (jsxRuntime.jsx("div", { ...sharedContainerProps, children: jsxRuntime.jsx(BreadcrumbForLargeScreen, { breadcrumbItems: breadcrumbItems, "data-testid": `largeScreen-${dataTestId}` }) }));
|
|
4054
|
-
};
|
|
4055
|
-
|
|
4056
|
-
/**
|
|
4057
|
-
* useBreadcrumbItemsToRender is a custom hook that takes an array of BreadcrumbItemProps and returns an array of React.ReactElement
|
|
4058
|
-
*/
|
|
4059
|
-
const useBreadcrumbItemsToRender = (breadcrumbItems) => {
|
|
4060
|
-
return react.useMemo(() => breadcrumbItems.map((item, index, array) => {
|
|
4061
|
-
const isLast = index === array.length - 1;
|
|
4062
|
-
if (!isLast) {
|
|
4063
|
-
return (jsxRuntime.jsxs("div", { className: "flex items-center", children: [jsxRuntime.jsx(Button, { asChild: true, size: "small", variant: "ghost-neutral", children: jsxRuntime.jsx(reactRouter.Link, { to: item.to, children: item.label }) }), jsxRuntime.jsx(Icon, { className: "text-neutral-300", name: "Slash", size: "small" })] }, index));
|
|
4064
|
-
}
|
|
4065
|
-
return (jsxRuntime.jsx(Text, { className: "text-nowrap pl-2 font-medium", size: "small", children: item.label }, index));
|
|
4066
|
-
}), [breadcrumbItems]);
|
|
4067
|
-
};
|
|
4068
|
-
|
|
4069
|
-
/**
|
|
4070
|
-
* The breadcrumb component shows a user's location in a website or application. Breadcrumbs are particularly useful when a large amount of content is organized in a hierarchical manner. They streamline navigation, minimize the steps required to revisit previous pages, and offer contextual insights to the users.
|
|
4071
|
-
*
|
|
4072
|
-
* All items except the last are rendered as clickable links. The last item is rendered as plain text representing the current page.
|
|
4073
|
-
* The component automatically adapts to the screen size: on large screens all items are visible, on medium screens middle items collapse behind an ellipsis button, and on small screens only the current page is shown.
|
|
4074
|
-
*
|
|
4075
|
-
* ### When to use
|
|
4076
|
-
* Use breadcrumbs when deep navigation is available, on apps or pages where users might dive into content and could benefit from a quick way to return to higher levels. Breadcrumbs display the current screen as the final item in the list to make it clear where the user is.
|
|
4077
|
-
*
|
|
4078
|
-
* ### When not to use
|
|
4079
|
-
* Do not use on the first hierarchal level, such as a home page or a main page, as there is no previous step the user can get to.
|
|
4080
|
-
* Avoid using breadcrumbs to indicate forthcoming steps. For processes with sequential steps, use a wizard flow instead.
|
|
4081
|
-
*
|
|
4082
|
-
* @example Basic breadcrumb navigation
|
|
4083
|
-
* ```tsx
|
|
4084
|
-
* import { Breadcrumb } from "@trackunit/react-components";
|
|
4085
|
-
* import { useNavigate } from "@tanstack/react-router";
|
|
4086
|
-
*
|
|
4087
|
-
* const AssetDetailBreadcrumb = () => {
|
|
4088
|
-
* const navigate = useNavigate();
|
|
4089
|
-
*
|
|
4090
|
-
* return (
|
|
4091
|
-
* <Breadcrumb
|
|
4092
|
-
* breadcrumbItems={[
|
|
4093
|
-
* { label: "Fleet", to: "/fleet" },
|
|
4094
|
-
* { label: "Assets", to: "/fleet/assets" },
|
|
4095
|
-
* { label: "Excavator 001", to: "/fleet/assets/123" },
|
|
4096
|
-
* ]}
|
|
4097
|
-
* onClickBack={() => navigate({ to: "/fleet/assets" })}
|
|
4098
|
-
* />
|
|
4099
|
-
* );
|
|
4100
|
-
* };
|
|
4101
|
-
* ```
|
|
4102
|
-
* @param {BreadcrumbProps} props - The props for the Breadcrumb component
|
|
4103
|
-
* @returns {ReactElement} Breadcrumb component
|
|
4104
|
-
*/
|
|
4105
|
-
const Breadcrumb = ({ className, "data-testid": dataTestId, breadcrumbItems, onClickBack, style, ref, }) => {
|
|
4106
|
-
const [t] = useTranslation();
|
|
4107
|
-
const breadCrumbItemsToJSX = useBreadcrumbItemsToRender(breadcrumbItems);
|
|
4108
|
-
return (jsxRuntime.jsxs("div", { className: cvaBreadcrumb({ className }), "data-testid": dataTestId, ref: ref, style: style, children: [jsxRuntime.jsx(IconButton, { "data-testid": `backButton-${dataTestId}`, icon: jsxRuntime.jsx(Icon, { name: "ArrowLeft", size: "small" }), onClick: onClickBack, size: "small", title: t("breadcrumb.backButton"), variant: "ghost-neutral" }), jsxRuntime.jsx("div", { children: jsxRuntime.jsx(BreadcrumbContainer, { breadcrumbItems: breadCrumbItemsToJSX, "data-testid": dataTestId }) })] }));
|
|
4109
|
-
};
|
|
4110
|
-
|
|
4111
3879
|
/**
|
|
4112
3880
|
* StarButton renders a clickable star icon for toggling favorite/starred state.
|
|
4113
3881
|
* The star appears in the primary color when starred and neutral when unstarred.
|
|
@@ -5138,7 +4906,7 @@ const cvaExternalLink = cssClassVarianceUtilities.cva({
|
|
|
5138
4906
|
* Use ExternalLink for any links that navigate to external resources outside the application (e.g., documentation, support pages, vendor sites).
|
|
5139
4907
|
*
|
|
5140
4908
|
* ### When not to use
|
|
5141
|
-
* Do not use ExternalLink for in-app navigation. Use `
|
|
4909
|
+
* Do not use ExternalLink for in-app navigation. Use `RouteLink` from `@trackunit/react-router-components` for in-app routes.
|
|
5142
4910
|
*
|
|
5143
4911
|
* @example Basic external link
|
|
5144
4912
|
* ```tsx
|
|
@@ -13526,76 +13294,6 @@ const useSessionStorage = (options) => useWebStorage(globalThis.sessionStorage,
|
|
|
13526
13294
|
*/
|
|
13527
13295
|
const useSessionStorageReducer = (options) => useWebStorageReducer(globalThis.sessionStorage, options);
|
|
13528
13296
|
|
|
13529
|
-
/**
|
|
13530
|
-
* Pure utilities for stamping a URL-persisted value with the identity that
|
|
13531
|
-
* wrote it.
|
|
13532
|
-
*
|
|
13533
|
-
* A URL (hash or search param) is shared by every account that uses the same
|
|
13534
|
-
* browser: it survives login redirects, history and bookmarks, and it takes
|
|
13535
|
-
* precedence over per-user localStorage when a page loads. Without a stamp
|
|
13536
|
-
* there is no way to tell "a colleague shared this link" from "the previous
|
|
13537
|
-
* account on this PC left its layout in the address bar" — and the second
|
|
13538
|
-
* case silently replaces the current user's saved state (GLU-2396).
|
|
13539
|
-
*
|
|
13540
|
-
* The stamp is a short, non-reversible fingerprint of `clientSideUserId`.
|
|
13541
|
-
* It is not a secret and not a security boundary; it only lets a reader
|
|
13542
|
-
* recognise a value written by a *different* identity on the same device.
|
|
13543
|
-
*/
|
|
13544
|
-
/**
|
|
13545
|
-
* Wire shape of a stamped URL value: `{ o: <fingerprint>, v: <payload> }`.
|
|
13546
|
-
* Values written before stamping existed are bare payloads and decode as
|
|
13547
|
-
* unowned (see {@link unwrapUrlOwner}).
|
|
13548
|
-
*/
|
|
13549
|
-
const stampedUrlValueSchema = zod.z.object({
|
|
13550
|
-
o: zod.z.string(),
|
|
13551
|
-
v: zod.z.unknown(),
|
|
13552
|
-
});
|
|
13553
|
-
const FNV_OFFSET_BASIS = 0x811c9dc5;
|
|
13554
|
-
const FNV_PRIME = 0x01000193;
|
|
13555
|
-
/**
|
|
13556
|
-
* 32-bit FNV-1a of `identity`, rendered in base36 (≤ 7 chars).
|
|
13557
|
-
*
|
|
13558
|
-
* Synchronous and dependency-free so it can run inside a `useState`
|
|
13559
|
-
* initializer. Collisions are possible in theory but irrelevant here: a
|
|
13560
|
-
* collision can only make a foreign value look like the reader's own, which
|
|
13561
|
-
* is today's behaviour for every value.
|
|
13562
|
-
*
|
|
13563
|
-
* @param identity - The client-side user id to fingerprint.
|
|
13564
|
-
* @returns {string} A short stable fingerprint of `identity`.
|
|
13565
|
-
*/
|
|
13566
|
-
const identityFingerprint = (identity) => {
|
|
13567
|
-
let hash = FNV_OFFSET_BASIS;
|
|
13568
|
-
for (let index = 0; index < identity.length; index++) {
|
|
13569
|
-
hash ^= identity.charCodeAt(index);
|
|
13570
|
-
hash = Math.imul(hash, FNV_PRIME) >>> 0;
|
|
13571
|
-
}
|
|
13572
|
-
return hash.toString(36);
|
|
13573
|
-
};
|
|
13574
|
-
/**
|
|
13575
|
-
* Wraps a URL payload with the fingerprint of the identity writing it.
|
|
13576
|
-
*
|
|
13577
|
-
* @param value - The payload that would otherwise be encoded into the URL.
|
|
13578
|
-
* @param owner - Fingerprint of the writing identity (see {@link identityFingerprint}).
|
|
13579
|
-
* @returns {StampedUrlValue} The payload wrapped in its ownership envelope.
|
|
13580
|
-
*/
|
|
13581
|
-
const wrapUrlOwner = (value, owner) => ({ o: owner, v: value });
|
|
13582
|
-
/**
|
|
13583
|
-
* Splits a decoded URL value into its owner fingerprint and payload.
|
|
13584
|
-
* Bare (pre-stamping) payloads come back with `owner: undefined`.
|
|
13585
|
-
*
|
|
13586
|
-
* @param decoded - The decoded URL value, stamped or bare.
|
|
13587
|
-
* @returns {{ owner: string | undefined; value: unknown }} The owner fingerprint (if any) and the payload.
|
|
13588
|
-
*/
|
|
13589
|
-
const unwrapUrlOwner = (decoded) => {
|
|
13590
|
-
const stamped = stampedUrlValueSchema.safeParse(decoded);
|
|
13591
|
-
// `z.unknown()` accepts a missing key, so `{ o: "x" }` parses without `v`.
|
|
13592
|
-
// Such an object is a payload of its own, not an envelope; the `in` check
|
|
13593
|
-
// is what keeps it unowned. Do not remove it as redundant.
|
|
13594
|
-
return stamped.success && "v" in stamped.data
|
|
13595
|
-
? { owner: stamped.data.o, value: stamped.data.v }
|
|
13596
|
-
: { owner: undefined, value: decoded };
|
|
13597
|
-
};
|
|
13598
|
-
|
|
13599
13297
|
/**
|
|
13600
13298
|
* Generates a localStorage key, optionally scoped to a specific user.
|
|
13601
13299
|
*
|
|
@@ -13610,580 +13308,6 @@ const useStorageKey = (key, userId) => {
|
|
|
13610
13308
|
return react.useMemo(() => (userId ? `${key}-${userId}` : key), [key, userId]);
|
|
13611
13309
|
};
|
|
13612
13310
|
|
|
13613
|
-
/**
|
|
13614
|
-
* Pure utilities for reading and writing single named entries inside a URL
|
|
13615
|
-
* fragment (the part after `#`).
|
|
13616
|
-
*
|
|
13617
|
-
* # Format contract
|
|
13618
|
-
*
|
|
13619
|
-
* The fragment body (after the leading `#`) is treated as a sequence of
|
|
13620
|
-
* `&`-separated tokens. A token may be:
|
|
13621
|
-
* - **A named entry** — `<key>=<value>` (the first `=` separates key from
|
|
13622
|
-
* value; later `=` chars are part of the value).
|
|
13623
|
-
* - **Foreign content** — anything else (bare scroll anchors like
|
|
13624
|
-
* `section`, hash routes, third-party data, malformed tokens). Foreign
|
|
13625
|
-
* tokens are preserved byte-for-byte, in their original positions.
|
|
13626
|
-
*
|
|
13627
|
-
* # Single-key ownership
|
|
13628
|
-
*
|
|
13629
|
-
* Callers operate on **one** named key per invocation. Every token whose
|
|
13630
|
-
* key does not match the supplied key is foreign — the utility never
|
|
13631
|
-
* categorises or rewrites it. This mirrors how `useSearchParamSync`
|
|
13632
|
-
* treats a single named search param.
|
|
13633
|
-
*
|
|
13634
|
-
* # Value-encoding invariant
|
|
13635
|
-
*
|
|
13636
|
-
* Values passed to {@link writeHashKey} must not contain `&`, `=`, or `#`.
|
|
13637
|
-
* The standard producer for this codebase is `useCustomEncoding`, whose
|
|
13638
|
-
* base64url alphabet (`[A-Za-z0-9_-]`) satisfies the invariant by
|
|
13639
|
-
* construction. Keys are restricted by the existing persistence-key
|
|
13640
|
-
* schema (camelCase, ≤15 chars), so they are likewise safe.
|
|
13641
|
-
*
|
|
13642
|
-
* # Anchor-scroll caveat
|
|
13643
|
-
*
|
|
13644
|
-
* Browsers locate scroll targets by matching the entire fragment string
|
|
13645
|
-
* against element IDs. Appending a named entry to a fragment containing
|
|
13646
|
-
* a bare anchor (e.g. `#section` becoming `#section&userTableTp=...`)
|
|
13647
|
-
* therefore breaks anchor scrolling on that page. This is unavoidable
|
|
13648
|
-
* for any hash-backed shared state. Consumers that rely on scroll
|
|
13649
|
-
* anchors should not also persist state to the hash on the same route.
|
|
13650
|
-
*/
|
|
13651
|
-
const TOKEN_SEPARATOR = "&";
|
|
13652
|
-
const KEY_VALUE_SEPARATOR = "=";
|
|
13653
|
-
const stripLeadingHash = (hash) => (hash.startsWith("#") ? hash.slice(1) : hash);
|
|
13654
|
-
const formatHash = (body) => (body.length === 0 ? "" : `#${body}`);
|
|
13655
|
-
/**
|
|
13656
|
-
* Reads the value of a single named entry from a URL fragment.
|
|
13657
|
-
*
|
|
13658
|
-
* @param hash - The fragment string, with or without the leading `#`.
|
|
13659
|
-
* @param key - The entry key to look up.
|
|
13660
|
-
* @returns {string | undefined} The raw value (everything after the first
|
|
13661
|
-
* `=` in the matching token), or `undefined` when the key is absent. An
|
|
13662
|
-
* entry whose token has no `=` is treated as foreign content and ignored.
|
|
13663
|
-
*/
|
|
13664
|
-
const readHashKey = (hash, key) => {
|
|
13665
|
-
const body = stripLeadingHash(hash);
|
|
13666
|
-
if (body.length === 0) {
|
|
13667
|
-
return undefined;
|
|
13668
|
-
}
|
|
13669
|
-
for (const token of body.split(TOKEN_SEPARATOR)) {
|
|
13670
|
-
const eqIndex = token.indexOf(KEY_VALUE_SEPARATOR);
|
|
13671
|
-
if (eqIndex > 0 && token.slice(0, eqIndex) === key) {
|
|
13672
|
-
return token.slice(eqIndex + 1);
|
|
13673
|
-
}
|
|
13674
|
-
}
|
|
13675
|
-
return undefined;
|
|
13676
|
-
};
|
|
13677
|
-
/**
|
|
13678
|
-
* Lists the key of every named entry in a URL fragment, in order of first
|
|
13679
|
-
* appearance. Foreign tokens (no `=`) are skipped and duplicates collapse.
|
|
13680
|
-
*
|
|
13681
|
-
* @param hash - The fragment string, with or without the leading `#`.
|
|
13682
|
-
* @returns {Array<string>} The distinct entry keys.
|
|
13683
|
-
*/
|
|
13684
|
-
const listHashKeys = (hash) => {
|
|
13685
|
-
const body = stripLeadingHash(hash);
|
|
13686
|
-
if (body.length === 0) {
|
|
13687
|
-
return [];
|
|
13688
|
-
}
|
|
13689
|
-
const keys = [];
|
|
13690
|
-
for (const token of body.split(TOKEN_SEPARATOR)) {
|
|
13691
|
-
const eqIndex = token.indexOf(KEY_VALUE_SEPARATOR);
|
|
13692
|
-
if (eqIndex > 0) {
|
|
13693
|
-
const key = token.slice(0, eqIndex);
|
|
13694
|
-
if (!keys.includes(key)) {
|
|
13695
|
-
keys.push(key);
|
|
13696
|
-
}
|
|
13697
|
-
}
|
|
13698
|
-
}
|
|
13699
|
-
return keys;
|
|
13700
|
-
};
|
|
13701
|
-
/**
|
|
13702
|
-
* Returns a new fragment string with the named entry written, replaced,
|
|
13703
|
-
* or removed. Foreign tokens are preserved in their original order; the
|
|
13704
|
-
* named entry retains its position if it already exists, and is appended
|
|
13705
|
-
* to the end when newly inserted. Empty tokens (e.g. produced by
|
|
13706
|
-
* consecutive `&`) are dropped on the way out.
|
|
13707
|
-
*
|
|
13708
|
-
* @param hash - The current fragment string, with or without the leading `#`.
|
|
13709
|
-
* @param key - The entry key to write.
|
|
13710
|
-
* @param value - The value to set, or `undefined` to remove the entry.
|
|
13711
|
-
* @returns {string} The new fragment string, including a leading `#` when
|
|
13712
|
-
* non-empty, or an empty string when the result has no tokens left.
|
|
13713
|
-
*/
|
|
13714
|
-
const writeHashKey = (hash, key, value) => {
|
|
13715
|
-
const body = stripLeadingHash(hash);
|
|
13716
|
-
const tokens = body.length === 0 ? [] : body.split(TOKEN_SEPARATOR);
|
|
13717
|
-
const next = [];
|
|
13718
|
-
let replaced = false;
|
|
13719
|
-
for (const token of tokens) {
|
|
13720
|
-
if (token.length === 0) {
|
|
13721
|
-
continue;
|
|
13722
|
-
}
|
|
13723
|
-
const eqIndex = token.indexOf(KEY_VALUE_SEPARATOR);
|
|
13724
|
-
if (eqIndex > 0 && token.slice(0, eqIndex) === key) {
|
|
13725
|
-
if (value !== undefined && !replaced) {
|
|
13726
|
-
next.push(`${key}${KEY_VALUE_SEPARATOR}${value}`);
|
|
13727
|
-
replaced = true;
|
|
13728
|
-
}
|
|
13729
|
-
// Else: drop the token (either removing the key or dropping a
|
|
13730
|
-
// duplicate occurrence after we already wrote the replacement).
|
|
13731
|
-
continue;
|
|
13732
|
-
}
|
|
13733
|
-
next.push(token);
|
|
13734
|
-
}
|
|
13735
|
-
if (value !== undefined && !replaced) {
|
|
13736
|
-
next.push(`${key}${KEY_VALUE_SEPARATOR}${value}`);
|
|
13737
|
-
}
|
|
13738
|
-
return formatHash(next.join(TOKEN_SEPARATOR));
|
|
13739
|
-
};
|
|
13740
|
-
|
|
13741
|
-
const searchRecordSchema = zod.z.record(zod.z.string(), zod.z.unknown());
|
|
13742
|
-
const primitiveSearchValueSchema = zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean()]);
|
|
13743
|
-
/**
|
|
13744
|
-
* Reads one search param from a TanStack `useSearch({ strict: false })` result.
|
|
13745
|
-
*
|
|
13746
|
-
* The registered route tree often has an empty or closed search schema, so a
|
|
13747
|
-
* string key cannot index that type. Parsing to a string record keeps the
|
|
13748
|
-
* lookup valid for every app router.
|
|
13749
|
-
*/
|
|
13750
|
-
const readPrimitiveSearchParam = (search, key) => {
|
|
13751
|
-
const parsedSearch = searchRecordSchema.safeParse(search);
|
|
13752
|
-
if (!parsedSearch.success) {
|
|
13753
|
-
return undefined;
|
|
13754
|
-
}
|
|
13755
|
-
const parsedValue = primitiveSearchValueSchema.safeParse(parsedSearch.data[key]);
|
|
13756
|
-
if (!parsedValue.success) {
|
|
13757
|
-
return undefined;
|
|
13758
|
-
}
|
|
13759
|
-
return String(parsedValue.data);
|
|
13760
|
-
};
|
|
13761
|
-
|
|
13762
|
-
/**
|
|
13763
|
-
* Maximum full URL length after adding one persisted search param before the
|
|
13764
|
-
* URL write is skipped (localStorage still persists the full state).
|
|
13765
|
-
*
|
|
13766
|
-
* Sized to stay safely below the AWS WAF managed-rule default
|
|
13767
|
-
* `SizeRestrictions_QUERYSTRING` cap of 5000 bytes for the entire
|
|
13768
|
-
* host + path + query + hash string.
|
|
13769
|
-
*/
|
|
13770
|
-
const MAX_URL_LENGTH = 5000;
|
|
13771
|
-
/**
|
|
13772
|
-
* Maximum length of the URL fragment after adding one persisted entry.
|
|
13773
|
-
* Oversized new entries are skipped; oversized replacements remove the stale
|
|
13774
|
-
* entry so localStorage remains authoritative.
|
|
13775
|
-
*
|
|
13776
|
-
* The fragment is never sent to the server, so the WAF cap above does not
|
|
13777
|
-
* apply. 64 KB stays well below typical browser URL limits (Chromium ≳ 100 KB,
|
|
13778
|
-
* Firefox ≳ 64 KB on the address bar) while leaving generous headroom for any
|
|
13779
|
-
* realistic table state.
|
|
13780
|
-
*/
|
|
13781
|
-
const MAX_HASH_LENGTH = 64 * 1024;
|
|
13782
|
-
/**
|
|
13783
|
-
* Syncs an encoded string value with a single named entry in the URL — either
|
|
13784
|
-
* a search param or an entry inside the hash fragment — via Tanstack Router.
|
|
13785
|
-
*
|
|
13786
|
-
* Writes go through the application router so other search params and
|
|
13787
|
-
* foreign fragment content are preserved. Each slot has a size guard
|
|
13788
|
-
* ({@link MAX_URL_LENGTH} for search, {@link MAX_HASH_LENGTH} for hash); an
|
|
13789
|
-
* oversized value is dropped from the URL and localStorage stays
|
|
13790
|
-
* authoritative. External changes to the entry (browser back/forward, a
|
|
13791
|
-
* pasted link, a foreign router push) fire `onExternalChange`.
|
|
13792
|
-
*
|
|
13793
|
-
* Writes are dispatched via a pending-write queue rather than calling
|
|
13794
|
-
* `navigate` directly. Tanstack Router's `router.state.status === "pending"`
|
|
13795
|
-
* is common during the first paint of a route (loaders, suspense), and an
|
|
13796
|
-
* implementation that simply returned in that case lost the write outright.
|
|
13797
|
-
* `updateSearchParam` stores the latest attempted write and schedules an rAF
|
|
13798
|
-
* flush; the flush only navigates when the router is idle, and an effect
|
|
13799
|
-
* retries whenever the router settles. A queued write is dropped on unmount so
|
|
13800
|
-
* it cannot drain onto the next route.
|
|
13801
|
-
*
|
|
13802
|
-
* @param options - Configuration for the URL entry sync.
|
|
13803
|
-
* @param options.key - The search param name or fragment entry name.
|
|
13804
|
-
* @param options.location - `"search"` or `"hash"`.
|
|
13805
|
-
* @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
|
|
13806
|
-
* @param options.onExternalChange - Called when the entry changes externally.
|
|
13807
|
-
* @param options.replace - `true` to always use `replaceState`; `false` to always push.
|
|
13808
|
-
* Default: replace when the entry is being added, push when it already has a value.
|
|
13809
|
-
*/
|
|
13810
|
-
const useUrlParamSync = ({ key, location: urlLocation, enabled = true, onExternalChange, replace: replaceOption, }) => {
|
|
13811
|
-
const navigate = reactRouter.useNavigate();
|
|
13812
|
-
const location = reactRouter.useLocation();
|
|
13813
|
-
const router = reactRouter.useRouter();
|
|
13814
|
-
const search = reactRouter.useSearch({ strict: false, shouldThrow: false });
|
|
13815
|
-
const isHash = urlLocation === "hash";
|
|
13816
|
-
const lastWrittenRef = react.useRef(undefined);
|
|
13817
|
-
const pendingWriteRef = react.useRef(null);
|
|
13818
|
-
const onExternalChangeRef = react.useRef(onExternalChange);
|
|
13819
|
-
react.useEffect(() => {
|
|
13820
|
-
onExternalChangeRef.current = onExternalChange;
|
|
13821
|
-
}, [onExternalChange]);
|
|
13822
|
-
const currentValue = react.useMemo(() => {
|
|
13823
|
-
if (!enabled) {
|
|
13824
|
-
return undefined;
|
|
13825
|
-
}
|
|
13826
|
-
return isHash ? readHashKey(location.hash, key) : readPrimitiveSearchParam(search, key);
|
|
13827
|
-
}, [enabled, isHash, location.hash, search, key]);
|
|
13828
|
-
react.useEffect(() => {
|
|
13829
|
-
if (!enabled || currentValue === undefined) {
|
|
13830
|
-
return;
|
|
13831
|
-
}
|
|
13832
|
-
if (currentValue === lastWrittenRef.current || lastWrittenRef.current === undefined) {
|
|
13833
|
-
return;
|
|
13834
|
-
}
|
|
13835
|
-
// A caller may have primed `lastWrittenRef` via `updateSearchParam` while
|
|
13836
|
-
// the URL still carries the previous value (queued rAF / router pending).
|
|
13837
|
-
// Treating that stale URL as an external change would reload state from
|
|
13838
|
-
// persistence before the write lands.
|
|
13839
|
-
if (pendingWriteRef.current !== null) {
|
|
13840
|
-
return;
|
|
13841
|
-
}
|
|
13842
|
-
requestAnimationFrame(() => {
|
|
13843
|
-
onExternalChangeRef.current?.();
|
|
13844
|
-
});
|
|
13845
|
-
}, [currentValue, enabled]);
|
|
13846
|
-
// Read through refs so the write path does not change identity on every
|
|
13847
|
-
// navigation; it only needs the location and current value at the moment it
|
|
13848
|
-
// flushes, which may be an animation frame after it was queued.
|
|
13849
|
-
const locationRef = react.useRef(location);
|
|
13850
|
-
const currentValueRef = react.useRef(currentValue);
|
|
13851
|
-
react.useEffect(() => {
|
|
13852
|
-
locationRef.current = location;
|
|
13853
|
-
currentValueRef.current = currentValue;
|
|
13854
|
-
}, [location, currentValue]);
|
|
13855
|
-
const getUrlLengthWithSearchParam = react.useCallback((paramValue, params) => {
|
|
13856
|
-
const { href, searchStr, hash } = locationRef.current;
|
|
13857
|
-
const otherParamsLength = sharedUtils.objectKeys(params)
|
|
13858
|
-
.filter(k => k !== key)
|
|
13859
|
-
.reduce((totalLength, k) => {
|
|
13860
|
-
const kLen = encodeURIComponent(String(k)).length;
|
|
13861
|
-
const v = params[k];
|
|
13862
|
-
const vLen = v !== null && v !== undefined ? encodeURIComponent(String(v)).length : 0;
|
|
13863
|
-
return totalLength + kLen + vLen + (totalLength > 0 ? 2 : 1);
|
|
13864
|
-
}, 0);
|
|
13865
|
-
const urlBase = href.length - (searchStr.length || 0) + (hash.length || 0);
|
|
13866
|
-
return urlBase + otherParamsLength + 1 + key.length + 1 + paramValue.length;
|
|
13867
|
-
}, [key]);
|
|
13868
|
-
const flushPendingWrite = react.useCallback(() => {
|
|
13869
|
-
const pending = pendingWriteRef.current;
|
|
13870
|
-
if (pending === null) {
|
|
13871
|
-
return;
|
|
13872
|
-
}
|
|
13873
|
-
if (router.state.status === "pending") {
|
|
13874
|
-
// Keep the write queued; the status-watcher effect retries once idle.
|
|
13875
|
-
return;
|
|
13876
|
-
}
|
|
13877
|
-
pendingWriteRef.current = null;
|
|
13878
|
-
const { encodedValue } = pending;
|
|
13879
|
-
const { hash } = locationRef.current;
|
|
13880
|
-
const valueInUrl = currentValueRef.current;
|
|
13881
|
-
const shouldReplace = replaceOption ?? !Boolean(valueInUrl);
|
|
13882
|
-
if (!isHash) {
|
|
13883
|
-
void navigate({
|
|
13884
|
-
to: ".",
|
|
13885
|
-
search: (prev) => getUrlLengthWithSearchParam(encodedValue ?? "", prev) <= MAX_URL_LENGTH
|
|
13886
|
-
? { ...prev, [key]: encodedValue }
|
|
13887
|
-
: { ...prev, [key]: undefined },
|
|
13888
|
-
hash,
|
|
13889
|
-
replace: shouldReplace,
|
|
13890
|
-
});
|
|
13891
|
-
return;
|
|
13892
|
-
}
|
|
13893
|
-
let nextHash = writeHashKey(hash, key, encodedValue);
|
|
13894
|
-
let removedOversizedValue = false;
|
|
13895
|
-
if (nextHash.length > MAX_HASH_LENGTH) {
|
|
13896
|
-
if (valueInUrl === undefined) {
|
|
13897
|
-
return;
|
|
13898
|
-
}
|
|
13899
|
-
nextHash = writeHashKey(hash, key, undefined);
|
|
13900
|
-
removedOversizedValue = true;
|
|
13901
|
-
}
|
|
13902
|
-
void navigate({
|
|
13903
|
-
to: ".",
|
|
13904
|
-
// Returning the same `prev` reference leaves the search params
|
|
13905
|
-
// unchanged; `search` must still be passed for the router types.
|
|
13906
|
-
search: (prev) => prev,
|
|
13907
|
-
// The router adds the `#` itself; forwarding it would double it.
|
|
13908
|
-
hash: nextHash.startsWith("#") ? nextHash.slice(1) : nextHash,
|
|
13909
|
-
replace: removedOversizedValue ? true : shouldReplace,
|
|
13910
|
-
});
|
|
13911
|
-
}, [router.state.status, replaceOption, isHash, navigate, getUrlLengthWithSearchParam, key]);
|
|
13912
|
-
react.useEffect(() => {
|
|
13913
|
-
if (router.state.status === "pending" || pendingWriteRef.current === null) {
|
|
13914
|
-
return;
|
|
13915
|
-
}
|
|
13916
|
-
flushPendingWrite();
|
|
13917
|
-
}, [router.state.status, flushPendingWrite]);
|
|
13918
|
-
react.useEffect(() => () => {
|
|
13919
|
-
pendingWriteRef.current = null;
|
|
13920
|
-
}, []);
|
|
13921
|
-
const updateSearchParam = react.useCallback((encodedValue) => {
|
|
13922
|
-
if (!enabled) {
|
|
13923
|
-
return;
|
|
13924
|
-
}
|
|
13925
|
-
// Dedupe against the last committed write or the value already queued;
|
|
13926
|
-
// both represent the caller's intent.
|
|
13927
|
-
const inFlightOrLastWritten = pendingWriteRef.current?.encodedValue ?? lastWrittenRef.current;
|
|
13928
|
-
if (encodedValue !== undefined && encodedValue === inFlightOrLastWritten) {
|
|
13929
|
-
return;
|
|
13930
|
-
}
|
|
13931
|
-
// Prime here (not in the flush) so the external-change detector treats
|
|
13932
|
-
// this value as the caller's state even when the flush is a no-op or
|
|
13933
|
-
// stays queued during a pending router phase.
|
|
13934
|
-
lastWrittenRef.current = encodedValue;
|
|
13935
|
-
if (currentValueRef.current === encodedValue) {
|
|
13936
|
-
return;
|
|
13937
|
-
}
|
|
13938
|
-
pendingWriteRef.current = { encodedValue };
|
|
13939
|
-
requestAnimationFrame(() => {
|
|
13940
|
-
flushPendingWrite();
|
|
13941
|
-
});
|
|
13942
|
-
}, [enabled, flushPendingWrite]);
|
|
13943
|
-
return react.useMemo(() => ({ searchValue: currentValue, updateSearchParam }), [currentValue, updateSearchParam]);
|
|
13944
|
-
};
|
|
13945
|
-
|
|
13946
|
-
/**
|
|
13947
|
-
* Raw localStorage access, kept in one place so this file's exemption from the
|
|
13948
|
-
* no-direct-storage-access rule is centralised rather than repeated at each call site.
|
|
13949
|
-
*
|
|
13950
|
-
* TODO: migrate to the useLocalStorage hook — this file was exempted when the
|
|
13951
|
-
* no-direct-storage-access rule was introduced.
|
|
13952
|
-
*/
|
|
13953
|
-
/* eslint-disable no-restricted-globals */
|
|
13954
|
-
const rawLocalStorage = {
|
|
13955
|
-
read: (key) => localStorage.getItem(key),
|
|
13956
|
-
write: (key, value) => localStorage.setItem(key, value),
|
|
13957
|
-
remove: (key) => localStorage.removeItem(key),
|
|
13958
|
-
};
|
|
13959
|
-
/**
|
|
13960
|
-
* Generic persistence hook that loads state from a URL slot (search param
|
|
13961
|
-
* or hash fragment, depending on `urlLocation`) with localStorage fallback,
|
|
13962
|
-
* and writes changes to both.
|
|
13963
|
-
*
|
|
13964
|
-
* On mount the hook resolves the initial state as follows:
|
|
13965
|
-
* - If the URL slot holds a value, decode it and pass through `validate`.
|
|
13966
|
-
* - If localStorage is enabled, read and validate its value.
|
|
13967
|
-
* - When `mergeWithStorageOnRead` is `false` (default): URL state wins
|
|
13968
|
-
* entirely when present, otherwise fall back to localStorage.
|
|
13969
|
-
* - When `mergeWithStorageOnRead` is `true`: shallow-merge URL fields over
|
|
13970
|
-
* localStorage so fields stripped by `toUrlValue` survive via storage.
|
|
13971
|
-
*
|
|
13972
|
-
* A value that comes only from localStorage is never written to the URL on
|
|
13973
|
-
* load. Copying it left a layout in history, bookmarks, and login redirects,
|
|
13974
|
-
* which then replayed an older layout over the saved one (GLU-2396). The URL
|
|
13975
|
-
* is written when the consumer persists a change.
|
|
13976
|
-
*
|
|
13977
|
-
* `persistState` writes to localStorage (`storageSerializer`) and to the URL
|
|
13978
|
-
* (via `toUrlValue` + encoding).
|
|
13979
|
-
* Each slot skips a value equal to its last write. The URL side compares what
|
|
13980
|
-
* would land in the URL (the `toUrlValue` projection and the identity stamp),
|
|
13981
|
-
* starting from the projection of the hydrated state and the stamp the URL
|
|
13982
|
-
* carries: echoing `initialState`, or changing only a field the projection
|
|
13983
|
-
* strips, writes no URL entry, while an equal state persisted after the
|
|
13984
|
-
* identity resolves rewrites the URL with its stamp. The storage side starts
|
|
13985
|
-
* from what localStorage held, so a consumer that persists `initialState`
|
|
13986
|
-
* after opening a link saves that link's state for the account that opened
|
|
13987
|
-
* it, and a reload of the account's own link writes nothing.
|
|
13988
|
-
* `persistToStorage` and `persistToUrl` are the same two halves exposed
|
|
13989
|
-
* separately for consumers that schedule them differently; passing `undefined`
|
|
13990
|
-
* removes the entry rather than writing an empty value.
|
|
13991
|
-
*
|
|
13992
|
-
* The snapshot is re-read when the storage key or the storage/merge options
|
|
13993
|
-
* change, in the same render, so a write in that commit already sees the
|
|
13994
|
-
* state for the new key.
|
|
13995
|
-
*
|
|
13996
|
-
* @param options - Configuration for the persisted state.
|
|
13997
|
-
* @param options.key - Unique identifier used for both the URL slot and the localStorage key.
|
|
13998
|
-
* @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
|
|
13999
|
-
* @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
|
|
14000
|
-
* @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
|
|
14001
|
-
* @param options.localStorageEnabled - When `false` localStorage is neither read nor written (default `true`).
|
|
14002
|
-
* @param options.replace - Forwarded to the underlying URL sync hook.
|
|
14003
|
-
* @param options.clientSideUserId - The user ID to use for the localStorage key.
|
|
14004
|
-
* @param options.mergeWithStorageOnRead - Merge URL state on top of localStorage
|
|
14005
|
-
* state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
|
|
14006
|
-
* omits fields so they still survive via localStorage. Default `false`.
|
|
14007
|
-
* @param options.urlLocation - Which URL slot backs the value (`"search"` or `"hash"`). Default `"search"`.
|
|
14008
|
-
* @param options.stampUrlWithIdentity - Stamp URL writes with the current identity so the login
|
|
14009
|
-
* redirect can drop entries left by another account on this browser. Default `false`.
|
|
14010
|
-
*/
|
|
14011
|
-
const usePersistedState = ({ key, validate, toUrlValue, fromUrlValue, localStorageEnabled = true, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", stampUrlWithIdentity = false, }) => {
|
|
14012
|
-
const { encode, decode } = useCustomEncoding();
|
|
14013
|
-
// Fingerprint of the identity this hook writes URL values for. `undefined`
|
|
14014
|
-
// while the identity is unresolved (or when stamping is off): writes are
|
|
14015
|
-
// then unstamped.
|
|
14016
|
-
const currentUrlOwner = react.useMemo(() => (stampUrlWithIdentity && clientSideUserId !== undefined ? identityFingerprint(clientSideUserId) : undefined), [stampUrlWithIdentity, clientSideUserId]);
|
|
14017
|
-
const { searchValue: urlValue, updateSearchParam: updateUrlValue } = useUrlParamSync({
|
|
14018
|
-
key,
|
|
14019
|
-
location: urlLocation,
|
|
14020
|
-
replace,
|
|
14021
|
-
});
|
|
14022
|
-
const storageKey = useStorageKey(key, clientSideUserId);
|
|
14023
|
-
// `toUrlValue` is read through a ref so the write callbacks stay stable
|
|
14024
|
-
// when a consumer passes an inline function.
|
|
14025
|
-
const toUrlValueRef = react.useRef(toUrlValue);
|
|
14026
|
-
react.useEffect(() => {
|
|
14027
|
-
toUrlValueRef.current = toUrlValue;
|
|
14028
|
-
}, [toUrlValue]);
|
|
14029
|
-
const readStoredState = react.useCallback((validateState) => {
|
|
14030
|
-
if (!localStorageEnabled) {
|
|
14031
|
-
return undefined;
|
|
14032
|
-
}
|
|
14033
|
-
try {
|
|
14034
|
-
const raw = rawLocalStorage.read(storageKey);
|
|
14035
|
-
if (!raw) {
|
|
14036
|
-
return undefined;
|
|
14037
|
-
}
|
|
14038
|
-
return validateState(sharedUtils.storageSerializer.deserialize(raw));
|
|
14039
|
-
}
|
|
14040
|
-
catch {
|
|
14041
|
-
return undefined;
|
|
14042
|
-
}
|
|
14043
|
-
}, [localStorageEnabled, storageKey]);
|
|
14044
|
-
const readPersistedState = react.useCallback(({ valueFromUrl, validateState, transformFromUrlValue, }) => {
|
|
14045
|
-
let urlState;
|
|
14046
|
-
let urlStamp;
|
|
14047
|
-
if (valueFromUrl) {
|
|
14048
|
-
try {
|
|
14049
|
-
// The ownership stamp decides nothing on read (the login redirect
|
|
14050
|
-
// does that, see `useStripForeignIdentityHash`); it is kept only to
|
|
14051
|
-
// seed the URL dedupe with what the URL is currently stamped with.
|
|
14052
|
-
const { value, owner } = unwrapUrlOwner(decode(valueFromUrl));
|
|
14053
|
-
urlState = validateState(transformFromUrlValue ? transformFromUrlValue(value) : value);
|
|
14054
|
-
urlStamp = urlState === undefined ? undefined : { owner };
|
|
14055
|
-
}
|
|
14056
|
-
catch {
|
|
14057
|
-
urlState = undefined;
|
|
14058
|
-
}
|
|
14059
|
-
}
|
|
14060
|
-
// Storage is read even when the URL wins outright: it seeds the storage
|
|
14061
|
-
// dedupe, so persisting the hydrated state writes only when the URL
|
|
14062
|
-
// changed something.
|
|
14063
|
-
const stored = readStoredState(validateState);
|
|
14064
|
-
let hydrated;
|
|
14065
|
-
if (urlState === undefined) {
|
|
14066
|
-
hydrated = stored;
|
|
14067
|
-
}
|
|
14068
|
-
else if (mergeWithStorageOnRead && stored !== undefined) {
|
|
14069
|
-
// URL fields take precedence; storage fills in whatever the URL
|
|
14070
|
-
// omits (e.g. fields stripped by `toUrlValue`).
|
|
14071
|
-
hydrated = { ...stored, ...urlState };
|
|
14072
|
-
}
|
|
14073
|
-
else {
|
|
14074
|
-
hydrated = urlState;
|
|
14075
|
-
}
|
|
14076
|
-
return { initialState: hydrated, storedState: stored, urlValue: urlStamp };
|
|
14077
|
-
}, [decode, mergeWithStorageOnRead, readStoredState]);
|
|
14078
|
-
// The shareable projection of `state`: what a URL write carries before
|
|
14079
|
-
// encoding. The URL dedupe compares this, not the whole state, so a change
|
|
14080
|
-
// confined to a field `toUrlValue` strips is not a URL change.
|
|
14081
|
-
const projectForUrl = react.useCallback((state) => (toUrlValueRef.current ? toUrlValueRef.current(state) : state), []);
|
|
14082
|
-
// Every URL write goes through here so the ownership stamp is applied uniformly.
|
|
14083
|
-
const encodeUrlWrite = react.useCallback(({ projected, owner }) => encode(owner === undefined ? projected : wrapUrlOwner(projected, owner)), [encode]);
|
|
14084
|
-
// `readPersistedState` changes identity exactly when an input that changes
|
|
14085
|
-
// what a read returns does (storage key, storage/merge options), so the
|
|
14086
|
-
// snapshot is re-read then and only then — synchronously, in the render
|
|
14087
|
-
// where the input changed, by deriving state from the changed input. A URL
|
|
14088
|
-
// change after mount or an inline `validate` deliberately does not
|
|
14089
|
-
// re-hydrate `initialState`: `useUrlParamSync` owns URL changes.
|
|
14090
|
-
const readSnapshot = () => ({
|
|
14091
|
-
readWith: readPersistedState,
|
|
14092
|
-
...readPersistedState({ valueFromUrl: urlValue, validateState: validate, transformFromUrlValue: fromUrlValue }),
|
|
14093
|
-
});
|
|
14094
|
-
const [snapshot, setSnapshot] = react.useState(readSnapshot);
|
|
14095
|
-
if (snapshot.readWith !== readPersistedState) {
|
|
14096
|
-
setSnapshot(readSnapshot());
|
|
14097
|
-
}
|
|
14098
|
-
const { initialState, storedState } = snapshot;
|
|
14099
|
-
// Last value written to each slot, for deduping. Two refs, not one, because
|
|
14100
|
-
// a consumer may schedule the two writes differently (storage at once, URL
|
|
14101
|
-
// debounced). Both are re-seeded whenever the snapshot is re-read so the
|
|
14102
|
-
// first write to a newly scoped key is never skipped.
|
|
14103
|
-
//
|
|
14104
|
-
// The storage side starts as what storage held, so persisting the hydrated
|
|
14105
|
-
// state after opening a link writes only when the URL changed something.
|
|
14106
|
-
//
|
|
14107
|
-
// The URL side holds what a write would put in the URL: the shareable
|
|
14108
|
-
// projection plus the owner stamp. It starts as the projection of the
|
|
14109
|
-
// hydrated value, so a consumer that echoes `initialState` on mount, or
|
|
14110
|
-
// changes only a field the projection strips, writes nothing: a layout read
|
|
14111
|
-
// from storage must not grow a URL entry (GLU-2396). Its owner starts as the
|
|
14112
|
-
// stamp the URL carries (or the current owner when the URL is empty), so an
|
|
14113
|
-
// equal state persisted after the identity resolves still rewrites the URL
|
|
14114
|
-
// with its stamp.
|
|
14115
|
-
const urlOwnerSeed = snapshot.urlValue ? snapshot.urlValue.owner : currentUrlOwner;
|
|
14116
|
-
const seedUrlWrite = react.useCallback((project) => initialState === undefined ? undefined : { projected: project(initialState), owner: urlOwnerSeed }, [initialState, urlOwnerSeed]);
|
|
14117
|
-
const lastStoredRef = react.useRef(storedState);
|
|
14118
|
-
// The first seed is computed during render, so it projects with the
|
|
14119
|
-
// `toUrlValue` prop rather than reading `toUrlValueRef` (refs are not read
|
|
14120
|
-
// in render); the projection is cheap, and later seeds come from the effect.
|
|
14121
|
-
const lastUrlRef = react.useRef(seedUrlWrite(state => (toUrlValue ? toUrlValue(state) : state)));
|
|
14122
|
-
react.useEffect(() => {
|
|
14123
|
-
lastStoredRef.current = storedState;
|
|
14124
|
-
lastUrlRef.current = seedUrlWrite(projectForUrl);
|
|
14125
|
-
}, [seedUrlWrite, projectForUrl, storedState]);
|
|
14126
|
-
const persistToStorage = react.useCallback((state) => {
|
|
14127
|
-
if (!localStorageEnabled) {
|
|
14128
|
-
return;
|
|
14129
|
-
}
|
|
14130
|
-
if (state === undefined) {
|
|
14131
|
-
lastStoredRef.current = undefined;
|
|
14132
|
-
rawLocalStorage.remove(storageKey);
|
|
14133
|
-
return;
|
|
14134
|
-
}
|
|
14135
|
-
if (dequal.dequal(lastStoredRef.current, state)) {
|
|
14136
|
-
return;
|
|
14137
|
-
}
|
|
14138
|
-
try {
|
|
14139
|
-
rawLocalStorage.write(storageKey, sharedUtils.storageSerializer.serialize(state));
|
|
14140
|
-
lastStoredRef.current = state;
|
|
14141
|
-
}
|
|
14142
|
-
catch {
|
|
14143
|
-
// Quota or a disabled store: the next write retries; the URL is unaffected.
|
|
14144
|
-
}
|
|
14145
|
-
}, [localStorageEnabled, storageKey]);
|
|
14146
|
-
const persistToUrl = react.useCallback((state) => {
|
|
14147
|
-
if (state === undefined) {
|
|
14148
|
-
lastUrlRef.current = undefined;
|
|
14149
|
-
updateUrlValue(undefined);
|
|
14150
|
-
return;
|
|
14151
|
-
}
|
|
14152
|
-
const write = { projected: projectForUrl(state), owner: currentUrlOwner };
|
|
14153
|
-
const last = lastUrlRef.current;
|
|
14154
|
-
// Same projection: write only to add or change a stamp. An unresolved
|
|
14155
|
-
// identity never rewrites a stamped URL just to strip the stamp; it is
|
|
14156
|
-
// re-stamped once the identity resolves.
|
|
14157
|
-
if (last !== undefined &&
|
|
14158
|
-
dequal.dequal(last.projected, write.projected) &&
|
|
14159
|
-
(last.owner === write.owner || write.owner === undefined)) {
|
|
14160
|
-
return;
|
|
14161
|
-
}
|
|
14162
|
-
lastUrlRef.current = write;
|
|
14163
|
-
updateUrlValue(encodeUrlWrite(write));
|
|
14164
|
-
}, [currentUrlOwner, encodeUrlWrite, projectForUrl, updateUrlValue]);
|
|
14165
|
-
const persistState = react.useCallback((state) => {
|
|
14166
|
-
persistToStorage(state);
|
|
14167
|
-
persistToUrl(state);
|
|
14168
|
-
}, [persistToStorage, persistToUrl]);
|
|
14169
|
-
return react.useMemo(() => ({ initialState, persistState, persistToStorage, persistToUrl }), [initialState, persistState, persistToStorage, persistToUrl]);
|
|
14170
|
-
};
|
|
14171
|
-
|
|
14172
|
-
/**
|
|
14173
|
-
* Syncs an encoded string value with a URL search parameter via Tanstack Router.
|
|
14174
|
-
*
|
|
14175
|
-
* Thin wrapper over {@link useUrlParamSync} with `location: "search"`; see it
|
|
14176
|
-
* for the write queue, the size guard (`MAX_URL_LENGTH`), and external-change
|
|
14177
|
-
* detection.
|
|
14178
|
-
*
|
|
14179
|
-
* @param options - Configuration for the search param sync.
|
|
14180
|
-
* @param options.key - The URL search parameter name.
|
|
14181
|
-
* @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
|
|
14182
|
-
* @param options.onExternalChange - Called when the param changes externally.
|
|
14183
|
-
* @param options.replace - `true` to always use `replaceState`.
|
|
14184
|
-
*/
|
|
14185
|
-
const useSearchParamSync = (options) => useUrlParamSync({ ...options, location: "search" });
|
|
14186
|
-
|
|
14187
13311
|
/**
|
|
14188
13312
|
* Enumerates localStorage keys. Kept local so this file's exemption from the
|
|
14189
13313
|
* no-direct-storage-access rule is centralised, as in `usePersistedState`.
|
|
@@ -14217,7 +13341,7 @@ const isOwnedByOtherLocalIdentity = (key, owner, currentOwner) => {
|
|
|
14217
13341
|
try {
|
|
14218
13342
|
return localStorageKeys()
|
|
14219
13343
|
.filter(storageKey => storageKey.startsWith(prefix))
|
|
14220
|
-
.some(storageKey => identityFingerprint(storageKey.slice(prefix.length)) === owner);
|
|
13344
|
+
.some(storageKey => sharedUtils.identityFingerprint(storageKey.slice(prefix.length)) === owner);
|
|
14221
13345
|
}
|
|
14222
13346
|
catch {
|
|
14223
13347
|
return false;
|
|
@@ -14257,22 +13381,22 @@ const useStripForeignIdentityHash = () => {
|
|
|
14257
13381
|
}
|
|
14258
13382
|
const base = url.slice(0, hashIndex);
|
|
14259
13383
|
const hash = url.slice(hashIndex);
|
|
14260
|
-
const currentOwner = identityFingerprint(clientSideUserId);
|
|
13384
|
+
const currentOwner = sharedUtils.identityFingerprint(clientSideUserId);
|
|
14261
13385
|
let next = hash;
|
|
14262
|
-
for (const key of listHashKeys(hash)) {
|
|
14263
|
-
const raw = readHashKey(hash, key);
|
|
13386
|
+
for (const key of sharedUtils.listHashKeys(hash)) {
|
|
13387
|
+
const raw = sharedUtils.readHashKey(hash, key);
|
|
14264
13388
|
if (raw === undefined) {
|
|
14265
13389
|
continue;
|
|
14266
13390
|
}
|
|
14267
13391
|
let owner;
|
|
14268
13392
|
try {
|
|
14269
|
-
owner = unwrapUrlOwner(decode(raw)).owner;
|
|
13393
|
+
owner = sharedUtils.unwrapUrlOwner(decode(raw)).owner;
|
|
14270
13394
|
}
|
|
14271
13395
|
catch {
|
|
14272
13396
|
continue;
|
|
14273
13397
|
}
|
|
14274
13398
|
if (isOwnedByOtherLocalIdentity(key, owner, currentOwner)) {
|
|
14275
|
-
next = writeHashKey(next, key, undefined);
|
|
13399
|
+
next = sharedUtils.writeHashKey(next, key, undefined);
|
|
14276
13400
|
}
|
|
14277
13401
|
}
|
|
14278
13402
|
return `${base}${next}`;
|
|
@@ -14423,6 +13547,40 @@ const useClickOutside = (el, options) => {
|
|
|
14423
13547
|
});
|
|
14424
13548
|
};
|
|
14425
13549
|
|
|
13550
|
+
/**
|
|
13551
|
+
* Maps size keys to their corresponding state property names.
|
|
13552
|
+
*/
|
|
13553
|
+
const breakpointPropsMap = {
|
|
13554
|
+
xs: "isXs",
|
|
13555
|
+
sm: "isSm",
|
|
13556
|
+
md: "isMd",
|
|
13557
|
+
lg: "isLg",
|
|
13558
|
+
xl: "isXl",
|
|
13559
|
+
"2xl": "is2xl",
|
|
13560
|
+
"3xl": "is3xl",
|
|
13561
|
+
};
|
|
13562
|
+
/**
|
|
13563
|
+
* The default state for sizes, with all values set to false.
|
|
13564
|
+
*/
|
|
13565
|
+
const defaultBreakpointState = {
|
|
13566
|
+
isXs: false,
|
|
13567
|
+
isSm: false,
|
|
13568
|
+
isMd: false,
|
|
13569
|
+
isLg: false,
|
|
13570
|
+
isXl: false,
|
|
13571
|
+
is2xl: false,
|
|
13572
|
+
is3xl: false,
|
|
13573
|
+
};
|
|
13574
|
+
/**
|
|
13575
|
+
* Creates a breakpoint state object based on a width value
|
|
13576
|
+
*/
|
|
13577
|
+
const createBreakpointState = ({ width }) => {
|
|
13578
|
+
return sharedUtils.objectEntries(uiDesignTokens.themeScreenSizeAsNumber).reduce((acc, [size, minWidth]) => ({
|
|
13579
|
+
...acc,
|
|
13580
|
+
[breakpointPropsMap[size]]: width >= minWidth,
|
|
13581
|
+
}), { ...defaultBreakpointState });
|
|
13582
|
+
};
|
|
13583
|
+
|
|
14426
13584
|
/**
|
|
14427
13585
|
* A custom React hook that provides real-time information about a container's size.
|
|
14428
13586
|
*
|
|
@@ -14584,77 +13742,6 @@ const useContinuousTimeout = ({ onTimeout, onMaxRetries, duration, maxRetries, }
|
|
|
14584
13742
|
}), [startTimeouts, stopTimeouts, isRunning]);
|
|
14585
13743
|
};
|
|
14586
13744
|
|
|
14587
|
-
const DEFAULT_PARAM_NAME = "c";
|
|
14588
|
-
const DEFAULT_DEBOUNCE_MS = 300;
|
|
14589
|
-
/**
|
|
14590
|
-
* Reads a cursor from the URL search params on mount and provides a debounced
|
|
14591
|
-
* updater for writing it back. Uses `replace: true` to avoid polluting browser
|
|
14592
|
-
* history with every scroll position change.
|
|
14593
|
-
*
|
|
14594
|
-
* Wire `initialCursor` into `usePaginationQuery` and `updateCursor` into
|
|
14595
|
-
* `useBidirectionalScroll`'s `onTopItemChange` callback (mapping index to cursor).
|
|
14596
|
-
*
|
|
14597
|
-
* A pending write is dropped whenever a path-changing navigation starts or the
|
|
14598
|
-
* location has moved on: the write targets `to: "."`, so landing it after the
|
|
14599
|
-
* user opened a row would replace their destination with the list they came
|
|
14600
|
-
* from.
|
|
14601
|
-
*/
|
|
14602
|
-
const useCursorUrlSync = ({ paramName = DEFAULT_PARAM_NAME, debounceMs = DEFAULT_DEBOUNCE_MS, } = {}) => {
|
|
14603
|
-
const router = reactRouter.useRouter();
|
|
14604
|
-
const navigate = reactRouter.useNavigate();
|
|
14605
|
-
// Read the cursor off the router rather than `useSearch`, which would
|
|
14606
|
-
// subscribe the table to its own writes and re-render every row mid-scroll.
|
|
14607
|
-
const [initialCursor] = react.useState(() => {
|
|
14608
|
-
const entry = Object.entries(router.latestLocation.search).find(([key]) => key === paramName);
|
|
14609
|
-
return typeof entry?.[1] === "string" ? entry[1] : undefined;
|
|
14610
|
-
});
|
|
14611
|
-
const timerRef = react.useRef(undefined);
|
|
14612
|
-
const cancelPendingUpdate = react.useCallback(() => {
|
|
14613
|
-
if (timerRef.current !== undefined) {
|
|
14614
|
-
clearTimeout(timerRef.current);
|
|
14615
|
-
timerRef.current = undefined;
|
|
14616
|
-
}
|
|
14617
|
-
}, []);
|
|
14618
|
-
const paramNameRef = react.useRef(paramName);
|
|
14619
|
-
react.useEffect(() => {
|
|
14620
|
-
paramNameRef.current = paramName;
|
|
14621
|
-
}, [paramName]);
|
|
14622
|
-
const updateCursor = react.useCallback((cursor) => {
|
|
14623
|
-
cancelPendingUpdate();
|
|
14624
|
-
const scheduledAtPathname = router.latestLocation.pathname;
|
|
14625
|
-
timerRef.current = setTimeout(() => {
|
|
14626
|
-
timerRef.current = undefined;
|
|
14627
|
-
if (router.latestLocation.pathname !== scheduledAtPathname) {
|
|
14628
|
-
return;
|
|
14629
|
-
}
|
|
14630
|
-
void navigate({
|
|
14631
|
-
to: ".",
|
|
14632
|
-
search: (prev) => ({
|
|
14633
|
-
...prev,
|
|
14634
|
-
[paramNameRef.current]: cursor,
|
|
14635
|
-
}),
|
|
14636
|
-
// Without this the router drops the fragment, and with it the table layout.
|
|
14637
|
-
hash: true,
|
|
14638
|
-
replace: true,
|
|
14639
|
-
});
|
|
14640
|
-
}, debounceMs);
|
|
14641
|
-
}, [cancelPendingUpdate, debounceMs, navigate, router]);
|
|
14642
|
-
// Only path-changing navigations invalidate a pending write. Same-pathname
|
|
14643
|
-
// navigations (filter bar, table persistence, map viewBox) share this URL and
|
|
14644
|
-
// their writes must not cancel ours. Cancelling here rather than relying on
|
|
14645
|
-
// the pathname check alone also covers navigating away and back within the
|
|
14646
|
-
// debounce window, which would otherwise land a stale cursor on a fresh list.
|
|
14647
|
-
react.useEffect(() => router.subscribe("onBeforeNavigate", event => {
|
|
14648
|
-
if (event.pathChanged) {
|
|
14649
|
-
cancelPendingUpdate();
|
|
14650
|
-
}
|
|
14651
|
-
}), [cancelPendingUpdate, router]);
|
|
14652
|
-
react.useEffect(() => {
|
|
14653
|
-
return cancelPendingUpdate;
|
|
14654
|
-
}, [cancelPendingUpdate]);
|
|
14655
|
-
return react.useMemo(() => ({ initialCursor, updateCursor }), [initialCursor, updateCursor]);
|
|
14656
|
-
};
|
|
14657
|
-
|
|
14658
13745
|
// Copied from https://github.com/rexxars/use-device-pixel-ratio
|
|
14659
13746
|
/**
|
|
14660
13747
|
* Get the device pixel ratio, potentially rounded and capped.
|
|
@@ -15469,6 +14556,69 @@ function useTextSearch(items, props) {
|
|
|
15469
14556
|
return react.useMemo(() => [result, searchText, setSearchText], [result, searchText, setSearchText]);
|
|
15470
14557
|
}
|
|
15471
14558
|
|
|
14559
|
+
const canUseMatchMedia = () => typeof window !== "undefined" && typeof window.matchMedia === "function";
|
|
14560
|
+
const getViewportBreakpointState = () => {
|
|
14561
|
+
if (!canUseMatchMedia())
|
|
14562
|
+
return { ...defaultBreakpointState };
|
|
14563
|
+
return sharedUtils.objectEntries(uiDesignTokens.themeScreenSizeAsNumber).reduce((acc, [size, minWidth]) => ({
|
|
14564
|
+
...acc,
|
|
14565
|
+
[breakpointPropsMap[size]]: window.matchMedia(`(min-width: ${minWidth}px)`).matches,
|
|
14566
|
+
}), { ...defaultBreakpointState });
|
|
14567
|
+
};
|
|
14568
|
+
/**
|
|
14569
|
+
* Returns real-time boolean flags for each design-token breakpoint based on viewport width.
|
|
14570
|
+
*
|
|
14571
|
+
* **When to use:**
|
|
14572
|
+
* - Use `useViewportBreakpoints` when you need to respond to the **full viewport width**
|
|
14573
|
+
* — for example, toggling a mobile navigation drawer or switching a global layout shell.
|
|
14574
|
+
* - **Prefer `useContainerBreakpoints`** for component-level responsiveness, as it
|
|
14575
|
+
* responds to the parent container's width and works correctly when a component appears
|
|
14576
|
+
* in different layout contexts (sidebar, main content, widget).
|
|
14577
|
+
* - Pass `{ skip: true }` when you only need the initial value and don't want ongoing
|
|
14578
|
+
* `matchMedia` listeners.
|
|
14579
|
+
*
|
|
14580
|
+
* @param {object} [options] - Configuration options
|
|
14581
|
+
* @param {boolean} [options.skip] - Whether to skip observing for viewport changes (default: false)
|
|
14582
|
+
* @returns {BreakpointState} An object containing boolean values for each viewport size breakpoint.
|
|
14583
|
+
* @example
|
|
14584
|
+
* const MyComponent = () => {
|
|
14585
|
+
* const viewportSize = useViewportBreakpoints();
|
|
14586
|
+
*
|
|
14587
|
+
* if (viewportSize.isLg) {
|
|
14588
|
+
* return <LargeScreenLayout />;
|
|
14589
|
+
* }
|
|
14590
|
+
*
|
|
14591
|
+
* return viewportSize.isMd ? <MediumScreenLayout /> : <SmallLayout />;
|
|
14592
|
+
* }
|
|
14593
|
+
*/
|
|
14594
|
+
const useViewportBreakpoints = (options = {}) => {
|
|
14595
|
+
const { skip = false } = options;
|
|
14596
|
+
const [viewportSize, setViewportSize] = react.useState(getViewportBreakpointState);
|
|
14597
|
+
const updateViewportSize = react.useCallback(() => {
|
|
14598
|
+
setViewportSize(getViewportBreakpointState());
|
|
14599
|
+
}, []);
|
|
14600
|
+
const updateViewportSizeRef = react.useRef(updateViewportSize);
|
|
14601
|
+
react.useEffect(() => {
|
|
14602
|
+
updateViewportSizeRef.current = updateViewportSize;
|
|
14603
|
+
}, [updateViewportSize]);
|
|
14604
|
+
react.useEffect(() => {
|
|
14605
|
+
if (skip || !canUseMatchMedia()) {
|
|
14606
|
+
return;
|
|
14607
|
+
}
|
|
14608
|
+
updateViewportSizeRef.current();
|
|
14609
|
+
const mediaQueryLists = sharedUtils.objectEntries(uiDesignTokens.themeScreenSizeAsNumber).map(([_, minWidth]) => window.matchMedia(`(min-width: ${minWidth}px)`));
|
|
14610
|
+
mediaQueryLists.forEach(mql => {
|
|
14611
|
+
mql.addEventListener("change", updateViewportSizeRef.current);
|
|
14612
|
+
});
|
|
14613
|
+
return () => {
|
|
14614
|
+
mediaQueryLists.forEach(mql => {
|
|
14615
|
+
mql.removeEventListener("change", updateViewportSizeRef.current);
|
|
14616
|
+
});
|
|
14617
|
+
};
|
|
14618
|
+
}, [skip]);
|
|
14619
|
+
return viewportSize;
|
|
14620
|
+
};
|
|
14621
|
+
|
|
15472
14622
|
const hasFocus = () => typeof document !== "undefined" && document.hasFocus();
|
|
15473
14623
|
/**
|
|
15474
14624
|
* Use this hook to disable functionality while the tab is hidden within the browser or to react to focus or blur events
|
|
@@ -15517,7 +14667,6 @@ Object.defineProperty(exports, "storageSerializer", {
|
|
|
15517
14667
|
});
|
|
15518
14668
|
exports.Alert = Alert;
|
|
15519
14669
|
exports.Badge = Badge;
|
|
15520
|
-
exports.Breadcrumb = Breadcrumb;
|
|
15521
14670
|
exports.Button = Button;
|
|
15522
14671
|
exports.Card = Card;
|
|
15523
14672
|
exports.CardBody = CardBody;
|
|
@@ -15549,7 +14698,6 @@ exports.LabeledValue = LabeledValue;
|
|
|
15549
14698
|
exports.LabeledValueList = LabeledValueList;
|
|
15550
14699
|
exports.List = List;
|
|
15551
14700
|
exports.ListItem = ListItem;
|
|
15552
|
-
exports.MAX_URL_LENGTH = MAX_URL_LENGTH;
|
|
15553
14701
|
exports.MenuContent = MenuContent;
|
|
15554
14702
|
exports.MenuDivider = MenuDivider;
|
|
15555
14703
|
exports.MenuItem = MenuItem;
|
|
@@ -15658,7 +14806,6 @@ exports.useClickOutside = useClickOutside;
|
|
|
15658
14806
|
exports.useContainerBreakpoints = useContainerBreakpoints;
|
|
15659
14807
|
exports.useContinuousTimeout = useContinuousTimeout;
|
|
15660
14808
|
exports.useCopyToClipboard = useCopyToClipboard;
|
|
15661
|
-
exports.useCursorUrlSync = useCursorUrlSync;
|
|
15662
14809
|
exports.useCustomEncoding = useCustomEncoding;
|
|
15663
14810
|
exports.useDebounce = useDebounce;
|
|
15664
14811
|
exports.useDevicePixelRatio = useDevicePixelRatio;
|
|
@@ -15684,7 +14831,6 @@ exports.useModifierKey = useModifierKey;
|
|
|
15684
14831
|
exports.useOptionalPopoverContext = useOptionalPopoverContext;
|
|
15685
14832
|
exports.useOverflowBorder = useOverflowBorder;
|
|
15686
14833
|
exports.useOverflowItems = useOverflowItems;
|
|
15687
|
-
exports.usePersistedState = usePersistedState;
|
|
15688
14834
|
exports.usePopoverContext = usePopoverContext;
|
|
15689
14835
|
exports.usePrevious = usePrevious;
|
|
15690
14836
|
exports.usePrompt = usePrompt;
|
|
@@ -15693,7 +14839,6 @@ exports.useRelayPagination = useRelayPagination;
|
|
|
15693
14839
|
exports.useResize = useResize;
|
|
15694
14840
|
exports.useScrollBlock = useScrollBlock;
|
|
15695
14841
|
exports.useScrollDetection = useScrollDetection;
|
|
15696
|
-
exports.useSearchParamSync = useSearchParamSync;
|
|
15697
14842
|
exports.useSelfUpdatingRef = useSelfUpdatingRef;
|
|
15698
14843
|
exports.useSessionStorage = useSessionStorage;
|
|
15699
14844
|
exports.useSessionStorageReducer = useSessionStorageReducer;
|