@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.
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/api-client.d.ts +139 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +133 -0
- package/dist/api-client.js.map +1 -0
- package/dist/api-error.d.ts +45 -0
- package/dist/api-error.d.ts.map +1 -0
- package/dist/api-error.js +52 -0
- package/dist/api-error.js.map +1 -0
- package/dist/auth-store.d.ts +49 -0
- package/dist/auth-store.d.ts.map +1 -0
- package/dist/auth-store.js +27 -0
- package/dist/auth-store.js.map +1 -0
- package/dist/chunk-reload.d.ts +61 -0
- package/dist/chunk-reload.d.ts.map +1 -0
- package/dist/chunk-reload.js +92 -0
- package/dist/chunk-reload.js.map +1 -0
- package/dist/cn.d.ts +13 -0
- package/dist/cn.d.ts.map +1 -0
- package/dist/cn.js +16 -0
- package/dist/cn.js.map +1 -0
- package/dist/describe-error.d.ts +74 -0
- package/dist/describe-error.d.ts.map +1 -0
- package/dist/describe-error.js +70 -0
- package/dist/describe-error.js.map +1 -0
- package/dist/error-boundary.d.ts +61 -0
- package/dist/error-boundary.d.ts.map +1 -0
- package/dist/error-boundary.js +32 -0
- package/dist/error-boundary.js.map +1 -0
- package/dist/guards.d.ts +65 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +52 -0
- package/dist/guards.js.map +1 -0
- package/dist/hydrate.d.ts +30 -0
- package/dist/hydrate.d.ts.map +1 -0
- package/dist/hydrate.js +25 -0
- package/dist/hydrate.js.map +1 -0
- package/dist/i18n.d.ts +70 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +66 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/prerender-contract.d.ts +30 -0
- package/dist/prerender-contract.d.ts.map +1 -0
- package/dist/prerender-contract.js +30 -0
- package/dist/prerender-contract.js.map +1 -0
- package/dist/query.d.ts +28 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +51 -0
- package/dist/query.js.map +1 -0
- package/dist/sse.d.ts +64 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +105 -0
- package/dist/sse.js.map +1 -0
- package/dist/states.d.ts +69 -0
- package/dist/states.d.ts.map +1 -0
- package/dist/states.js +2 -0
- package/dist/states.js.map +1 -0
- package/dist/ui/class-name.d.ts +16 -0
- package/dist/ui/class-name.d.ts.map +1 -0
- package/dist/ui/class-name.js +13 -0
- package/dist/ui/class-name.js.map +1 -0
- package/dist/ui/combobox.d.ts +81 -0
- package/dist/ui/combobox.d.ts.map +1 -0
- package/dist/ui/combobox.js +70 -0
- package/dist/ui/combobox.js.map +1 -0
- package/dist/ui/dialog.d.ts +39 -0
- package/dist/ui/dialog.d.ts.map +1 -0
- package/dist/ui/dialog.js +32 -0
- package/dist/ui/dialog.js.map +1 -0
- package/dist/ui/drawer.d.ts +63 -0
- package/dist/ui/drawer.d.ts.map +1 -0
- package/dist/ui/drawer.js +55 -0
- package/dist/ui/drawer.js.map +1 -0
- package/dist/ui/glyphs.d.ts +4 -0
- package/dist/ui/glyphs.d.ts.map +1 -0
- package/dist/ui/glyphs.js +20 -0
- package/dist/ui/glyphs.js.map +1 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +48 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/input.d.ts +45 -0
- package/dist/ui/input.d.ts.map +1 -0
- package/dist/ui/input.js +108 -0
- package/dist/ui/input.js.map +1 -0
- package/dist/ui/menu.d.ts +70 -0
- package/dist/ui/menu.d.ts.map +1 -0
- package/dist/ui/menu.js +85 -0
- package/dist/ui/menu.js.map +1 -0
- package/dist/ui/select.d.ts +69 -0
- package/dist/ui/select.d.ts.map +1 -0
- package/dist/ui/select.js +48 -0
- package/dist/ui/select.js.map +1 -0
- package/dist/ui/tabs.d.ts +52 -0
- package/dist/ui/tabs.d.ts.map +1 -0
- package/dist/ui/tabs.js +62 -0
- package/dist/ui/tabs.js.map +1 -0
- package/package.json +124 -0
- package/src/api-client.test.ts +256 -0
- package/src/api-client.ts +305 -0
- package/src/api-error.ts +60 -0
- package/src/auth-store.ts +59 -0
- package/src/chunk-reload.test.ts +46 -0
- package/src/chunk-reload.ts +94 -0
- package/src/cn.ts +16 -0
- package/src/describe-error.test.ts +102 -0
- package/src/describe-error.ts +140 -0
- package/src/error-boundary.tsx +88 -0
- package/src/guards.tsx +99 -0
- package/src/hydrate.ts +36 -0
- package/src/i18n.test.ts +68 -0
- package/src/i18n.ts +101 -0
- package/src/index.ts +63 -0
- package/src/prerender-contract.ts +31 -0
- package/src/query.test.ts +54 -0
- package/src/query.ts +79 -0
- package/src/sse.test.ts +50 -0
- package/src/sse.ts +117 -0
- package/src/states.ts +71 -0
- package/src/ui/class-name.ts +23 -0
- package/src/ui/combobox.test.ts +32 -0
- package/src/ui/combobox.tsx +242 -0
- package/src/ui/dialog.tsx +87 -0
- package/src/ui/drawer.tsx +161 -0
- package/src/ui/focus.test.ts +82 -0
- package/src/ui/glyphs.tsx +35 -0
- package/src/ui/index.ts +145 -0
- package/src/ui/input.test.ts +22 -0
- package/src/ui/input.tsx +181 -0
- package/src/ui/menu.test.ts +73 -0
- package/src/ui/menu.tsx +186 -0
- package/src/ui/select.tsx +191 -0
- 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
|
+
}
|