@primitiv-ui/react 0.1.29 → 0.1.30
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 +15 -2
- package/package.json +1 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
- package/src/Accordion/Accordion.tsx +7 -7
- package/src/Accordion/README.md +2 -2
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
- package/src/Breadcrumb/Breadcrumb.tsx +7 -7
- package/src/Breadcrumb/README.md +2 -2
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
- package/src/Breadcrumb/types.ts +1 -1
- package/src/Button/__tests__/Button.contract.test.tsx +1 -1
- package/src/Carousel/Carousel.tsx +6 -6
- package/src/Carousel/README.md +22 -22
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
- package/src/Carousel/loopEngine.ts +1 -1
- package/src/Carousel/types.ts +1 -1
- package/src/Checkbox/Checkbox.tsx +2 -2
- package/src/CheckboxCard/CheckboxCard.tsx +1 -1
- package/src/CheckboxCard/README.md +1 -1
- package/src/Collapsible/README.md +2 -2
- package/src/Combobox/Combobox.tsx +508 -0
- package/src/Combobox/ComboboxContext.ts +23 -0
- package/src/Combobox/README.md +170 -0
- package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
- package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
- package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
- package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
- package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
- package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
- package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
- package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
- package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
- package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
- package/src/Combobox/hooks/index.ts +1 -0
- package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
- package/src/Combobox/index.ts +3 -0
- package/src/Combobox/types.ts +133 -0
- package/src/ContextMenu/ContextMenu.tsx +3 -3
- package/src/ContextMenu/README.md +1 -1
- package/src/DirectionProvider/DirectionProvider.tsx +5 -5
- package/src/DirectionProvider/README.md +4 -4
- package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
- package/src/Drawer/Drawer.tsx +5 -5
- package/src/Drawer/README.md +2 -2
- package/src/Dropdown/Dropdown.tsx +2 -2
- package/src/Dropdown/README.md +7 -5
- package/src/EmptyState/README.md +2 -2
- package/src/Field/README.md +1 -1
- package/src/Field/hooks/useFieldProps.ts +1 -1
- package/src/Fieldset/Fieldset.tsx +3 -3
- package/src/Fieldset/README.md +2 -2
- package/src/Input/Input.tsx +1 -1
- package/src/Input/README.md +2 -2
- package/src/Listbox/Listbox.tsx +475 -0
- package/src/Listbox/ListboxContext.ts +48 -0
- package/src/Listbox/README.md +248 -0
- package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
- package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
- package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
- package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
- package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
- package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
- package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
- package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
- package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
- package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
- package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
- package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
- package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
- package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
- package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
- package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
- package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
- package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
- package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
- package/src/Listbox/constants.ts +2 -0
- package/src/Listbox/hooks/index.ts +1 -0
- package/src/Listbox/hooks/useListboxRoot.ts +407 -0
- package/src/Listbox/index.ts +3 -0
- package/src/Listbox/types.ts +238 -0
- package/src/MillerColumns/MillerColumns.tsx +104 -30
- package/src/MillerColumns/README.md +90 -20
- package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
- package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
- package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
- package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
- package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
- package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
- package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
- package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
- package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
- package/src/MillerColumns/constants.ts +2 -0
- package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
- package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
- package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
- package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
- package/src/MillerColumns/index.ts +4 -0
- package/src/MillerColumns/types.ts +56 -6
- package/src/MillerColumns/utils.ts +69 -3
- package/src/Modal/Modal.tsx +4 -4
- package/src/Modal/README.md +5 -5
- package/src/Modal/types.ts +1 -1
- package/src/NavigationMenu/NavigationMenu.tsx +6 -6
- package/src/NavigationMenu/README.md +3 -3
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
- package/src/Popover/Popover.tsx +2 -2
- package/src/Popover/README.md +1 -1
- package/src/Portal/Portal.tsx +2 -2
- package/src/Portal/README.md +2 -2
- package/src/Progress/README.md +1 -1
- package/src/Radio/Radio.tsx +1 -1
- package/src/RadioGroup/README.md +2 -2
- package/src/RadioGroup/RadioGroup.tsx +1 -1
- package/src/SegmentedControl/README.md +2 -2
- package/src/SegmentedControl/SegmentedControl.tsx +1 -1
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
- package/src/Select/README.md +2 -2
- package/src/Select/Select.tsx +8 -8
- package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
- package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
- package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
- package/src/Select/types.ts +2 -2
- package/src/SkipNav/README.md +3 -3
- package/src/SkipNav/SkipNav.tsx +5 -5
- package/src/Slider/README.md +37 -7
- package/src/Slider/Slider.tsx +9 -3
- package/src/Slot/Slot.tsx +2 -2
- package/src/Slot/__tests__/Slot.test.tsx +2 -2
- package/src/Slot/types.ts +1 -1
- package/src/SplitButton/README.md +225 -0
- package/src/SplitButton/SplitButton.tsx +457 -0
- package/src/SplitButton/SplitButtonContext.ts +26 -0
- package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
- package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
- package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
- package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
- package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
- package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
- package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
- package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
- package/src/SplitButton/index.ts +2 -0
- package/src/SplitButton/types.ts +186 -0
- package/src/Switch/README.md +1 -1
- package/src/Switch/Switch.tsx +2 -2
- package/src/Switch/types.ts +1 -1
- package/src/Table/README.md +110 -8
- package/src/Table/Table.tsx +206 -10
- package/src/Table/TableExpandableContext.ts +7 -0
- package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
- package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
- package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
- package/src/Table/types.ts +125 -0
- package/src/Tabs/README.md +4 -4
- package/src/Tabs/Tabs.tsx +5 -4
- package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
- package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
- package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
- package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
- package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
- package/src/Tabs/hooks/useTabsContent.ts +5 -0
- package/src/Tabs/hooks/useTabsRoot.ts +29 -5
- package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
- package/src/ToggleGroup/README.md +4 -4
- package/src/Tooltip/README.md +7 -7
- package/src/Tooltip/Tooltip.tsx +2 -2
- package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
- package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
- package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
- package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
- package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
- package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
- package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
- package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
- package/src/Tree/README.md +69 -5
- package/src/Tree/Tree.tsx +1 -1
- package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
- package/src/Tree/hooks/useTreePath.ts +33 -1
- package/src/Tree/index.ts +6 -1
- package/src/Tree/utils.ts +46 -4
- package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useTypeahead.ts +95 -0
- package/src/index.ts +6 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
- package/src/useLocalStorage/index.ts +1 -0
- package/src/useLocalStorage/useLocalStorage.ts +140 -0
- package/src/useMediaQuery/README.md +66 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
- package/src/useMediaQuery/index.ts +1 -0
- package/src/useMediaQuery/useMediaQuery.ts +49 -0
- package/src/usePagination/README.md +170 -0
- package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
- package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
- package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
- package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
- package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
- package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
- package/src/usePagination/index.ts +1 -0
- package/src/usePagination/usePagination.ts +292 -0
- package/src/utils/getKeyToActionMap.ts +1 -1
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from "./Carousel/index.ts";
|
|
|
8
8
|
export * from "./Checkbox/index.ts";
|
|
9
9
|
export * from "./CheckboxCard/index.ts";
|
|
10
10
|
export * from "./Collapsible/index.ts";
|
|
11
|
+
export * from "./Combobox/index.ts";
|
|
11
12
|
export * from "./ContextMenu/index.ts";
|
|
12
13
|
export * from "./DirectionProvider/index.ts";
|
|
13
14
|
export * from "./Divider/index.ts";
|
|
@@ -18,6 +19,7 @@ export * from "./Field/index.ts";
|
|
|
18
19
|
export * from "./Fieldset/index.ts";
|
|
19
20
|
export * from "./Input/index.ts";
|
|
20
21
|
export * from "./InputGroup/index.ts";
|
|
22
|
+
export * from "./Listbox/index.ts";
|
|
21
23
|
export * from "./MillerColumns/index.ts";
|
|
22
24
|
export * from "./Modal/index.ts";
|
|
23
25
|
export * from "./NavigationMenu/index.ts";
|
|
@@ -33,6 +35,7 @@ export * from "./SkipNav/index.ts";
|
|
|
33
35
|
export * from "./Slider/index.ts";
|
|
34
36
|
export { Slot } from "./Slot/index.ts";
|
|
35
37
|
export type { SlotProps } from "./Slot/index.ts";
|
|
38
|
+
export * from "./SplitButton/index.ts";
|
|
36
39
|
export * from "./Status/index.ts";
|
|
37
40
|
export * from "./Switch/index.ts";
|
|
38
41
|
export * from "./Table/index.ts";
|
|
@@ -42,5 +45,8 @@ export * from "./Toggle/index.ts";
|
|
|
42
45
|
export * from "./ToggleGroup/index.ts";
|
|
43
46
|
export * from "./Tooltip/index.ts";
|
|
44
47
|
export * from "./Tree/index.ts";
|
|
48
|
+
export * from "./useLocalStorage/index.ts";
|
|
49
|
+
export * from "./useMediaQuery/index.ts";
|
|
50
|
+
export * from "./usePagination/index.ts";
|
|
45
51
|
export * from "./VisuallyHidden/index.ts";
|
|
46
52
|
export type { HeadingLevel } from "./types.ts";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
import { useLocalStorage } from "../useLocalStorage.ts";
|
|
5
|
+
|
|
6
|
+
const KEY = "primitiv-test-key";
|
|
7
|
+
|
|
8
|
+
function Probe({ fallback }: { fallback: string }) {
|
|
9
|
+
const [value] = useLocalStorage(KEY, fallback);
|
|
10
|
+
return <span data-testid="value">{value}</span>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("useLocalStorage reading", () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
window.localStorage.clear();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should return the fallback when nothing is stored under the key", () => {
|
|
19
|
+
render(<Probe fallback="light" />);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByTestId("value")).toHaveTextContent("light");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should return the stored value when one is present", () => {
|
|
25
|
+
window.localStorage.setItem(KEY, JSON.stringify("dark"));
|
|
26
|
+
|
|
27
|
+
render(<Probe fallback="light" />);
|
|
28
|
+
|
|
29
|
+
expect(screen.getByTestId("value")).toHaveTextContent("dark");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { act, render, screen } from "@testing-library/react";
|
|
3
|
+
import { userEvent } from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { useLocalStorage } from "../useLocalStorage.ts";
|
|
6
|
+
|
|
7
|
+
const KEY = "primitiv-test-key";
|
|
8
|
+
|
|
9
|
+
function Probe() {
|
|
10
|
+
const [value, setValue] = useLocalStorage(KEY, "light");
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<span data-testid="value">{value}</span>
|
|
14
|
+
<button type="button" onClick={() => setValue("dark")}>
|
|
15
|
+
set
|
|
16
|
+
</button>
|
|
17
|
+
</>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Storage methods are stubbed on `Storage.prototype`, NOT on
|
|
23
|
+
* `window.localStorage`. jsdom's localStorage is a Proxy, so an instance spy
|
|
24
|
+
* silently never intercepts — `vi.spyOn(window.localStorage, "setItem")`
|
|
25
|
+
* records zero calls and the real write goes through. That is exactly the shape
|
|
26
|
+
* of bug that makes a test pass for the wrong reason, so each test below stores
|
|
27
|
+
* a real value first, ensuring the fallback can ONLY come from the throw path.
|
|
28
|
+
*/
|
|
29
|
+
describe("useLocalStorage resilience", () => {
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
window.localStorage.clear();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should fall back when the stored value is not valid JSON", () => {
|
|
36
|
+
// Storage is shared, long-lived and hand-editable, so a bad value has to
|
|
37
|
+
// degrade rather than throw.
|
|
38
|
+
window.localStorage.setItem(KEY, "{not json");
|
|
39
|
+
|
|
40
|
+
render(<Probe />);
|
|
41
|
+
|
|
42
|
+
expect(screen.getByTestId("value")).toHaveTextContent("light");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should fall back when reading storage throws", () => {
|
|
46
|
+
// A readable "dark" is present, so "light" can only mean the read threw.
|
|
47
|
+
window.localStorage.setItem(KEY, JSON.stringify("dark"));
|
|
48
|
+
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
|
49
|
+
throw new Error("SecurityError");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
render(<Probe />);
|
|
53
|
+
|
|
54
|
+
expect(screen.getByTestId("value")).toHaveTextContent("light");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should leave the value untouched when writing to storage throws", async () => {
|
|
58
|
+
const user = userEvent.setup();
|
|
59
|
+
render(<Probe />);
|
|
60
|
+
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
|
61
|
+
throw new Error("QuotaExceededError");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await user.click(screen.getByRole("button", { name: "set" }));
|
|
65
|
+
|
|
66
|
+
// The write failed, so storage still holds nothing and the re-read returns
|
|
67
|
+
// the fallback — the hook does not optimistically keep an unsaved value.
|
|
68
|
+
expect(screen.getByTestId("value")).toHaveTextContent("light");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should pick up a change made in another tab", () => {
|
|
72
|
+
render(<Probe />);
|
|
73
|
+
|
|
74
|
+
// What the browser dispatches when a different document writes the key.
|
|
75
|
+
act(() => {
|
|
76
|
+
window.localStorage.setItem(KEY, JSON.stringify("dark"));
|
|
77
|
+
window.dispatchEvent(new StorageEvent("storage", { key: KEY }));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(screen.getByTestId("value")).toHaveTextContent("dark");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should stop listening once the last consumer unmounts", () => {
|
|
84
|
+
const add = vi.spyOn(window, "addEventListener");
|
|
85
|
+
const remove = vi.spyOn(window, "removeEventListener");
|
|
86
|
+
|
|
87
|
+
const { unmount } = render(<Probe />);
|
|
88
|
+
unmount();
|
|
89
|
+
|
|
90
|
+
const added = add.mock.calls.filter(([type]) => type === "storage").length;
|
|
91
|
+
const removed = remove.mock.calls.filter(
|
|
92
|
+
([type]) => type === "storage",
|
|
93
|
+
).length;
|
|
94
|
+
expect(added).toBeGreaterThan(0);
|
|
95
|
+
expect(removed).toBe(added);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
|
|
5
|
+
import { useLocalStorage } from "../useLocalStorage.ts";
|
|
6
|
+
|
|
7
|
+
function Probe() {
|
|
8
|
+
const [value] = useLocalStorage("primitiv-theme", "light");
|
|
9
|
+
return <>{value}</>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("useLocalStorage SSR", () => {
|
|
13
|
+
it("should render the fallback when there is no window to read storage from", () => {
|
|
14
|
+
expect(typeof window).toBe("undefined");
|
|
15
|
+
|
|
16
|
+
const markup = renderToStaticMarkup(<Probe />);
|
|
17
|
+
|
|
18
|
+
expect(markup).toBe("light");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { userEvent } from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { useLocalStorage } from "../useLocalStorage.ts";
|
|
6
|
+
|
|
7
|
+
const KEY = "primitiv-test-key";
|
|
8
|
+
|
|
9
|
+
function Probe({ next }: { next: string }) {
|
|
10
|
+
const [value, setValue] = useLocalStorage(KEY, "light");
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<span data-testid="value">{value}</span>
|
|
14
|
+
<button type="button" onClick={() => setValue(next)}>
|
|
15
|
+
set
|
|
16
|
+
</button>
|
|
17
|
+
</>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Two independent consumers of the SAME key, to prove they stay in step. */
|
|
22
|
+
function TwoProbes() {
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<Probe next="dark" />
|
|
26
|
+
<SecondReader />
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function SecondReader() {
|
|
32
|
+
const [value] = useLocalStorage(KEY, "light");
|
|
33
|
+
return <span data-testid="second">{value}</span>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("useLocalStorage writing", () => {
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
window.localStorage.clear();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should update the returned value when the setter is called", async () => {
|
|
42
|
+
const user = userEvent.setup();
|
|
43
|
+
render(<Probe next="dark" />);
|
|
44
|
+
|
|
45
|
+
await user.click(screen.getByRole("button", { name: "set" }));
|
|
46
|
+
|
|
47
|
+
expect(screen.getByTestId("value")).toHaveTextContent("dark");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should persist the new value to localStorage as JSON", async () => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
render(<Probe next="dark" />);
|
|
53
|
+
|
|
54
|
+
await user.click(screen.getByRole("button", { name: "set" }));
|
|
55
|
+
|
|
56
|
+
expect(window.localStorage.getItem(KEY)).toBe(JSON.stringify("dark"));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
* The `storage` event does NOT fire in the document that performed the write,
|
|
61
|
+
* so without an in-document notification a second consumer of the same key
|
|
62
|
+
* would keep rendering the stale value until something else re-rendered it.
|
|
63
|
+
*/
|
|
64
|
+
it("should update every consumer of the same key in the same document", async () => {
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
render(<TwoProbes />);
|
|
67
|
+
|
|
68
|
+
await user.click(screen.getByRole("button", { name: "set" }));
|
|
69
|
+
|
|
70
|
+
expect(screen.getByTestId("second")).toHaveTextContent("dark");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useLocalStorage } from "./useLocalStorage.ts";
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-document subscribers, keyed by storage key.
|
|
5
|
+
*
|
|
6
|
+
* The browser's own `storage` event only fires in OTHER documents, never the one
|
|
7
|
+
* that performed the write — so a second consumer of the same key in this
|
|
8
|
+
* document would keep rendering a stale value. These listeners cover that case;
|
|
9
|
+
* the `storage` event covers other tabs.
|
|
10
|
+
*/
|
|
11
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The listener set for a key, created on first use.
|
|
15
|
+
*
|
|
16
|
+
* Empty sets are deliberately never pruned. Pruning them would mean `notify`
|
|
17
|
+
* had to guard against a missing set — a branch nothing can reach, since the
|
|
18
|
+
* only way to obtain the setter is from a mounted hook that has already
|
|
19
|
+
* subscribed. One empty `Set` per key, for a handful of static keys, is a
|
|
20
|
+
* better trade than an untestable defensive branch.
|
|
21
|
+
*/
|
|
22
|
+
function listenersFor(key: string): Set<() => void> {
|
|
23
|
+
let forKey = listeners.get(key);
|
|
24
|
+
if (!forKey) {
|
|
25
|
+
forKey = new Set();
|
|
26
|
+
listeners.set(key, forKey);
|
|
27
|
+
}
|
|
28
|
+
return forKey;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function notify(key: string): void {
|
|
32
|
+
for (const listener of listenersFor(key)) listener();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function read(key: string): string | null {
|
|
36
|
+
try {
|
|
37
|
+
return window.localStorage.getItem(key);
|
|
38
|
+
} catch {
|
|
39
|
+
// Storage can throw rather than merely be empty — Safari private mode and
|
|
40
|
+
// "block all cookies" both do. Treat it as "nothing stored".
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The server has no storage, so nothing is stored. */
|
|
46
|
+
function getServerSnapshot(): null {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads and writes a JSON-serialisable value in `localStorage`, re-rendering
|
|
52
|
+
* the consumer whenever it changes — including when another component, or
|
|
53
|
+
* another browser tab, changes the same key.
|
|
54
|
+
*
|
|
55
|
+
* Built on {@link https://react.dev/reference/react/useSyncExternalStore |
|
|
56
|
+
* `useSyncExternalStore`}, mirroring {@link useMediaQuery}: `localStorage` is
|
|
57
|
+
* external mutable state, so subscribing to it is more correct than copying it
|
|
58
|
+
* into component state and hoping the copies stay in step.
|
|
59
|
+
*
|
|
60
|
+
* **The snapshot is the raw string, not the parsed value.** `getSnapshot` must
|
|
61
|
+
* return a referentially stable result or React re-renders forever, and
|
|
62
|
+
* `JSON.parse` yields a fresh object every call. Returning the raw string — a
|
|
63
|
+
* primitive — is stable by definition, and the parse happens in a `useMemo`
|
|
64
|
+
* keyed on it.
|
|
65
|
+
*
|
|
66
|
+
* **Server rendering.** There is no `localStorage` on the server, so the server
|
|
67
|
+
* snapshot is `null` and the hook returns `fallback`. The client then reads the
|
|
68
|
+
* real value after hydration, which means a persisted choice can flash to its
|
|
69
|
+
* stored state on first paint — unavoidable without a cookie or a blocking
|
|
70
|
+
* inline script, and the same trade-off `useMediaQuery` makes.
|
|
71
|
+
*
|
|
72
|
+
* **Malformed data falls back.** Anything that fails to parse is treated as
|
|
73
|
+
* absent rather than thrown: storage is shared, long-lived and editable by
|
|
74
|
+
* hand, so a bad value must not break the page.
|
|
75
|
+
*
|
|
76
|
+
* @example Persisting a theme choice
|
|
77
|
+
* ```tsx
|
|
78
|
+
* const [theme, setTheme] = useLocalStorage<"light" | "dark">(
|
|
79
|
+
* "primitiv-theme",
|
|
80
|
+
* "light",
|
|
81
|
+
* );
|
|
82
|
+
* return (
|
|
83
|
+
* <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
|
|
84
|
+
* Switch theme
|
|
85
|
+
* </button>
|
|
86
|
+
* );
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* @param key - The `localStorage` key to read and write.
|
|
90
|
+
* @param fallback - Returned when the key is absent, unreadable, or holds
|
|
91
|
+
* malformed JSON.
|
|
92
|
+
* @returns A `[value, setValue]` tuple. `setValue` writes JSON to storage and
|
|
93
|
+
* notifies every consumer of the same key.
|
|
94
|
+
*/
|
|
95
|
+
export function useLocalStorage<T>(
|
|
96
|
+
key: string,
|
|
97
|
+
fallback: T,
|
|
98
|
+
): [T, (next: T) => void] {
|
|
99
|
+
const subscribe = useCallback(
|
|
100
|
+
(onStoreChange: () => void) => {
|
|
101
|
+
const forKey = listenersFor(key);
|
|
102
|
+
forKey.add(onStoreChange);
|
|
103
|
+
window.addEventListener("storage", onStoreChange);
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
forKey.delete(onStoreChange);
|
|
107
|
+
window.removeEventListener("storage", onStoreChange);
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
[key],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const getSnapshot = useCallback(() => read(key), [key]);
|
|
114
|
+
|
|
115
|
+
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
116
|
+
|
|
117
|
+
const value = useMemo<T>(() => {
|
|
118
|
+
if (raw === null) return fallback;
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(raw) as T;
|
|
121
|
+
} catch {
|
|
122
|
+
return fallback;
|
|
123
|
+
}
|
|
124
|
+
}, [raw, fallback]);
|
|
125
|
+
|
|
126
|
+
const setValue = useCallback(
|
|
127
|
+
(next: T) => {
|
|
128
|
+
try {
|
|
129
|
+
window.localStorage.setItem(key, JSON.stringify(next));
|
|
130
|
+
} catch {
|
|
131
|
+
// Writing can fail on a full or blocked store. Still notify, so the
|
|
132
|
+
// in-memory read path stays consistent with whatever storage now holds.
|
|
133
|
+
}
|
|
134
|
+
notify(key);
|
|
135
|
+
},
|
|
136
|
+
[key],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return [value, setValue];
|
|
140
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# useMediaQuery
|
|
2
|
+
|
|
3
|
+
Tracks whether a CSS media query currently matches, re-rendering the
|
|
4
|
+
consumer whenever it changes.
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
import { useMediaQuery } from "@primitiv-ui/react";
|
|
8
|
+
|
|
9
|
+
function NavigationMenu() {
|
|
10
|
+
const isDesktop = useMediaQuery("(min-width: 64rem)");
|
|
11
|
+
|
|
12
|
+
return isDesktop ? <DesktopNav /> : <MobileMenu />;
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Signature
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
function useMediaQuery(query: string): boolean;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Param | Type | Notes |
|
|
23
|
+
| ------- | -------- | -------------------------------------------------- |
|
|
24
|
+
| `query` | `string` | A media query string, e.g. `"(min-width: 40rem)"`. |
|
|
25
|
+
|
|
26
|
+
Returns whether `query` currently matches.
|
|
27
|
+
|
|
28
|
+
## Generic, not tied to Primitiv's breakpoint scale
|
|
29
|
+
|
|
30
|
+
`useMediaQuery` doesn't know about Primitiv's own `xs`/`sm`/`md`/`lg`/`xl`/`2xl`
|
|
31
|
+
tokens or any other design values — pass whatever query string you need,
|
|
32
|
+
including features other than width:
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");
|
|
36
|
+
const isCompact = useMediaQuery("(max-width: 40rem)");
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If you're using the styled registry components and want Primitiv's own
|
|
40
|
+
breakpoint values, `primitiv tokens` generates a `breakpoints` constants file
|
|
41
|
+
into your project alongside the CSS token layer — build the query string from
|
|
42
|
+
that rather than hardcoding the numbers:
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { useMediaQuery } from "@primitiv-ui/react";
|
|
46
|
+
import { breakpoints } from "./styles/primitiv/breakpoints";
|
|
47
|
+
|
|
48
|
+
const isCompact = useMediaQuery(`(max-width: ${breakpoints.sm})`);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Server rendering
|
|
52
|
+
|
|
53
|
+
`useMediaQuery` renders `false` during server rendering and on the client's
|
|
54
|
+
very first paint before hydration confirms the real value — there is no
|
|
55
|
+
`window`/`matchMedia` available on the server, so `false` is the safest
|
|
56
|
+
default rather than guessing. Design around a possible flash/reflow on
|
|
57
|
+
hydration if you're conditionally rendering off the result, the same as any
|
|
58
|
+
other client-only media query hook.
|
|
59
|
+
|
|
60
|
+
## Live updates
|
|
61
|
+
|
|
62
|
+
Unlike a one-time `window.matchMedia(query).matches` read, the hook
|
|
63
|
+
subscribes to the query's real `MediaQueryList` and re-renders on `change` —
|
|
64
|
+
resizing the window, rotating a device, or the query itself changing between
|
|
65
|
+
renders (the hook unsubscribes from the old query and resubscribes to the new
|
|
66
|
+
one automatically) all update the returned value.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { useMediaQuery } from "../useMediaQuery.ts";
|
|
4
|
+
|
|
5
|
+
function mockMatchMedia(matches: boolean) {
|
|
6
|
+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
7
|
+
matches,
|
|
8
|
+
media: query,
|
|
9
|
+
onchange: null,
|
|
10
|
+
addEventListener: vi.fn(),
|
|
11
|
+
removeEventListener: vi.fn(),
|
|
12
|
+
dispatchEvent: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function MediaQueryProbe({ query }: { query: string }) {
|
|
17
|
+
const matches = useMediaQuery(query);
|
|
18
|
+
return <span data-testid="result">{matches ? "true" : "false"}</span>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("useMediaQuery matching", () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
// @ts-expect-error: restore by deletion so other suites get jsdom default.
|
|
24
|
+
delete window.matchMedia;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should return true when the query currently matches", () => {
|
|
28
|
+
mockMatchMedia(true);
|
|
29
|
+
|
|
30
|
+
render(<MediaQueryProbe query="(min-width: 40rem)" />);
|
|
31
|
+
|
|
32
|
+
expect(screen.getByTestId("result")).toHaveTextContent("true");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should return false when the query does not currently match", () => {
|
|
36
|
+
mockMatchMedia(false);
|
|
37
|
+
|
|
38
|
+
render(<MediaQueryProbe query="(min-width: 40rem)" />);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByTestId("result")).toHaveTextContent("false");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import { useMediaQuery } from "../useMediaQuery.ts";
|
|
5
|
+
|
|
6
|
+
function Probe() {
|
|
7
|
+
const matches = useMediaQuery("(min-width: 40rem)");
|
|
8
|
+
return matches ? <>matches</> : <>no-match</>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("useMediaQuery SSR", () => {
|
|
12
|
+
it("should render a static false snapshot when there is no window", () => {
|
|
13
|
+
expect(typeof window).toBe("undefined");
|
|
14
|
+
|
|
15
|
+
const markup = renderToStaticMarkup(<Probe />);
|
|
16
|
+
|
|
17
|
+
expect(markup).toBe("no-match");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { act, render, screen } from "@testing-library/react";
|
|
3
|
+
import { useMediaQuery } from "../useMediaQuery.ts";
|
|
4
|
+
|
|
5
|
+
type ChangeListener = () => void;
|
|
6
|
+
|
|
7
|
+
function createMatchMediaMock(initialMatches: Record<string, boolean>) {
|
|
8
|
+
const listeners = new Map<string, Set<ChangeListener>>();
|
|
9
|
+
|
|
10
|
+
const matchMedia = vi.fn((query: string) => ({
|
|
11
|
+
get matches() {
|
|
12
|
+
return initialMatches[query] ?? false;
|
|
13
|
+
},
|
|
14
|
+
media: query,
|
|
15
|
+
onchange: null,
|
|
16
|
+
addEventListener: vi.fn((_type: "change", listener: ChangeListener) => {
|
|
17
|
+
if (!listeners.has(query)) listeners.set(query, new Set());
|
|
18
|
+
listeners.get(query)?.add(listener);
|
|
19
|
+
}),
|
|
20
|
+
removeEventListener: vi.fn((_type: "change", listener: ChangeListener) => {
|
|
21
|
+
listeners.get(query)?.delete(listener);
|
|
22
|
+
}),
|
|
23
|
+
addListener: vi.fn(),
|
|
24
|
+
removeListener: vi.fn(),
|
|
25
|
+
dispatchEvent: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
matchMedia,
|
|
30
|
+
change(query: string, matches: boolean) {
|
|
31
|
+
initialMatches[query] = matches;
|
|
32
|
+
listeners.get(query)?.forEach((listener) => listener());
|
|
33
|
+
},
|
|
34
|
+
listenerCount(query: string) {
|
|
35
|
+
return listeners.get(query)?.size ?? 0;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function MediaQueryProbe({ query }: { query: string }) {
|
|
41
|
+
const matches = useMediaQuery(query);
|
|
42
|
+
return <span data-testid="result">{matches ? "true" : "false"}</span>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("useMediaQuery live subscription", () => {
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
// @ts-expect-error: restore by deletion so other suites get jsdom default.
|
|
48
|
+
delete window.matchMedia;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should update when the media query's match state changes", () => {
|
|
52
|
+
const mock = createMatchMediaMock({ "(min-width: 40rem)": false });
|
|
53
|
+
window.matchMedia = mock.matchMedia;
|
|
54
|
+
render(<MediaQueryProbe query="(min-width: 40rem)" />);
|
|
55
|
+
|
|
56
|
+
act(() => {
|
|
57
|
+
mock.change("(min-width: 40rem)", true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(screen.getByTestId("result")).toHaveTextContent("true");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should remove its change listener on unmount", () => {
|
|
64
|
+
const mock = createMatchMediaMock({ "(min-width: 40rem)": false });
|
|
65
|
+
window.matchMedia = mock.matchMedia;
|
|
66
|
+
const { unmount } = render(<MediaQueryProbe query="(min-width: 40rem)" />);
|
|
67
|
+
expect(mock.listenerCount("(min-width: 40rem)")).toBe(1);
|
|
68
|
+
|
|
69
|
+
unmount();
|
|
70
|
+
|
|
71
|
+
expect(mock.listenerCount("(min-width: 40rem)")).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should resubscribe when the query argument changes", () => {
|
|
75
|
+
const mock = createMatchMediaMock({
|
|
76
|
+
"(min-width: 40rem)": false,
|
|
77
|
+
"(min-width: 64rem)": true,
|
|
78
|
+
});
|
|
79
|
+
window.matchMedia = mock.matchMedia;
|
|
80
|
+
const { rerender } = render(<MediaQueryProbe query="(min-width: 40rem)" />);
|
|
81
|
+
expect(mock.listenerCount("(min-width: 40rem)")).toBe(1);
|
|
82
|
+
|
|
83
|
+
rerender(<MediaQueryProbe query="(min-width: 64rem)" />);
|
|
84
|
+
|
|
85
|
+
expect(screen.getByTestId("result")).toHaveTextContent("true");
|
|
86
|
+
expect(mock.listenerCount("(min-width: 40rem)")).toBe(0);
|
|
87
|
+
expect(mock.listenerCount("(min-width: 64rem)")).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./useMediaQuery.ts";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
function getServerSnapshot(): boolean {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tracks whether a CSS media query currently matches, re-rendering the
|
|
9
|
+
* consumer whenever it changes.
|
|
10
|
+
*
|
|
11
|
+
* Built on {@link https://react.dev/reference/react/useSyncExternalStore |
|
|
12
|
+
* `useSyncExternalStore`}, so it subscribes to the query's real
|
|
13
|
+
* `MediaQueryList` (`change` events, cleaned up on unmount and resubscribed
|
|
14
|
+
* whenever `query` itself changes) rather than polling, and renders a static
|
|
15
|
+
* `false` during server rendering — there is no `window`/`matchMedia` on the
|
|
16
|
+
* server, so `false` is the safest default until the client mounts and can
|
|
17
|
+
* read the real value. This is a generic primitive: it does not know about
|
|
18
|
+
* Primitiv's own breakpoint scale or any other token values — pass whatever
|
|
19
|
+
* query string the consumer needs.
|
|
20
|
+
*
|
|
21
|
+
* @example Responding to a breakpoint
|
|
22
|
+
* ```tsx
|
|
23
|
+
* const isDesktop = useMediaQuery("(min-width: 64rem)");
|
|
24
|
+
* return isDesktop ? <DesktopNav /> : <MobileMenu />;
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @example Any media feature, not just width
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @param query - A media query string, e.g. `"(min-width: 40rem)"`.
|
|
33
|
+
* @returns Whether `query` currently matches. `false` on the server and on
|
|
34
|
+
* the client's very first paint before hydration confirms the real value.
|
|
35
|
+
*/
|
|
36
|
+
export function useMediaQuery(query: string): boolean {
|
|
37
|
+
const subscribe = useCallback(
|
|
38
|
+
(onStoreChange: () => void) => {
|
|
39
|
+
const mediaQueryList = window.matchMedia(query);
|
|
40
|
+
mediaQueryList.addEventListener("change", onStoreChange);
|
|
41
|
+
return () => mediaQueryList.removeEventListener("change", onStoreChange);
|
|
42
|
+
},
|
|
43
|
+
[query],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const getSnapshot = useCallback(() => window.matchMedia(query).matches, [query]);
|
|
47
|
+
|
|
48
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
49
|
+
}
|