@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
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// @gusnips/react/ui — the Base UI wrappers, behind a subpath so an app on a different
|
|
2
|
+
// primitive library (or none) never resolves `@base-ui/react`.
|
|
3
|
+
//
|
|
4
|
+
// SEVEN, out of twenty-seven in the donor. The rest added a class string and nothing else:
|
|
5
|
+
// Base UI already does scroll lock, focus trap, escape, outside-dismiss, focus return, roving
|
|
6
|
+
// focus and typeahead, so a wrapper earns its place for one of three reasons only —
|
|
7
|
+
// composition a caller cannot skip (a dialog with no scrim is unwritable), a required a11y
|
|
8
|
+
// prop expressed as a TYPE (no close button without its label, no tab rail without a name),
|
|
9
|
+
// or an encoded gotcha (the drawer's four, the menu's caption, the tab rail's padding).
|
|
10
|
+
//
|
|
11
|
+
// These are not styled components. The classes here are the ones that make the primitive
|
|
12
|
+
// work — position, stacking, overflow, an opaque surface, the transition hooks Base UI
|
|
13
|
+
// animates through. Padding, radius, shadow and type are the product's, and every part takes
|
|
14
|
+
// a `className` that wins over ours.
|
|
15
|
+
//
|
|
16
|
+
// Nothing here draws a focus ring, and nothing removes one outside a popup container. The
|
|
17
|
+
// app's single `:focus-visible` outline lives in `@gusnips/tokens`' base layer; a wrapper
|
|
18
|
+
// that swaps it for a `ring-*` box-shadow looks identical in every normal browser and
|
|
19
|
+
// vanishes under `forced-colors: active`. `focus.test.ts` pins that.
|
|
20
|
+
//
|
|
21
|
+
// The stacking contract, in one place, because the parts are all portalled to `<body>` and
|
|
22
|
+
// nesting decides nothing:
|
|
23
|
+
// z-40 drawer backdrop
|
|
24
|
+
// z-50 the overlay layer — dialog, drawer. A tie on purpose: a dialog opened from inside
|
|
25
|
+
// a drawer must paint over it, and at a tie the later portal wins.
|
|
26
|
+
// z-60 popups anchored inside that layer — a select, combobox or menu opened in a dialog.
|
|
27
|
+
// Every one of the three takes `positionerProps={{ className: "z-…" }}` to move.
|
|
28
|
+
|
|
29
|
+
export { mergeClassName, type StateClassName } from "./class-name.ts";
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
Dialog,
|
|
33
|
+
DialogBackdrop,
|
|
34
|
+
DialogClose,
|
|
35
|
+
DialogContent,
|
|
36
|
+
DialogDescription,
|
|
37
|
+
DialogPopup,
|
|
38
|
+
DialogPortal,
|
|
39
|
+
DialogTitle,
|
|
40
|
+
DialogTrigger,
|
|
41
|
+
type DialogContentProps,
|
|
42
|
+
} from "./dialog.tsx";
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
Drawer,
|
|
46
|
+
DrawerBackdrop,
|
|
47
|
+
DrawerClose,
|
|
48
|
+
DrawerContent,
|
|
49
|
+
DrawerDescription,
|
|
50
|
+
DrawerHandle,
|
|
51
|
+
DrawerPopup,
|
|
52
|
+
DrawerPortal,
|
|
53
|
+
DrawerRoot,
|
|
54
|
+
DrawerSwipeArea,
|
|
55
|
+
DrawerTitle,
|
|
56
|
+
DrawerTrigger,
|
|
57
|
+
DrawerViewport,
|
|
58
|
+
type DrawerProps,
|
|
59
|
+
type DrawerSide,
|
|
60
|
+
} from "./drawer.tsx";
|
|
61
|
+
|
|
62
|
+
// `SelectGroupLabel` and `ComboboxGroupLabel` below ARE exported, and the menu's is not. The
|
|
63
|
+
// hazard is identical — all three read their group's context and throw when written outside
|
|
64
|
+
// it, reported in production as `Base UI error #56` for select, `#18` for combobox and `#31`
|
|
65
|
+
// for the menu — so the difference is a decision, not an oversight.
|
|
66
|
+
//
|
|
67
|
+
// It turns on what the wrapper already covers. `MenuGroup` composes the caption as a `label`
|
|
68
|
+
// prop, so the raw part has no job left and withholding it is free. `Select` and `Combobox`
|
|
69
|
+
// take a flat option list and cannot express a grouped one at all, so these are the escape
|
|
70
|
+
// hatch for that shape — and a caller reaching for `SelectGroup` is writing its
|
|
71
|
+
// `SelectGroupLabel` in the same breath, which is the arrangement that works.
|
|
72
|
+
export {
|
|
73
|
+
Select,
|
|
74
|
+
SelectGroup,
|
|
75
|
+
SelectGroupLabel,
|
|
76
|
+
SelectIcon,
|
|
77
|
+
SelectItem,
|
|
78
|
+
SelectItemIndicator,
|
|
79
|
+
SelectItemText,
|
|
80
|
+
SelectList,
|
|
81
|
+
SelectPopup,
|
|
82
|
+
SelectPortal,
|
|
83
|
+
SelectPositioner,
|
|
84
|
+
SelectRoot,
|
|
85
|
+
SelectSeparator,
|
|
86
|
+
SelectTrigger,
|
|
87
|
+
SelectValue,
|
|
88
|
+
type SelectOption,
|
|
89
|
+
type SelectProps,
|
|
90
|
+
} from "./select.tsx";
|
|
91
|
+
|
|
92
|
+
export {
|
|
93
|
+
Combobox,
|
|
94
|
+
comboboxInputValue,
|
|
95
|
+
ComboboxClear,
|
|
96
|
+
ComboboxEmpty,
|
|
97
|
+
ComboboxGroup,
|
|
98
|
+
ComboboxGroupLabel,
|
|
99
|
+
ComboboxInput,
|
|
100
|
+
ComboboxInputGroup,
|
|
101
|
+
ComboboxItem,
|
|
102
|
+
ComboboxList,
|
|
103
|
+
ComboboxPopup,
|
|
104
|
+
ComboboxPortal,
|
|
105
|
+
ComboboxPositioner,
|
|
106
|
+
ComboboxRoot,
|
|
107
|
+
ComboboxTrigger,
|
|
108
|
+
type ComboboxProps,
|
|
109
|
+
} from "./combobox.tsx";
|
|
110
|
+
|
|
111
|
+
export {
|
|
112
|
+
Input,
|
|
113
|
+
textareaHeight,
|
|
114
|
+
type FieldProps,
|
|
115
|
+
type InputProps,
|
|
116
|
+
type MultilineInputProps,
|
|
117
|
+
} from "./input.tsx";
|
|
118
|
+
|
|
119
|
+
// No `MenuGroupLabel`, and no raw `Group` / `RadioGroup`. That is the whole guarantee — see
|
|
120
|
+
// the note at the top of menu.tsx.
|
|
121
|
+
export {
|
|
122
|
+
Menu,
|
|
123
|
+
MenuArrow,
|
|
124
|
+
MenuBackdrop,
|
|
125
|
+
MenuCheckboxItem,
|
|
126
|
+
MenuCheckboxItemIndicator,
|
|
127
|
+
MenuContent,
|
|
128
|
+
MenuGroup,
|
|
129
|
+
MenuItem,
|
|
130
|
+
MenuLinkItem,
|
|
131
|
+
MenuPopup,
|
|
132
|
+
MenuPortal,
|
|
133
|
+
MenuPositioner,
|
|
134
|
+
MenuRadioGroup,
|
|
135
|
+
MenuRadioItem,
|
|
136
|
+
MenuRadioItemIndicator,
|
|
137
|
+
MenuSeparator,
|
|
138
|
+
MenuSubmenuRoot,
|
|
139
|
+
MenuSubmenuTrigger,
|
|
140
|
+
MenuTrigger,
|
|
141
|
+
type MenuContentProps,
|
|
142
|
+
type MenuRadioItemProps,
|
|
143
|
+
} from "./menu.tsx";
|
|
144
|
+
|
|
145
|
+
export { Tab, TabIndicator, TabList, TabPanel, Tabs, type TabListProps } from "./tabs.tsx";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { textareaHeight } from "./input.tsx";
|
|
3
|
+
|
|
4
|
+
describe("textareaHeight", () => {
|
|
5
|
+
it("grows with the content", () => {
|
|
6
|
+
expect(textareaHeight(400, 72)).toBe("72px");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("stops at the cap, so a long paste scrolls instead of taking the page", () => {
|
|
10
|
+
expect(textareaHeight(400, 900)).toBe("160px");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// A field squeezed to nothing wraps every word, so scrollHeight reads as tall as the cap
|
|
14
|
+
// and the box opens to full height for one line of text. Hand it back to CSS instead.
|
|
15
|
+
it("does not autosize a field too narrow to measure honestly", () => {
|
|
16
|
+
expect(textareaHeight(120, 900)).toBe("");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("takes over again the moment the column is wide enough", () => {
|
|
20
|
+
expect(textareaHeight(180, 40)).toBe("40px");
|
|
21
|
+
});
|
|
22
|
+
});
|
package/src/ui/input.tsx
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Input as Primitive } from "@base-ui/react/input";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "../cn.ts";
|
|
4
|
+
|
|
5
|
+
/** Below this width a textarea wraps almost every word, so `scrollHeight` jumps straight to
|
|
6
|
+
* the cap and the field opens to full height for one word. Under it, let CSS decide. */
|
|
7
|
+
const TEXTAREA_MIN_AUTO_WIDTH = 180;
|
|
8
|
+
/** Past this the textarea stops growing and starts scrolling, so a long paste cannot push
|
|
9
|
+
* the submit button off the screen. */
|
|
10
|
+
const TEXTAREA_MAX_HEIGHT = 160;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The inline height an autosizing textarea should carry, or `""` for "leave it to CSS".
|
|
14
|
+
*
|
|
15
|
+
* ponytail: this whole observer is here only until `field-sizing: content` is baseline —
|
|
16
|
+
* one CSS line does the same job. Ceiling: it does not exist in every browser we support yet.
|
|
17
|
+
*/
|
|
18
|
+
export function textareaHeight(clientWidth: number, scrollHeight: number): string {
|
|
19
|
+
if (clientWidth < TEXTAREA_MIN_AUTO_WIDTH) return "";
|
|
20
|
+
return `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type FieldChrome = {
|
|
24
|
+
label?: React.ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* The message under the field. Its presence is what wires `aria-invalid` and
|
|
27
|
+
* `aria-describedby`, so a field that looks wrong also announces that it is.
|
|
28
|
+
*/
|
|
29
|
+
error?: React.ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* Classes for the control. A plain string here, not Base UI's state-callback form: this
|
|
32
|
+
* component renders a native `<textarea>` for the multiline case, which has no Base UI
|
|
33
|
+
* state to call back with, and one field type styling differently from the other would be
|
|
34
|
+
* a worse trade than the callback is worth.
|
|
35
|
+
*/
|
|
36
|
+
className?: string;
|
|
37
|
+
wrapperClassName?: string;
|
|
38
|
+
labelClassName?: string;
|
|
39
|
+
errorClassName?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type InputProps = FieldChrome & Omit<Primitive.Props, "className"> & { multiline?: false };
|
|
43
|
+
|
|
44
|
+
export type MultilineInputProps = FieldChrome &
|
|
45
|
+
Omit<React.ComponentProps<"textarea">, "className"> & { multiline: true };
|
|
46
|
+
|
|
47
|
+
export type FieldProps = InputProps | MultilineInputProps;
|
|
48
|
+
|
|
49
|
+
function withoutChrome<P extends FieldChrome & { multiline?: boolean }>(props: P) {
|
|
50
|
+
const {
|
|
51
|
+
label: _label,
|
|
52
|
+
error: _error,
|
|
53
|
+
className: _className,
|
|
54
|
+
wrapperClassName: _wrapperClassName,
|
|
55
|
+
labelClassName: _labelClassName,
|
|
56
|
+
errorClassName: _errorClassName,
|
|
57
|
+
multiline: _multiline,
|
|
58
|
+
...rest
|
|
59
|
+
} = props;
|
|
60
|
+
return rest;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `border-input` rather than `border-border`: a control's boundary is a non-text contrast
|
|
65
|
+
* target and has to clear 3:1 (WCAG 1.4.11), which the hairline between two panels does not.
|
|
66
|
+
*
|
|
67
|
+
* `text-base` up to `md`: iOS Safari zooms the whole page in when a field it focuses has type
|
|
68
|
+
* smaller than 16px, and it does not zoom back out. The pair is the fix, not a size choice.
|
|
69
|
+
*
|
|
70
|
+
* No focus style here, deliberately. The app's one ring is the `:focus-visible` outline in
|
|
71
|
+
* `@gusnips/tokens`' base layer, and a control that swaps it for `outline-none` plus a
|
|
72
|
+
* `ring-*` gains nothing and loses forced-colors mode: a ring is a `box-shadow`, which the UA
|
|
73
|
+
* forces to `none` under Windows High Contrast, while an outline survives and takes a system
|
|
74
|
+
* colour. The swap therefore deletes the focus indicator for exactly the people who need it
|
|
75
|
+
* most — and it is invisible in review, because it looks correct in every normal browser.
|
|
76
|
+
*/
|
|
77
|
+
const controlClasses =
|
|
78
|
+
"w-full min-w-0 rounded-md border border-input bg-background px-3 py-2 text-base text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive md:text-sm";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A field with its label, its error, and the aria that ties the three together.
|
|
82
|
+
*
|
|
83
|
+
* That wiring is the reason this exists: `htmlFor`/`id`, `aria-describedby` pointing at the
|
|
84
|
+
* error, `aria-invalid` set from the same prop that draws the red border, and an id generated
|
|
85
|
+
* when the caller has not given one. Every one of those is a line somebody forgets, and
|
|
86
|
+
* forgetting it is invisible until a screen reader reads the field with no name and no reason.
|
|
87
|
+
*/
|
|
88
|
+
export function Input(props: FieldProps) {
|
|
89
|
+
const { label, error, className, wrapperClassName, labelClassName, errorClassName } = props;
|
|
90
|
+
const generatedId = React.useId();
|
|
91
|
+
const fieldId = props.id ?? (label || error ? generatedId : undefined);
|
|
92
|
+
const errorId = error ? `${fieldId ?? generatedId}-error` : undefined;
|
|
93
|
+
// Appended, never replaced: a caller describing the field with a hint of their own keeps it.
|
|
94
|
+
const describedBy = errorId
|
|
95
|
+
? [props["aria-describedby"], errorId].filter(Boolean).join(" ")
|
|
96
|
+
: props["aria-describedby"];
|
|
97
|
+
|
|
98
|
+
const shared = {
|
|
99
|
+
id: fieldId,
|
|
100
|
+
"aria-invalid": error ? true : props["aria-invalid"],
|
|
101
|
+
"aria-describedby": describedBy,
|
|
102
|
+
className: cn(controlClasses, props.multiline && "resize-none", className),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className={cn("w-full", wrapperClassName)}>
|
|
107
|
+
{label ? (
|
|
108
|
+
<label htmlFor={fieldId} className={cn("mb-1.5 block text-sm", labelClassName)}>
|
|
109
|
+
{label}
|
|
110
|
+
</label>
|
|
111
|
+
) : null}
|
|
112
|
+
{props.multiline ? (
|
|
113
|
+
<AutosizeTextarea {...withoutChrome(props)} {...shared} />
|
|
114
|
+
) : (
|
|
115
|
+
<Primitive {...withoutChrome(props)} {...shared} />
|
|
116
|
+
)}
|
|
117
|
+
{error ? (
|
|
118
|
+
<div
|
|
119
|
+
id={errorId}
|
|
120
|
+
role="alert"
|
|
121
|
+
className={cn("mt-1.5 text-xs text-destructive", errorClassName)}
|
|
122
|
+
>
|
|
123
|
+
{error}
|
|
124
|
+
</div>
|
|
125
|
+
) : null}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A textarea that grows with its content and then scrolls.
|
|
132
|
+
*
|
|
133
|
+
* It watches its own SIZE, not just its value: a textarea also reflows when the column it
|
|
134
|
+
* sits in changes width — a drawer opening, a sidebar collapsing, a phone turning — and
|
|
135
|
+
* height set from the old width is either a gap under the text or a scrollbar over two lines.
|
|
136
|
+
*/
|
|
137
|
+
function AutosizeTextarea({ ref, onInput, ...rest }: React.ComponentProps<"textarea">) {
|
|
138
|
+
const element = React.useRef<HTMLTextAreaElement | null>(null);
|
|
139
|
+
|
|
140
|
+
const setElement = React.useCallback(
|
|
141
|
+
(node: HTMLTextAreaElement | null) => {
|
|
142
|
+
element.current = node;
|
|
143
|
+
if (typeof ref === "function") ref(node);
|
|
144
|
+
else if (ref) ref.current = node;
|
|
145
|
+
},
|
|
146
|
+
[ref],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const fit = React.useCallback(() => {
|
|
150
|
+
const node = element.current;
|
|
151
|
+
if (!node) return;
|
|
152
|
+
// Reset first. `scrollHeight` is the content height OR the box height, whichever is
|
|
153
|
+
// larger, so a box already held open at yesterday's height never reports a smaller one —
|
|
154
|
+
// the field would grow and never shrink.
|
|
155
|
+
node.style.height = "auto";
|
|
156
|
+
node.style.height = textareaHeight(node.clientWidth, node.scrollHeight);
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
React.useEffect(() => {
|
|
160
|
+
fit();
|
|
161
|
+
}, [fit, rest.value, rest.placeholder]);
|
|
162
|
+
|
|
163
|
+
React.useEffect(() => {
|
|
164
|
+
const node = element.current;
|
|
165
|
+
if (!node || typeof ResizeObserver === "undefined") return undefined;
|
|
166
|
+
const observer = new ResizeObserver(fit);
|
|
167
|
+
observer.observe(node);
|
|
168
|
+
return () => observer.disconnect();
|
|
169
|
+
}, [fit]);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<textarea
|
|
173
|
+
{...rest}
|
|
174
|
+
ref={setElement}
|
|
175
|
+
onInput={(event) => {
|
|
176
|
+
fit();
|
|
177
|
+
onInput?.(event);
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The group-caption rule, which only a structure can keep.
|
|
3
|
+
*
|
|
4
|
+
* `GroupLabel` reads its group's context in all three components that have one, so a caption
|
|
5
|
+
* written as a SIBLING of the group throws at render. A production build reports that as
|
|
6
|
+
* `Base UI error #31` for the menu, `#56` for select and `#18` for combobox, plus a link — a
|
|
7
|
+
* number to go look up, naming neither the file, nor the part, nor the component. Documenting
|
|
8
|
+
* that would not have stopped it. Two things do, and both are checked here.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
// `fileURLToPath`, never `import.meta.dir` — invariant 11.
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Comments out, code in. A guard that reads its own explanation as a violation is a guard that
|
|
20
|
+
* teaches people to delete the explanation — and this rule is worth a paragraph that has to be
|
|
21
|
+
* free to name the part it forbids.
|
|
22
|
+
*/
|
|
23
|
+
function codeOf(name: string): string {
|
|
24
|
+
return readFileSync(join(here, name), "utf8")
|
|
25
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
26
|
+
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const count = (text: string, pattern: RegExp) => text.match(pattern)?.length ?? 0;
|
|
30
|
+
|
|
31
|
+
/** Every `<Primitive.Group>…</Primitive.Group>` body in a file, for one group kind. */
|
|
32
|
+
function bodiesOf(code: string, part: string): string[] {
|
|
33
|
+
const tag = `<Primitive\\.${part}\\b[^>]*>[\\s\\S]*?</Primitive\\.${part}>`;
|
|
34
|
+
return code.match(new RegExp(tag, "g")) ?? [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function misplacedCaptions(code: string): number {
|
|
38
|
+
const inside = ["Group", "RadioGroup"]
|
|
39
|
+
.flatMap((part) => bodiesOf(code, part))
|
|
40
|
+
.reduce((total, body) => total + count(body, /<Primitive\.GroupLabel\b/g), 0);
|
|
41
|
+
return count(code, /<Primitive\.GroupLabel\b/g) - inside;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sources = readdirSync(here)
|
|
45
|
+
.filter((name) => name.endsWith(".tsx"))
|
|
46
|
+
.map((name) => ({ name, code: codeOf(name) }));
|
|
47
|
+
|
|
48
|
+
describe("a group caption cannot be placed outside its group", () => {
|
|
49
|
+
// Folder-wide rather than menu-only: today `menu.tsx` is the one file that renders a
|
|
50
|
+
// caption, but `Select` and `Combobox` both have the part and would grow one the day either
|
|
51
|
+
// wrapper learns grouped options. That is exactly where this bug lands next.
|
|
52
|
+
it.each(sources)("$name keeps every GroupLabel inside a Group", ({ code }) => {
|
|
53
|
+
expect(misplacedCaptions(code)).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("has a caption somewhere to check, so the rule above is not vacuous", () => {
|
|
57
|
+
const captions = sources.reduce(
|
|
58
|
+
(total, { code }) => total + count(code, /<Primitive\.GroupLabel\b/g),
|
|
59
|
+
0,
|
|
60
|
+
);
|
|
61
|
+
expect(captions).toBeGreaterThan(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("never exports the menu's GroupLabel, Group or RadioGroup as raw parts", () => {
|
|
65
|
+
// Menu only, on purpose. `MenuGroup` composes the caption as a prop, so the raw parts have
|
|
66
|
+
// no job left and withholding them is free. Select and Combobox export theirs as the
|
|
67
|
+
// escape hatch for grouped options, a shape those wrappers do not express — see the note
|
|
68
|
+
// beside them in index.ts. Widening this assertion would forbid that on purpose.
|
|
69
|
+
const surface = codeOf("menu.tsx") + codeOf("index.ts");
|
|
70
|
+
expect(surface).not.toMatch(/=\s*Primitive\.(?:GroupLabel|Group|RadioGroup)\s*;/);
|
|
71
|
+
expect(surface).not.toMatch(/\bMenuGroupLabel\b/);
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/ui/menu.tsx
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Menu as Primitive } from "@base-ui/react/menu";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { mergeClassName } from "./class-name.ts";
|
|
4
|
+
import { CheckGlyph } from "./glyphs.tsx";
|
|
5
|
+
|
|
6
|
+
// The parts — every one of them EXCEPT `GroupLabel`, and every one except `Group` and
|
|
7
|
+
// `RadioGroup`, which are wrapped below. That omission is not tidying: it is the only thing
|
|
8
|
+
// that actually prevents the bug. `Menu.GroupLabel` reads its group's context, so a caption
|
|
9
|
+
// written as a SIBLING of the group throws at render, and a production build reports that as
|
|
10
|
+
// `Base UI error #31` plus a link — a number to go look up, naming neither the file, nor the
|
|
11
|
+
// part, nor the component it came from. A comment cannot stop that; not shipping the part
|
|
12
|
+
// can. The caption is a prop on the group instead, so the wrong arrangement has nowhere to be
|
|
13
|
+
// written. `Select.GroupLabel` and `Combobox.GroupLabel` throw the same way (#56 and #18) and
|
|
14
|
+
// ARE exported — see the note beside them in index.ts for why that is the right call there.
|
|
15
|
+
export const Menu = Primitive.Root;
|
|
16
|
+
export const MenuTrigger = Primitive.Trigger;
|
|
17
|
+
export const MenuPortal = Primitive.Portal;
|
|
18
|
+
export const MenuPositioner = Primitive.Positioner;
|
|
19
|
+
export const MenuPopup = Primitive.Popup;
|
|
20
|
+
export const MenuBackdrop = Primitive.Backdrop;
|
|
21
|
+
export const MenuArrow = Primitive.Arrow;
|
|
22
|
+
export const MenuLinkItem = Primitive.LinkItem;
|
|
23
|
+
export const MenuCheckboxItem = Primitive.CheckboxItem;
|
|
24
|
+
export const MenuCheckboxItemIndicator = Primitive.CheckboxItemIndicator;
|
|
25
|
+
export const MenuRadioItemIndicator = Primitive.RadioItemIndicator;
|
|
26
|
+
export const MenuSubmenuRoot = Primitive.SubmenuRoot;
|
|
27
|
+
export const MenuSubmenuTrigger = Primitive.SubmenuTrigger;
|
|
28
|
+
|
|
29
|
+
export type MenuContentProps = Primitive.Popup.Props & {
|
|
30
|
+
/**
|
|
31
|
+
* Accessible name for the menu. Usually leave this out: Base UI already points the popup's
|
|
32
|
+
* `aria-labelledby` at whichever trigger opened it, so a name here REPLACES the words on
|
|
33
|
+
* the button a screen reader just read. Pass it only when the menu is about something the
|
|
34
|
+
* trigger does not say.
|
|
35
|
+
*/
|
|
36
|
+
label?: string;
|
|
37
|
+
/** Where the portal mounts. Defaults to `<body>`. */
|
|
38
|
+
container?: Primitive.Portal.Props["container"];
|
|
39
|
+
/**
|
|
40
|
+
* Positioner props — `side`, `align`, `sideOffset`, `collisionPadding`, and the stacking
|
|
41
|
+
* order. The default `z-[60]` puts the menu above the overlay layer (z-50: dialog, drawer)
|
|
42
|
+
* so a menu opened inside a dialog is not painted behind it; raise it with
|
|
43
|
+
* `positionerProps={{ className: "z-[80]" }}` if the app stacks something higher.
|
|
44
|
+
*/
|
|
45
|
+
positionerProps?: Primitive.Positioner.Props;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A dropdown of decisions — the account menu, a row's actions, a picker. Portal, positioner
|
|
50
|
+
* and popup composed once, for the same reason the dialog's are: the arrangement is not
|
|
51
|
+
* optional and getting it wrong throws a number.
|
|
52
|
+
*
|
|
53
|
+
* Roving focus, typeahead, escape, outside-dismiss and focus return all come from Base UI.
|
|
54
|
+
* Nothing here re-implements any of them.
|
|
55
|
+
*/
|
|
56
|
+
export function MenuContent({
|
|
57
|
+
className,
|
|
58
|
+
children,
|
|
59
|
+
label,
|
|
60
|
+
container,
|
|
61
|
+
positionerProps,
|
|
62
|
+
...rest
|
|
63
|
+
}: MenuContentProps) {
|
|
64
|
+
return (
|
|
65
|
+
<Primitive.Portal container={container}>
|
|
66
|
+
<Primitive.Positioner
|
|
67
|
+
side="bottom"
|
|
68
|
+
align="end"
|
|
69
|
+
sideOffset={6}
|
|
70
|
+
{...positionerProps}
|
|
71
|
+
className={mergeClassName(
|
|
72
|
+
"z-[60] max-w-[var(--available-width)]",
|
|
73
|
+
positionerProps?.className,
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{/* `outline-none` on the popup and nowhere else in this file: the popup takes focus
|
|
77
|
+
programmatically the moment it opens, and the ring is meant to answer a keyboard,
|
|
78
|
+
not to frame a panel that just appeared. The items keep theirs — see below. */}
|
|
79
|
+
<Primitive.Popup
|
|
80
|
+
aria-label={label}
|
|
81
|
+
{...rest}
|
|
82
|
+
className={mergeClassName(
|
|
83
|
+
"max-h-[var(--available-height)] min-w-[var(--anchor-width)] origin-[var(--transform-origin)] overflow-y-auto 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",
|
|
84
|
+
className,
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{children}
|
|
88
|
+
</Primitive.Popup>
|
|
89
|
+
</Primitive.Positioner>
|
|
90
|
+
</Primitive.Portal>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Base UI gives the highlighted item a real `tabIndex: 0` and moves DOM focus to it, so a
|
|
96
|
+
* keyboard user gets the app's `:focus-visible` outline for free and `data-highlighted` draws
|
|
97
|
+
* the pointer's highlight. Nothing here suppresses either — a menu row is one of the places
|
|
98
|
+
* the ring is most needed and most often deleted.
|
|
99
|
+
*
|
|
100
|
+
* The coarse-pointer minimum is a 44px row: a thumb misses anything smaller, and a menu is
|
|
101
|
+
* usually a list of one-way actions.
|
|
102
|
+
*/
|
|
103
|
+
const itemClasses =
|
|
104
|
+
"flex cursor-default items-center gap-2 px-3 py-1.5 select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-accent data-highlighted:text-accent-foreground [@media(pointer:coarse)]:min-h-11";
|
|
105
|
+
|
|
106
|
+
export function MenuItem({ className, ...rest }: Primitive.Item.Props) {
|
|
107
|
+
return <Primitive.Item {...rest} className={mergeClassName(itemClasses, className)} />;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function MenuSeparator({ className, ...rest }: Primitive.Separator.Props) {
|
|
111
|
+
return (
|
|
112
|
+
<Primitive.Separator {...rest} className={mergeClassName("my-1 h-px bg-border", className)} />
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The caption a group is named by, or nothing. Never a part you place yourself. */
|
|
117
|
+
type MenuGroupCaption = { label?: string };
|
|
118
|
+
|
|
119
|
+
const captionClasses = "px-3 pt-2 pb-1 text-muted-foreground";
|
|
120
|
+
|
|
121
|
+
// The caption block below is written out TWICE, once per group kind, and that is deliberate.
|
|
122
|
+
// Lifting it into a `caption(label)` helper is the obvious cleanup and it was tried: it moves
|
|
123
|
+
// the `<Primitive.GroupLabel>` out of the `<Primitive.Group>` body it is written inside, and
|
|
124
|
+
// `menu.test.ts` fails immediately — correctly. That guard proves placement by READING THE
|
|
125
|
+
// SOURCE, so an indirection it cannot follow does not just break the test, it retires the
|
|
126
|
+
// guarantee: after the extraction nothing stops a later edit calling `caption()` somewhere that
|
|
127
|
+
// is not inside a group, which is the render-time throw this whole file exists to prevent.
|
|
128
|
+
// Six duplicated lines are the price of a rule a machine can still check. Leave them.
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A set of related items with an optional caption.
|
|
132
|
+
*
|
|
133
|
+
* The caption is a PROP because that is the only arrangement that cannot be got wrong: it
|
|
134
|
+
* lands inside the group, which is both what stops Base UI throwing and what gives the set
|
|
135
|
+
* its `aria-labelledby`. It is also not focusable, so it never eats an arrow-key stop.
|
|
136
|
+
* Optional, because a group whose trigger already says what it is would only repeat itself.
|
|
137
|
+
*/
|
|
138
|
+
export function MenuGroup({ label, children, ...rest }: Primitive.Group.Props & MenuGroupCaption) {
|
|
139
|
+
return (
|
|
140
|
+
<Primitive.Group {...rest}>
|
|
141
|
+
{label === undefined ? null : (
|
|
142
|
+
<Primitive.GroupLabel className={captionClasses}>{label}</Primitive.GroupLabel>
|
|
143
|
+
)}
|
|
144
|
+
{children}
|
|
145
|
+
</Primitive.Group>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A set of choices — theme, locale, one axis of a filter. Same caption rule as `MenuGroup`. */
|
|
150
|
+
export function MenuRadioGroup({
|
|
151
|
+
label,
|
|
152
|
+
children,
|
|
153
|
+
...rest
|
|
154
|
+
}: Primitive.RadioGroup.Props & MenuGroupCaption) {
|
|
155
|
+
return (
|
|
156
|
+
<Primitive.RadioGroup {...rest}>
|
|
157
|
+
{label === undefined ? null : (
|
|
158
|
+
<Primitive.GroupLabel className={captionClasses}>{label}</Primitive.GroupLabel>
|
|
159
|
+
)}
|
|
160
|
+
{children}
|
|
161
|
+
</Primitive.RadioGroup>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type MenuRadioItemProps = Primitive.RadioItem.Props & {
|
|
166
|
+
/** Mark for the chosen item. */
|
|
167
|
+
indicator?: ReactNode;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One choice in a set. The mark keeps a fixed leading column whether or not it is showing, so
|
|
172
|
+
* the labels do not shift sideways when the selection moves.
|
|
173
|
+
*/
|
|
174
|
+
export function MenuRadioItem({ className, children, indicator, ...rest }: MenuRadioItemProps) {
|
|
175
|
+
return (
|
|
176
|
+
<Primitive.RadioItem
|
|
177
|
+
{...rest}
|
|
178
|
+
className={mergeClassName([itemClasses, "data-checked:text-foreground"], className)}
|
|
179
|
+
>
|
|
180
|
+
<span aria-hidden="true" className="grid size-4 shrink-0 place-items-center">
|
|
181
|
+
<Primitive.RadioItemIndicator>{indicator ?? <CheckGlyph />}</Primitive.RadioItemIndicator>
|
|
182
|
+
</span>
|
|
183
|
+
{children}
|
|
184
|
+
</Primitive.RadioItem>
|
|
185
|
+
);
|
|
186
|
+
}
|