@gusnips/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/dist/api-client.d.ts +139 -0
  4. package/dist/api-client.d.ts.map +1 -0
  5. package/dist/api-client.js +133 -0
  6. package/dist/api-client.js.map +1 -0
  7. package/dist/api-error.d.ts +45 -0
  8. package/dist/api-error.d.ts.map +1 -0
  9. package/dist/api-error.js +52 -0
  10. package/dist/api-error.js.map +1 -0
  11. package/dist/auth-store.d.ts +49 -0
  12. package/dist/auth-store.d.ts.map +1 -0
  13. package/dist/auth-store.js +27 -0
  14. package/dist/auth-store.js.map +1 -0
  15. package/dist/chunk-reload.d.ts +61 -0
  16. package/dist/chunk-reload.d.ts.map +1 -0
  17. package/dist/chunk-reload.js +92 -0
  18. package/dist/chunk-reload.js.map +1 -0
  19. package/dist/cn.d.ts +13 -0
  20. package/dist/cn.d.ts.map +1 -0
  21. package/dist/cn.js +16 -0
  22. package/dist/cn.js.map +1 -0
  23. package/dist/describe-error.d.ts +74 -0
  24. package/dist/describe-error.d.ts.map +1 -0
  25. package/dist/describe-error.js +70 -0
  26. package/dist/describe-error.js.map +1 -0
  27. package/dist/error-boundary.d.ts +61 -0
  28. package/dist/error-boundary.d.ts.map +1 -0
  29. package/dist/error-boundary.js +32 -0
  30. package/dist/error-boundary.js.map +1 -0
  31. package/dist/guards.d.ts +65 -0
  32. package/dist/guards.d.ts.map +1 -0
  33. package/dist/guards.js +52 -0
  34. package/dist/guards.js.map +1 -0
  35. package/dist/hydrate.d.ts +30 -0
  36. package/dist/hydrate.d.ts.map +1 -0
  37. package/dist/hydrate.js +25 -0
  38. package/dist/hydrate.js.map +1 -0
  39. package/dist/i18n.d.ts +70 -0
  40. package/dist/i18n.d.ts.map +1 -0
  41. package/dist/i18n.js +66 -0
  42. package/dist/i18n.js.map +1 -0
  43. package/dist/index.d.ts +12 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +31 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/prerender-contract.d.ts +30 -0
  48. package/dist/prerender-contract.d.ts.map +1 -0
  49. package/dist/prerender-contract.js +30 -0
  50. package/dist/prerender-contract.js.map +1 -0
  51. package/dist/query.d.ts +28 -0
  52. package/dist/query.d.ts.map +1 -0
  53. package/dist/query.js +51 -0
  54. package/dist/query.js.map +1 -0
  55. package/dist/sse.d.ts +64 -0
  56. package/dist/sse.d.ts.map +1 -0
  57. package/dist/sse.js +105 -0
  58. package/dist/sse.js.map +1 -0
  59. package/dist/states.d.ts +69 -0
  60. package/dist/states.d.ts.map +1 -0
  61. package/dist/states.js +2 -0
  62. package/dist/states.js.map +1 -0
  63. package/dist/ui/class-name.d.ts +16 -0
  64. package/dist/ui/class-name.d.ts.map +1 -0
  65. package/dist/ui/class-name.js +13 -0
  66. package/dist/ui/class-name.js.map +1 -0
  67. package/dist/ui/combobox.d.ts +81 -0
  68. package/dist/ui/combobox.d.ts.map +1 -0
  69. package/dist/ui/combobox.js +70 -0
  70. package/dist/ui/combobox.js.map +1 -0
  71. package/dist/ui/dialog.d.ts +39 -0
  72. package/dist/ui/dialog.d.ts.map +1 -0
  73. package/dist/ui/dialog.js +32 -0
  74. package/dist/ui/dialog.js.map +1 -0
  75. package/dist/ui/drawer.d.ts +63 -0
  76. package/dist/ui/drawer.d.ts.map +1 -0
  77. package/dist/ui/drawer.js +55 -0
  78. package/dist/ui/drawer.js.map +1 -0
  79. package/dist/ui/glyphs.d.ts +4 -0
  80. package/dist/ui/glyphs.d.ts.map +1 -0
  81. package/dist/ui/glyphs.js +20 -0
  82. package/dist/ui/glyphs.js.map +1 -0
  83. package/dist/ui/index.d.ts +9 -0
  84. package/dist/ui/index.d.ts.map +1 -0
  85. package/dist/ui/index.js +48 -0
  86. package/dist/ui/index.js.map +1 -0
  87. package/dist/ui/input.d.ts +45 -0
  88. package/dist/ui/input.d.ts.map +1 -0
  89. package/dist/ui/input.js +108 -0
  90. package/dist/ui/input.js.map +1 -0
  91. package/dist/ui/menu.d.ts +70 -0
  92. package/dist/ui/menu.d.ts.map +1 -0
  93. package/dist/ui/menu.js +85 -0
  94. package/dist/ui/menu.js.map +1 -0
  95. package/dist/ui/select.d.ts +69 -0
  96. package/dist/ui/select.d.ts.map +1 -0
  97. package/dist/ui/select.js +48 -0
  98. package/dist/ui/select.js.map +1 -0
  99. package/dist/ui/tabs.d.ts +52 -0
  100. package/dist/ui/tabs.d.ts.map +1 -0
  101. package/dist/ui/tabs.js +62 -0
  102. package/dist/ui/tabs.js.map +1 -0
  103. package/package.json +124 -0
  104. package/src/api-client.test.ts +256 -0
  105. package/src/api-client.ts +305 -0
  106. package/src/api-error.ts +60 -0
  107. package/src/auth-store.ts +59 -0
  108. package/src/chunk-reload.test.ts +46 -0
  109. package/src/chunk-reload.ts +94 -0
  110. package/src/cn.ts +16 -0
  111. package/src/describe-error.test.ts +102 -0
  112. package/src/describe-error.ts +140 -0
  113. package/src/error-boundary.tsx +88 -0
  114. package/src/guards.tsx +99 -0
  115. package/src/hydrate.ts +36 -0
  116. package/src/i18n.test.ts +68 -0
  117. package/src/i18n.ts +101 -0
  118. package/src/index.ts +63 -0
  119. package/src/prerender-contract.ts +31 -0
  120. package/src/query.test.ts +54 -0
  121. package/src/query.ts +79 -0
  122. package/src/sse.test.ts +50 -0
  123. package/src/sse.ts +117 -0
  124. package/src/states.ts +71 -0
  125. package/src/ui/class-name.ts +23 -0
  126. package/src/ui/combobox.test.ts +32 -0
  127. package/src/ui/combobox.tsx +242 -0
  128. package/src/ui/dialog.tsx +87 -0
  129. package/src/ui/drawer.tsx +161 -0
  130. package/src/ui/focus.test.ts +82 -0
  131. package/src/ui/glyphs.tsx +35 -0
  132. package/src/ui/index.ts +145 -0
  133. package/src/ui/input.test.ts +22 -0
  134. package/src/ui/input.tsx +181 -0
  135. package/src/ui/menu.test.ts +73 -0
  136. package/src/ui/menu.tsx +186 -0
  137. package/src/ui/select.tsx +191 -0
  138. package/src/ui/tabs.tsx +95 -0
@@ -0,0 +1,242 @@
1
+ import { Combobox as Primitive } from "@base-ui/react/combobox";
2
+ import { useMemo, useState, type ReactNode } from "react";
3
+ import { mergeClassName, type StateClassName } from "./class-name.ts";
4
+ import { ChevronGlyph, CloseGlyph } from "./glyphs.tsx";
5
+
6
+ // The parts, for a combobox this wrapper's shape cannot express — multi-select with chips,
7
+ // groups, a virtualised list.
8
+ export const ComboboxRoot = Primitive.Root;
9
+ export const ComboboxInput = Primitive.Input;
10
+ export const ComboboxInputGroup = Primitive.InputGroup;
11
+ export const ComboboxTrigger = Primitive.Trigger;
12
+ export const ComboboxClear = Primitive.Clear;
13
+ export const ComboboxPortal = Primitive.Portal;
14
+ export const ComboboxPositioner = Primitive.Positioner;
15
+ export const ComboboxPopup = Primitive.Popup;
16
+ export const ComboboxList = Primitive.List;
17
+ export const ComboboxItem = Primitive.Item;
18
+ export const ComboboxEmpty = Primitive.Empty;
19
+ export const ComboboxGroup = Primitive.Group;
20
+ export const ComboboxGroupLabel = Primitive.GroupLabel;
21
+
22
+ /**
23
+ * What the input shows: the QUERY while the list is open, the selected item's LABEL while it
24
+ * is closed.
25
+ *
26
+ * The two are different strings and swapping them is the whole job. Show the label while
27
+ * typing and every keystroke is overwritten by the old selection; show the query while
28
+ * closed and the field goes blank the moment focus leaves, even though a value is set.
29
+ */
30
+ export function comboboxInputValue<T>(
31
+ open: boolean,
32
+ query: string,
33
+ value: T | null,
34
+ getLabel: (item: T) => string,
35
+ ): string {
36
+ if (open) return query;
37
+ return value === null ? "" : getLabel(value);
38
+ }
39
+
40
+ /** A loading state needs the line that says so — there is no default, and no English. */
41
+ type ComboboxLoadingState =
42
+ { loading: boolean; loadingHint: ReactNode } | { loading?: undefined; loadingHint?: undefined };
43
+
44
+ export type ComboboxProps<T> = Omit<
45
+ Primitive.Root.Props<T, false, T>,
46
+ | "children"
47
+ | "items"
48
+ | "filteredItems"
49
+ | "filter"
50
+ | "value"
51
+ | "defaultValue"
52
+ | "multiple"
53
+ | "open"
54
+ | "defaultOpen"
55
+ | "inputValue"
56
+ | "defaultInputValue"
57
+ | "onInputValueChange"
58
+ | "itemToStringLabel"
59
+ | "isItemEqualToValue"
60
+ > & {
61
+ value: T | null;
62
+ /**
63
+ * Items for the current query. Filtering is yours: this passes `filter={null}` to Base UI,
64
+ * so the list shows exactly what you return — a server search, a fuzzy match, a bounded
65
+ * page. Bound it here if it needs bounding; there is no cap in the wrapper, because a cap
66
+ * would quietly truncate a list a caller meant to show whole.
67
+ */
68
+ search: (query: string) => readonly T[];
69
+ /** Stable identity of an item: its React key, and how two items are compared. */
70
+ getKey: (item: T) => string;
71
+ /** The item's text — what the input shows once it is picked, and what typeahead matches. */
72
+ getLabel: (item: T) => string;
73
+ renderItem?: (item: T) => ReactNode;
74
+ /**
75
+ * Shown when the query matches nothing. Required: "no results" with no next step is a dead
76
+ * end, and this is the line that offers one.
77
+ */
78
+ emptyHint: ReactNode;
79
+ /** Label for the clear button. Required — it is a button with an icon and no text. */
80
+ clearLabel: string;
81
+ /** Label for the button that opens the list. Required, same reason. */
82
+ openLabel: string;
83
+ /** Accessible name for the input, when no visible label points at it. */
84
+ label?: string;
85
+ leadingIcon?: ReactNode;
86
+ /** Classes for the input itself. */
87
+ className?: StateClassName<Primitive.Input.State>;
88
+ popupClassName?: StateClassName<Primitive.Popup.State>;
89
+ inputProps?: Primitive.Input.Props;
90
+ /**
91
+ * Positioner props — `align`, `side`, `sideOffset`, `collisionPadding`, and the stacking
92
+ * order. The default `z-[60]` puts the list above the overlay layer (z-50: dialog, drawer);
93
+ * raise it with `positionerProps={{ className: "z-[80]" }}` if the app stacks something
94
+ * higher than a dialog above it.
95
+ */
96
+ positionerProps?: Primitive.Positioner.Props;
97
+ } & ComboboxLoadingState;
98
+
99
+ /**
100
+ * A text field that filters a list. `name`, `form`, `required`, `id` and `disabled` pass
101
+ * straight through to the root, so it submits inside a `<form>`.
102
+ *
103
+ * Controlled only: the wrapper owns `open` and the input value, because those two are what
104
+ * the label/query bridge is made of.
105
+ */
106
+ export function Combobox<T>({
107
+ value,
108
+ search,
109
+ getKey,
110
+ getLabel,
111
+ renderItem,
112
+ emptyHint,
113
+ loading,
114
+ loadingHint,
115
+ clearLabel,
116
+ openLabel,
117
+ label,
118
+ leadingIcon,
119
+ className,
120
+ popupClassName,
121
+ inputProps,
122
+ positionerProps,
123
+ onOpenChange,
124
+ ...rest
125
+ }: ComboboxProps<T>) {
126
+ const [open, setOpen] = useState(false);
127
+ const [query, setQuery] = useState("");
128
+ const items = useMemo(() => search(query), [query, search]);
129
+
130
+ return (
131
+ <Primitive.Root<T, false, T>
132
+ value={value}
133
+ items={items}
134
+ open={open}
135
+ itemToStringLabel={getLabel}
136
+ isItemEqualToValue={(left, right) => getKey(left) === getKey(right)}
137
+ // `null` hands filtering to `search`. Base UI's own filter would run a SECOND pass over
138
+ // an already-filtered list, so a server that matched on something the label does not
139
+ // contain — an id, a phone number, an accent-stripped name — would return rows the
140
+ // client then threw away.
141
+ filter={null}
142
+ inputValue={comboboxInputValue(open, query, value, getLabel)}
143
+ onInputValueChange={(next) => {
144
+ setQuery(next);
145
+ if (!open) setOpen(true);
146
+ }}
147
+ onOpenChange={(next, details) => {
148
+ setOpen(next);
149
+ // A fresh open starts from the whole list, not from whatever was typed last time.
150
+ if (next) setQuery("");
151
+ onOpenChange?.(next, details);
152
+ }}
153
+ {...rest}
154
+ >
155
+ <Primitive.InputGroup className="relative">
156
+ {leadingIcon ? (
157
+ <span className="pointer-events-none absolute top-1/2 left-3 z-10 -translate-y-1/2 text-muted-foreground">
158
+ {leadingIcon}
159
+ </span>
160
+ ) : null}
161
+ <Primitive.Input
162
+ aria-label={label}
163
+ autoComplete="off"
164
+ {...inputProps}
165
+ className={mergeClassName(
166
+ [
167
+ // No focus style: the field keeps the app's one `:focus-visible` outline. Swapping
168
+ // it for a `ring-*` loses forced-colors mode, where a box-shadow is forced to none.
169
+ "w-full min-w-0 rounded-md border border-input bg-background py-2 pr-16 pl-3 text-base text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 md:text-sm",
170
+ leadingIcon && "pl-9",
171
+ ],
172
+ // The caller's classes land BEFORE the space reserved for the buttons is
173
+ // re-applied below, so a `px-*` of their own cannot swallow it.
174
+ className,
175
+ )}
176
+ />
177
+ <span className="absolute top-1/2 right-1.5 z-10 flex -translate-y-1/2 items-center gap-0.5">
178
+ {/* Base UI unmounts Clear on its own when there is nothing to clear, so this is not
179
+ wrapped in a `value &&`. The input keeps its right padding either way, so the
180
+ text does not reflow when a value appears. */}
181
+ <Primitive.Clear
182
+ aria-label={clearLabel}
183
+ className="inline-flex rounded-full p-1.5 text-muted-foreground"
184
+ >
185
+ <CloseGlyph />
186
+ </Primitive.Clear>
187
+ <Primitive.Trigger
188
+ aria-label={openLabel}
189
+ className="inline-flex rounded-full p-1.5 text-muted-foreground"
190
+ >
191
+ <ChevronGlyph />
192
+ </Primitive.Trigger>
193
+ </span>
194
+ </Primitive.InputGroup>
195
+ <Primitive.Portal>
196
+ {/* Above the overlay layer (z-50: dialog, drawer). The list portals to the body and
197
+ so does the dialog it may be sitting in, so nesting decides nothing and z decides
198
+ everything — one step lower and the list paints behind its own anchor. */}
199
+ <Primitive.Positioner
200
+ sideOffset={4}
201
+ {...positionerProps}
202
+ className={mergeClassName(
203
+ "z-[60] max-w-[var(--available-width)] outline-none",
204
+ positionerProps?.className,
205
+ )}
206
+ >
207
+ <Primitive.Popup
208
+ className={mergeClassName(
209
+ "w-[var(--anchor-width)] overflow-hidden border border-border bg-popover text-popover-foreground outline-none transition-[scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
210
+ popupClassName,
211
+ )}
212
+ >
213
+ {/* Empty stays MOUNTED and swaps its children — it is the popup's live region,
214
+ and a live region a screen reader was never given cannot announce a change to
215
+ it. So it is not wrapped in a condition, and its padding sits on the child
216
+ instead: the part's own div is left in the DOM at zero height when there is
217
+ something in the list, rather than as a blank 40px strip above it. */}
218
+ <Primitive.Empty>
219
+ <div className="px-3 py-2 text-muted-foreground">
220
+ {loading ? loadingHint : emptyHint}
221
+ </div>
222
+ </Primitive.Empty>
223
+ {/* `--available-height` is the room the positioner measured between the anchor
224
+ and the edge of the viewport. Without it the list is unbounded and a long
225
+ result set runs off the bottom of the screen with no way to reach the end. */}
226
+ <Primitive.List className="max-h-[var(--available-height)] overflow-y-auto">
227
+ {(item: T) => (
228
+ <Primitive.Item
229
+ key={getKey(item)}
230
+ value={item}
231
+ className="cursor-default px-3 py-2 outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
232
+ >
233
+ {renderItem ? renderItem(item) : getLabel(item)}
234
+ </Primitive.Item>
235
+ )}
236
+ </Primitive.List>
237
+ </Primitive.Popup>
238
+ </Primitive.Positioner>
239
+ </Primitive.Portal>
240
+ </Primitive.Root>
241
+ );
242
+ }
@@ -0,0 +1,87 @@
1
+ import { Dialog as Primitive } from "@base-ui/react/dialog";
2
+ import { mergeClassName, type StateClassName } from "./class-name.ts";
3
+ import { CloseGlyph } from "./glyphs.tsx";
4
+
5
+ // The parts, straight through. Base UI already does focus trap, escape, outside-dismiss,
6
+ // scroll lock, focus return and the aria wiring, and a wrapper around any of these would only
7
+ // add a class name — so they are re-exported, not re-implemented.
8
+ export const Dialog = Primitive.Root;
9
+ export const DialogTrigger = Primitive.Trigger;
10
+ export const DialogTitle = Primitive.Title;
11
+ export const DialogDescription = Primitive.Description;
12
+ export const DialogClose = Primitive.Close;
13
+ export const DialogPortal = Primitive.Portal;
14
+ export const DialogBackdrop = Primitive.Backdrop;
15
+ export const DialogPopup = Primitive.Popup;
16
+
17
+ export type DialogContentProps = Primitive.Popup.Props & {
18
+ /**
19
+ * Accessible name, for a dialog with no visible `DialogTitle`. With a title, leave this
20
+ * out: Base UI points the popup's `aria-labelledby` at it, and an `aria-label` here would
21
+ * override the words the user can actually see.
22
+ */
23
+ label?: string;
24
+ /**
25
+ * Label for the built-in close button — REQUIRED to get one, which is the whole point:
26
+ * passing the label is the only way to render the button, so an unlabelled close button
27
+ * cannot be written. There is no default string, because a default would be English.
28
+ */
29
+ closeLabel?: string;
30
+ backdropClassName?: StateClassName<Primitive.Backdrop.State>;
31
+ /** Where the portal mounts. Defaults to `<body>`. */
32
+ container?: Primitive.Portal.Props["container"];
33
+ };
34
+
35
+ /**
36
+ * Portal + backdrop + popup as one part, so a scrimless dialog is not a thing a caller can
37
+ * ship by forgetting a line. The popup owns the scroll (`overflow-y-auto` plus
38
+ * `overscroll-contain`, so reaching the end of a long dialog does not start scrolling the
39
+ * page behind it) and is capped to the viewport, so a tall dialog is never taller than the
40
+ * screen it opens on.
41
+ *
42
+ * Everything else — padding, radius, shadow, type — is the product's. What is here is what
43
+ * makes the primitive work.
44
+ */
45
+ export function DialogContent({
46
+ className,
47
+ backdropClassName,
48
+ container,
49
+ label,
50
+ closeLabel,
51
+ children,
52
+ ...rest
53
+ }: DialogContentProps) {
54
+ return (
55
+ <Primitive.Portal container={container}>
56
+ <Primitive.Backdrop
57
+ className={mergeClassName(
58
+ "fixed inset-0 z-50 bg-scrim transition-opacity data-ending-style:opacity-0 data-starting-style:opacity-0",
59
+ backdropClassName,
60
+ )}
61
+ />
62
+ {/* `outline-none` belongs on a popup and almost nowhere else: this element takes
63
+ programmatic focus the moment it opens, and a focus ring is meant to answer a
64
+ keyboard, not to frame every dialog that appears. Controls inside keep theirs. */}
65
+ <Primitive.Popup
66
+ aria-label={label}
67
+ {...rest}
68
+ className={mergeClassName(
69
+ "fixed top-1/2 left-1/2 z-50 max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain bg-popover text-popover-foreground outline-none transition-[scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
70
+ className,
71
+ )}
72
+ >
73
+ {children}
74
+ {closeLabel === undefined ? null : (
75
+ <Primitive.Close
76
+ aria-label={closeLabel}
77
+ // 44px square: the close button is often the only way out on a phone, and a
78
+ // smaller target is one a thumb misses.
79
+ className="absolute top-0 right-0 inline-flex size-11 items-center justify-center text-muted-foreground"
80
+ >
81
+ <CloseGlyph />
82
+ </Primitive.Close>
83
+ )}
84
+ </Primitive.Popup>
85
+ </Primitive.Portal>
86
+ );
87
+ }
@@ -0,0 +1,161 @@
1
+ import { Drawer as Primitive } from "@base-ui/react/drawer";
2
+ import type { ReactNode } from "react";
3
+ import { mergeClassName, type StateClassName } from "./class-name.ts";
4
+
5
+ // The parts, for a drawer that needs a shape this wrapper does not give it.
6
+ export const DrawerRoot = Primitive.Root;
7
+ export const DrawerTrigger = Primitive.Trigger;
8
+ export const DrawerPortal = Primitive.Portal;
9
+ export const DrawerBackdrop = Primitive.Backdrop;
10
+ export const DrawerViewport = Primitive.Viewport;
11
+ export const DrawerPopup = Primitive.Popup;
12
+ export const DrawerContent = Primitive.Content;
13
+ export const DrawerHandle = Primitive.Handle;
14
+ export const DrawerSwipeArea = Primitive.SwipeArea;
15
+ export const DrawerTitle = Primitive.Title;
16
+ export const DrawerDescription = Primitive.Description;
17
+ export const DrawerClose = Primitive.Close;
18
+
19
+ export type DrawerSide = "left" | "right" | "bottom";
20
+
21
+ const viewportSide: Record<DrawerSide, string> = {
22
+ left: "items-stretch justify-start",
23
+ right: "items-stretch justify-end",
24
+ bottom: "items-end",
25
+ };
26
+
27
+ // The translate pair is what Base UI animates between: `data-starting-style` is the frame
28
+ // before it opens, `data-ending-style` the frame it leaves on. Same class both ways, so the
29
+ // drawer goes back out the side it came in.
30
+ const popupSide: Record<DrawerSide, string> = {
31
+ left: "w-[min(24rem,calc(100vw-1rem))] data-ending-style:-translate-x-full data-starting-style:-translate-x-full",
32
+ right:
33
+ "w-[min(24rem,calc(100vw-1rem))] data-ending-style:translate-x-full data-starting-style:translate-x-full",
34
+ bottom:
35
+ "max-h-[90svh] w-full data-ending-style:translate-y-full data-starting-style:translate-y-full",
36
+ };
37
+
38
+ /**
39
+ * Where the portal mounts, and whether the drawer covers the page or a box inside it.
40
+ *
41
+ * A scoped drawer positions itself `absolute` against a container instead of `fixed` against
42
+ * the viewport — so it MUST be given that container, or it lands in `<body>` and positions
43
+ * against a box that is not there. The two travel together in the type.
44
+ *
45
+ * The container also needs a positioning context of its own (`relative`): `absolute` walks up
46
+ * to the nearest positioned ancestor, so a static container hands the drawer the whole page
47
+ * and the "scoped" part quietly does not happen.
48
+ */
49
+ type DrawerPortalTarget =
50
+ | { scoped: true; container: NonNullable<Primitive.Portal.Props["container"]> }
51
+ | { scoped?: false; container?: Primitive.Portal.Props["container"] };
52
+
53
+ export type DrawerProps<Payload = unknown> = Omit<
54
+ Primitive.Root.Props<Payload>,
55
+ "children" | "swipeDirection"
56
+ > &
57
+ DrawerPortalTarget & {
58
+ /**
59
+ * Which edge it comes from. This also sets `swipeDirection` on the root — the two must
60
+ * agree, and a mismatch is silent: the drawer looks right and dismisses on a swipe
61
+ * towards the screen it is attached to.
62
+ */
63
+ side?: DrawerSide;
64
+ /** Accessible name, for a drawer with no visible `DrawerTitle`. */
65
+ label?: string;
66
+ children?: ReactNode;
67
+ /** Classes for the popup — the panel itself. */
68
+ className?: StateClassName<Primitive.Popup.State>;
69
+ backdropClassName?: StateClassName<Primitive.Backdrop.State>;
70
+ viewportClassName?: StateClassName<Primitive.Viewport.State>;
71
+ contentClassName?: StateClassName<Primitive.Content.State>;
72
+ /** Popup-level props the wrapper does not own — `initialFocus`, `finalFocus`, `data-*`. */
73
+ popupProps?: Primitive.Popup.Props;
74
+ };
75
+
76
+ /**
77
+ * A panel that slides in from an edge. Root → portal → backdrop → viewport → popup → content,
78
+ * composed once here because the parts have to be arranged in exactly this order to work at
79
+ * all, and because two of the four rules below cost someone a bug that no test would catch.
80
+ *
81
+ * Everything the root takes — `open`, `onOpenChange`, `modal`, `snapPoints`, `actionsRef`,
82
+ * `data-*` — passes straight through.
83
+ */
84
+ export function Drawer<Payload = unknown>({
85
+ side = "right",
86
+ label,
87
+ scoped = false,
88
+ container,
89
+ className,
90
+ backdropClassName,
91
+ viewportClassName,
92
+ contentClassName,
93
+ popupProps,
94
+ children,
95
+ ...rest
96
+ }: DrawerProps<Payload>) {
97
+ return (
98
+ <Primitive.Root swipeDirection={side === "bottom" ? "down" : side} {...rest}>
99
+ {/* Base UI throws (error #26) if any drawer part sits outside a Portal, so the scoped
100
+ variant does not skip the portal — it portals INTO its container and positions
101
+ absolutely there. */}
102
+ <Primitive.Portal container={container}>
103
+ <Primitive.Backdrop
104
+ className={mergeClassName(
105
+ [
106
+ scoped ? "absolute" : "fixed",
107
+ "inset-0 z-40 bg-scrim transition-opacity data-ending-style:opacity-0 data-starting-style:opacity-0",
108
+ ],
109
+ backdropClassName,
110
+ )}
111
+ />
112
+ {/* The z-index lives on the VIEWPORT, not on the popup. `position: fixed` always
113
+ creates a stacking context, so a z on the popup would only rank it inside the
114
+ viewport's own `z-auto` context — which paints under every positioned z-30+
115
+ element on the page (header, sidebar, floating buttons) however high that z is.
116
+
117
+ It is z-50, which TIES the dialog layer rather than clearing it, on purpose. A
118
+ drawer hosts whole app surfaces — a mobile nav carries the entire sidebar — and
119
+ those surfaces open dialogs. Rank the drawer above the dialog layer and it paints
120
+ over every modal opened from inside it: the user taps, nothing appears. At a tie
121
+ the dialog wins on DOM order, because it portals to the body after the drawer
122
+ that opened it. */}
123
+ <Primitive.Viewport
124
+ className={mergeClassName(
125
+ [scoped ? "absolute" : "fixed", "inset-0 z-50 flex", viewportSide[side]],
126
+ viewportClassName,
127
+ )}
128
+ >
129
+ <Primitive.Popup
130
+ aria-label={label}
131
+ {...popupProps}
132
+ className={mergeClassName(
133
+ [
134
+ "pointer-events-auto flex flex-col overflow-y-auto overscroll-contain bg-background text-foreground outline-none transition-transform",
135
+ popupSide[side],
136
+ ],
137
+ className,
138
+ )}
139
+ >
140
+ <Primitive.Content
141
+ className={mergeClassName(
142
+ [
143
+ "flex w-full flex-1 flex-col",
144
+ // A side drawer has a definite height (the viewport's), so cap the content
145
+ // to it. Without `min-h-0` a child with its own scroll region grows the
146
+ // popup instead of scrolling inside it, and the footer that was meant to
147
+ // stay pinned ends up at the bottom of one long page scroll. A bottom
148
+ // drawer sizes from its content, so it keeps the default `min-height: auto`.
149
+ side !== "bottom" && "min-h-0",
150
+ ],
151
+ contentClassName,
152
+ )}
153
+ >
154
+ {children}
155
+ </Primitive.Content>
156
+ </Primitive.Popup>
157
+ </Primitive.Viewport>
158
+ </Primitive.Portal>
159
+ </Primitive.Root>
160
+ );
161
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Two structural rules for every wrapper in this folder, checked against the source rather
3
+ * than a render — because both failures look correct in a browser and only show up for the
4
+ * people least able to report them.
5
+ *
6
+ * Neither is a style opinion. Each is a rule from AGENTS.md with a mechanism behind it, and
7
+ * both were live in this folder before this file existed.
8
+ */
9
+ import { readdirSync, readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ // `fileURLToPath`, never `import.meta.dir` — invariant 11: the latter is bun-only, and this
15
+ // suite has to run under vitest on plain Node too.
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+
18
+ const sources = readdirSync(here)
19
+ .filter((name) => name.endsWith(".tsx") || (name.endsWith(".ts") && !name.includes(".test.")))
20
+ .map((name) => ({ name, text: readFileSync(join(here, name), "utf8") }));
21
+
22
+ /**
23
+ * Every class string is on one line here, so a line is the right unit to quote back.
24
+ *
25
+ * Comment lines are skipped, and that is load-bearing rather than tidy: each of these rules is
26
+ * worth a sentence saying why, and the sentence has to be free to name the thing it forbids. A
27
+ * guard that fails on its own explanation teaches people to delete the explanation.
28
+ */
29
+ function linesMatching(text: string, pattern: RegExp): number[] {
30
+ // Block comments are BLANKED, not deleted: removing them would collapse lines and every number
31
+ // reported after one would point at the wrong place. (That is also why this cannot share
32
+ // `menu.test.ts`'s `codeOf`, which strips outright because it only ever counts occurrences.)
33
+ // Blanking also means a wrapped comment line is ignored whether or not it opens with a `*`.
34
+ const code = text.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, " "));
35
+ return code
36
+ .split("\n")
37
+ .map((line, i) => (!line.trim().startsWith("//") && pattern.test(line) ? i + 1 : 0))
38
+ .filter(Boolean);
39
+ }
40
+
41
+ describe("the wrappers do not draw their own focus ring", () => {
42
+ // Invariant 19. A control that writes `outline-none focus-visible:ring-2` looks identical to
43
+ // the base outline in every normal browser and disappears under `forced-colors: active`,
44
+ // where the UA forces `box-shadow` to `none` and leaves `outline` painted in a system colour.
45
+ // So the swap deletes the keyboard focus indicator for Windows High Contrast users only.
46
+ // The app's one ring lives in `@gusnips/tokens`' base layer and needs no help from here.
47
+ it.each(sources)("$name has no focus-visible ring", ({ text }) => {
48
+ expect(linesMatching(text, /focus-visible:ring|focus:ring/)).toEqual([]);
49
+ });
50
+
51
+ // The other half: `outline-none` is legitimate on a container that takes focus
52
+ // programmatically (a Popup, its Positioner, a highlighted Item), and nowhere else. It is
53
+ // only a bug when it removes the outline without the primitive putting focus somewhere that
54
+ // still shows one — which is what the rule above now makes impossible, since there is no
55
+ // replacement ring left to write. This asserts the reset stayed inside the popup parts.
56
+ it.each(sources)("$name resets the outline only on popup parts", ({ text }) => {
57
+ const offenders = linesMatching(text, /outline-none/).filter((line) => {
58
+ // ponytail: "the nearest Primitive tag above" approximated as a 12-line lookback, which
59
+ // clears every part in this folder today with room to spare. The ceiling is a part whose
60
+ // props run longer than that, which would read as a violation while being correct. If that
61
+ // happens, parse the enclosing JSX element instead of counting lines — do not just raise 12.
62
+ const context = text
63
+ .split("\n")
64
+ .slice(Math.max(0, line - 12), line)
65
+ .join("\n");
66
+ return !/<Primitive\.(Popup|Positioner|Item|ItemsEmpty|Backdrop|Viewport)\b/.test(context);
67
+ });
68
+ expect(offenders).toEqual([]);
69
+ });
70
+ });
71
+
72
+ describe("the wrappers never hardcode a colour", () => {
73
+ // Invariant 21. `text-white` on a primary fill is correct in one donor's dark theme and
74
+ // unreadable in the other's, because `--color-primary-foreground` is `#ffffff` in one and a
75
+ // near-black in the other. A shared control cannot know which it is running in, so it always
76
+ // reads the token.
77
+ it.each(sources)("$name uses tokens, not literal colours", ({ text }) => {
78
+ const literal =
79
+ /\b(?:text|bg|border|fill|stroke|ring|shadow)-(?:white|black|slate|gray|zinc|neutral|stone|red|blue|green|amber|yellow)\b|#[0-9a-fA-F]{3,8}\b/;
80
+ expect(linesMatching(text, literal)).toEqual([]);
81
+ });
82
+ });
@@ -0,0 +1,35 @@
1
+ // The three affordance marks the wrappers draw by default: the chevron that says "this
2
+ // opens", the check that says "this one is picked", the cross that says "this closes".
3
+ //
4
+ // They are `currentColor` stroke on a 24-grid so a product's own icon set drops in without
5
+ // a size or colour change. Every component that draws one takes it as a prop — these are the
6
+ // zero-config default, never the only option.
7
+
8
+ function Glyph({ d }: { d: string }) {
9
+ return (
10
+ <svg
11
+ className="size-4"
12
+ viewBox="0 0 24 24"
13
+ fill="none"
14
+ stroke="currentColor"
15
+ strokeWidth={2}
16
+ strokeLinecap="round"
17
+ strokeLinejoin="round"
18
+ aria-hidden="true"
19
+ >
20
+ <path d={d} />
21
+ </svg>
22
+ );
23
+ }
24
+
25
+ export function ChevronGlyph() {
26
+ return <Glyph d="m6 9 6 6 6-6" />;
27
+ }
28
+
29
+ export function CheckGlyph() {
30
+ return <Glyph d="m5 13 4 4 10-10" />;
31
+ }
32
+
33
+ export function CloseGlyph() {
34
+ return <Glyph d="M18 6 6 18M6 6l12 12" />;
35
+ }