@stealthscale/component-disclosure 0.1.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,25 @@
1
1
  # @stealthscale/component-disclosure
2
2
 
3
- Draws what is shown and hidden on the reader's say-so. Every component binds a recipe and draws
4
- nothing of its own, so a theme restyles all of them by extending the recipe. The preset under
5
- `./theme` registers the recipes with an application's compiler.
6
-
7
- Every value a theme can change on a component is an axis of its recipe, so a caller sets it as a
8
- prop and writes no style. A caller changes the element a component draws with `as`.
3
+ React components that show and hide content, styled by the theme's recipes.
4
+
5
+ | Component | Renders |
6
+ | ------------- | --------------------------------------------------------------- |
7
+ | `Collapsible` | A trigger that shows and hides the content beneath it |
8
+ | `Details` | A native `details` element the browser opens and closes |
9
+ | `Accordion` | A column of headings whose triggers show and hide their content |
10
+ | `Tabs` | A list of tabs and the panel of the selected tab |
11
+ | `Steps` | A flow in steps, the current step's content and its buttons |
12
+ | `Popover` | A panel anchored to its trigger |
13
+ | `Tooltip` | A short label shown on hover and focus |
14
+ | `Truncate` | Text clipped to its lines, with the whole text in a tooltip |
15
+ | `ToggleTip` | A short note beside its trigger, opened by a press |
16
+ | `HoverCard` | A card beside a link that previews what the link leads to |
17
+ | `Menu` | A list of actions and options opened from a trigger |
18
+ | `Menubar` | A row of menus along the top of an application |
19
+
20
+ Every value a theme can change is an axis of a component's recipe. Set it as a prop, and write no
21
+ style. Change the element a component renders with `as`. A component with parts is exported as a
22
+ namespace, such as `Menu.Root`.
9
23
 
10
24
  ## Install
11
25
 
@@ -13,159 +27,387 @@ prop and writes no style. A caller changes the element a component draws with `a
13
27
  pnpm add @stealthscale/component-disclosure
14
28
  ```
15
29
 
16
- The package peers on `react`, `@stealthscale/hooks` and `@stealthscale/theme`, and installs the
17
- state machines it draws from. An application lists the preset under `./theme` among the presets its
18
- compiler installs.
30
+ The package peers on `react`, `@stealthscale/hooks`, `@stealthscale/theme`,
31
+ `@stealthscale/component-a11y` and `@stealthscale/component-primitives`, and depends on the Zag
32
+ state machines. List the preset under `./theme` among the presets your compiler installs.
19
33
 
20
34
  ## Collapsible
21
35
 
22
- Shows and hides the block beneath a control, composed as `Collapsible.Root` holding a trigger and
23
- the block.
36
+ `Collapsible` renders a trigger that shows and hides the content beneath it.
24
37
 
25
38
  ```tsx
39
+ import { ChevronDownIcon } from "lucide-react";
40
+
26
41
  import { Collapsible } from "@stealthscale/component-disclosure";
27
42
 
28
43
  <Collapsible.Root variant="outline">
29
44
  <Collapsible.Trigger>
30
45
  Delivery details
31
46
  <Collapsible.Indicator>
32
- <ChevronIcon />
47
+ <ChevronDownIcon size="100%" />
33
48
  </Collapsible.Indicator>
34
49
  </Collapsible.Trigger>
35
50
  <Collapsible.Content>Arrives Thursday.</Collapsible.Content>
36
51
  </Collapsible.Root>;
37
52
  ```
38
53
 
39
- | Axis | Values | Default |
40
- | --------- | ------------------------------------------------- | ------- |
41
- | `variant` | `plain`, `outline`, `subtle`, `surface` | `plain` |
42
- | `size` | `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `4xl` | `md` |
43
- | `motion` | `slide`, `fade`, `none` | `slide` |
44
-
45
- The size sets the trigger, the block and the mark from one step of the scale. The trigger reads the
46
- control scale, so a collapsible lines up with a button of the same size beside it.
47
-
48
- ### The machine's settings
49
-
50
- Beside the axes, the root takes the settings the machine reads. None of them is a style.
51
-
52
- | Setting | What it does |
53
- | --------------------- | ------------------------------------------------------ |
54
- | `open`, `defaultOpen` | Sets the state from outside, or sets where it starts |
55
- | `onOpenChange` | Called each time it opens or closes |
56
- | `disabled` | Stops the control opening it |
57
- | `collapsedHeight` | Leaves a strip of the block showing while it is closed |
58
- | `onExitComplete` | Runs once the closing animation has finished |
59
- | `id` | Names the machine, which builds its ARIA references |
60
- | `dir` | Sets which way the line runs |
61
-
62
- `collapsedHeight` is what turns a disclosure into a preview. The block keeps the height you name
63
- while closed, so a reader still sees the first line or two and the control reveals the rest.
54
+ | Axis | Values | Default |
55
+ | --------- | ----------------------------------------------------------------- | --------- |
56
+ | `variant` | `subtle`, `surface`, `outline`, `plain` | `plain` |
57
+ | `size` | `xs` to `4xl` | `md` |
58
+ | `palette` | `primary`, `secondary`, `accent`, `neutral` and the four statuses | `neutral` |
59
+ | `motion` | `slide`, `fade`, `none` | `slide` |
60
+
61
+ | Part | Element | What it renders |
62
+ | ----------- | -------- | --------------------------------------------- |
63
+ | `Root` | `div` | The root, and the machine its parts share |
64
+ | `Trigger` | `button` | The button that shows and hides the content |
65
+ | `Content` | `div` | The content |
66
+ | `Indicator` | `span` | The mark that turns while the content is open |
67
+
68
+ - The root takes the machine's settings: `open`, `defaultOpen`, `onOpenChange`, `disabled`,
69
+ `collapsedHeight`, `onExitComplete`, `id` and `dir`.
70
+ - `collapsedHeight` keeps part of the closed content visible, such as `"2lh"` for two lines.
71
+ - Pass `id` to the root for a stable id in tests or server rendering. The machine derives the
72
+ trigger's `aria-controls` from it, so the root takes no element `id`.
73
+ - The machine sets `aria-expanded` and `aria-controls` on the trigger, and `hidden` on closed
74
+ content, so a control inside closed content leaves the tab order.
75
+ - The indicator sets `aria-hidden` and turns 180 degrees while the content is open. Reduced motion
76
+ removes the transition.
77
+ - Content that starts open does not animate in, because the machine sets its open state from the
78
+ first animation frame.
79
+ - The size sets the trigger on the control scale, so its height matches a button of the same size.
80
+ The palette tints the subtle, surface and outline looks.
81
+
82
+ ## Details
83
+
84
+ `Details` renders a native `details` element, which the browser opens and closes without a script.
64
85
 
65
86
  ```tsx
66
- <Collapsible.Root collapsedHeight="3lh" defaultOpen={false}>
87
+ import { ChevronRightIcon } from "lucide-react";
88
+
89
+ import { Details } from "@stealthscale/component-disclosure";
90
+
91
+ <Details.Root>
92
+ <Details.Summary>
93
+ <Details.Indicator>
94
+ <ChevronRightIcon />
95
+ </Details.Indicator>
96
+ How long does a refund take?
97
+ </Details.Summary>
98
+ <Details.Content>A refund reaches the card within 5 to 10 business days.</Details.Content>
99
+ </Details.Root>;
67
100
  ```
68
101
 
69
- Name an `id` where you want a stable one, in a test or where the markup is rendered on a server.
70
- Pass it to the root rather than to an element, because the machine builds the reference between the
71
- control and the block from it. The root takes no element `id` or `dir` for that reason.
72
-
73
- ### The accessibility the machine writes
102
+ | Axis | Values | Default |
103
+ | --------- | ----------------------------------------------------------------- | --------- |
104
+ | `variant` | `subtle`, `surface`, `outline`, `plain` | `outline` |
105
+ | `size` | `xs` to `4xl` | `md` |
106
+ | `palette` | `primary`, `secondary`, `accent`, `neutral` and the four statuses | `neutral` |
107
+
108
+ | Part | Element | What it renders |
109
+ | ----------- | --------- | --------------------------------------------- |
110
+ | `Root` | `details` | The root, which the browser opens |
111
+ | `Summary` | `summary` | The line that names it and opens it |
112
+ | `Indicator` | `span` | The mark that turns while the details is open |
113
+ | `Content` | `div` | The content |
114
+
115
+ - The root takes the element's own props. `open` sets the state on the first render and whenever it
116
+ changes, `onToggle` reports every change, and details with the same `name` form an exclusive
117
+ group, in which opening one closes the others.
118
+ - The browser gives the summary its role, its name from its text and its expanded state, and opens
119
+ it on a press, Enter and Space. It also opens a closed details to show a fragment the address
120
+ points into.
121
+ - The indicator sets `aria-hidden`, goes before the words and turns a quarter clockwise while the
122
+ details is open, a quarter the other way under `dir="rtl"`. Pass a chevron that points to the end.
123
+ An `svg` in the indicator fills it, so the glyph scales with the size.
124
+ - The looks and the palette are the collapsible's. A one-line summary is a collapsible trigger's
125
+ height at the same size, and a longer one grows by its lines. The plain look draws no box and pads
126
+ no side, so its summary and content line up with the text around them. Under forced colors the
127
+ subtle box takes a `CanvasText` outline.
128
+ - Use `Collapsible` for open state the application controls or animates. `Details` has no
129
+ `onOpenChange` and no motion.
130
+
131
+ ## Accordion
132
+
133
+ `Accordion` renders a column of headings. Each heading contains a trigger that shows and hides the
134
+ content under it.
74
135
 
75
- None of this is a prop you can forget, because the machine writes all of it:
76
-
77
- - **The control reports its state.** It carries `aria-expanded` and points at the block with
78
- `aria-controls`.
79
- - **A closed block is out of reach.** It is hidden from a screen reader and from the tab order, so a
80
- keyboard passes over any control inside one.
81
- - **The mark is not announced.** The control already reports whether the block is expanded, so the
82
- indicator is kept out of the accessibility tree and turns on the state alone.
83
-
84
- The mark turns half a revolution as the block opens, and holds still for a reader who asked for
85
- reduced motion. Both motions read animation styles the theme owns, so a theme decides the pace and
86
- the reduced-motion behaviour once for everything.
136
+ ```tsx
137
+ import { ChevronDownIcon } from "lucide-react";
138
+
139
+ import { Accordion } from "@stealthscale/component-disclosure";
140
+
141
+ <Accordion.Root collapsible defaultValue={["delivery"]}>
142
+ <Accordion.Item value="delivery">
143
+ <Accordion.ItemHeading>
144
+ <Accordion.ItemTrigger>
145
+ How long does delivery take?
146
+ <Accordion.ItemIndicator>
147
+ <ChevronDownIcon size="100%" />
148
+ </Accordion.ItemIndicator>
149
+ </Accordion.ItemTrigger>
150
+ </Accordion.ItemHeading>
151
+ <Accordion.ItemContent>
152
+ <Accordion.ItemBody>Orders arrive in one to three working days.</Accordion.ItemBody>
153
+ </Accordion.ItemContent>
154
+ </Accordion.Item>
155
+ </Accordion.Root>;
156
+ ```
87
157
 
88
- A block that starts open does not animate in. The machine holds its state attribute back on the
89
- first render for exactly that reason, and writes it from the first animation frame onwards.
158
+ | Axis | Values | Default |
159
+ | --------- | ----------------------------------------------------------------- | --------- |
160
+ | `variant` | `subtle`, `surface`, `outline`, `flushed`, `plain` | `flushed` |
161
+ | `size` | `sm`, `md`, `lg` | `md` |
162
+ | `palette` | `primary`, `secondary`, `accent`, `neutral` and the four statuses | `neutral` |
163
+ | `motion` | `slide`, `fade`, `none` | `slide` |
164
+
165
+ | Part | Element | What it renders |
166
+ | --------------- | -------- | --------------------------------------------------- |
167
+ | `Root` | `div` | The root, and the machine its items share |
168
+ | `Item` | `div` | One item, and the machine that animates its content |
169
+ | `ItemHeading` | `h3` | The heading that contains the trigger |
170
+ | `ItemTrigger` | `button` | The button that opens and closes the item |
171
+ | `ItemIndicator` | `span` | The mark that turns while the item is open |
172
+ | `ItemContent` | `div` | The region the trigger shows and hides |
173
+ | `ItemBody` | `div` | The padded block inside the content |
174
+
175
+ - The root takes the machine's settings: `value`, `defaultValue`, `onValueChange`, `multiple`,
176
+ `collapsible`, `disabled`, `onFocusChange`, `ids`, `id` and `dir`. `value` is an array of the
177
+ values of the open items.
178
+ - One item is open at a time. `multiple` keeps the other items open when one opens. `collapsible`
179
+ lets a press close the open item, so that no item is open.
180
+ - The trigger sets `aria-expanded` and `aria-controls`. The content is a `region` named by its
181
+ trigger and is `hidden` while closed, so a control inside a closed item leaves the tab order.
182
+ - ArrowDown and ArrowUp move focus to the next and the previous trigger, and Home and End move it to
183
+ the first and the last. The keys skip a disabled trigger.
184
+ - `disabled` on the root disables every item, whatever an item sets. `disabled` on an item disables
185
+ that item alone.
186
+ - An item's value may contain a space. The IDs built from it are percent-encoded.
187
+ - The heading is an `h3`. Pass another level through `as` to fit the page's outline.
188
+ - A button after the trigger inside the heading, such as a download action, renders at the end of
189
+ the row and outside the trigger. The heading's accessible name then includes the button's name.
190
+ - The content animates to the height the machine measures. `ItemBody` has the padding, so put the
191
+ words inside it. An item that starts open does not animate in, and reduced motion removes the
192
+ animation.
193
+ - The trigger is at least a control's height and grows with a title that wraps. It sizes a leading
194
+ `svg` one icon size smaller than the indicator.
195
+ - The subtle, surface and outline looks inset the trigger and the body from the box's edges. The
196
+ flushed and plain looks align both with the text around the accordion.
197
+ - The machine's `orientation` is not offered, because the recipe lays the items out in a column.
198
+ Closed content remains in the document under `hidden`.
90
199
 
91
200
  ## Tabs
92
201
 
93
- Shows one panel at a time, chosen from a strip of controls, composed as `Tabs.Root` holding a list
94
- and a panel for each control.
202
+ `Tabs` renders a list of tabs and the panel of the selected tab. A person can close a closable tab.
95
203
 
96
204
  ```tsx
97
205
  import { Tabs } from "@stealthscale/component-disclosure";
98
206
 
99
207
  <Tabs.Root defaultValue="overview">
100
- <Tabs.List>
208
+ <Tabs.List aria-label="Account">
101
209
  <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
102
210
  <Tabs.Trigger value="activity">Activity</Tabs.Trigger>
103
211
  <Tabs.Indicator />
104
212
  </Tabs.List>
105
- <Tabs.Content value="overview">What the account holds.</Tabs.Content>
106
- <Tabs.Content value="activity">What has happened lately.</Tabs.Content>
213
+ <Tabs.Content value="overview">The balance and the owners of the account.</Tabs.Content>
214
+ <Tabs.Content value="activity">The payments of the last thirty days.</Tabs.Content>
215
+ </Tabs.Root>;
216
+ ```
217
+
218
+ | Axis | Values | Default |
219
+ | --------- | ----------------------------------------------------------------- | ------- |
220
+ | `variant` | `line`, `enclosed`, `subtle`, `plain` | `line` |
221
+ | `size` | `xs` to `4xl` | `md` |
222
+ | `palette` | `primary`, `secondary`, `accent`, `neutral` and the four statuses | none |
223
+ | `justify` | `start`, `center`, `end`, `between`, `around`, `evenly` | none |
224
+ | `fitted` | `true` | off |
225
+
226
+ | Part | Element | What it renders |
227
+ | -------------- | -------- | ------------------------------------------------ |
228
+ | `Root` | `div` | The root, and the machine its parts share |
229
+ | `List` | `div` | The list of tabs, with `role="tablist"` |
230
+ | `Trigger` | `button` | One tab, with `role="tab"` |
231
+ | `CloseTrigger` | `span` | The pointer's control that closes a closable tab |
232
+ | `Indicator` | `div` | The indicator of the selected tab |
233
+ | `Content` | `div` | One panel, with `role="tabpanel"` |
234
+
235
+ - A tab and its panel take the same `value`. The machine sets every role, id and ARIA reference.
236
+ Name the list with `aria-label` on `Tabs.List`.
237
+ - The root takes the machine's settings: `value`, `defaultValue`, `onValueChange`, `orientation`,
238
+ `activationMode`, `loopFocus`, `deselectable` and `id`, and `onClose`.
239
+ - `orientation` is the machine's option, not an axis. When it is `vertical`, the list turns into a
240
+ column, the indicator moves to the inline start and the up and down arrows move between tabs. A
241
+ vertical tab starts its words at its inline start.
242
+ - `activationMode="automatic"`, the default, selects a tab on focus. `manual` moves focus with the
243
+ arrows and selects on a press, for panels that are slow to render.
244
+ - Only the selected tab is in the tab order. A panel takes `tabIndex={0}`, so Tab from the list
245
+ lands on the selected panel, and a panel that is not selected is `hidden`.
246
+ - The machine measures the selected tab and positions the indicator. The indicator is `hidden` until
247
+ the first measurement, and measures again whenever a tab enters or leaves the list.
248
+ - `fitted` shares the list's width equally between the tabs. `justify` distributes the tabs in a
249
+ list wider than they are.
250
+ - The palette colours the line indicator, the subtle indicator and the selected tab's text. Under
251
+ forced colors the line and plain looks mark the selected tab with a `Highlight` bar, and the
252
+ subtle look outlines it in `Highlight`.
253
+ - An `svg` placed directly in a tab is one text size.
254
+
255
+ Close tabs from a list you keep, and scroll the list where it can be wider than its room:
256
+
257
+ ```tsx
258
+ import { XIcon } from "lucide-react";
259
+
260
+ import { Tabs } from "@stealthscale/component-disclosure";
261
+ import { ScrollArea } from "@stealthscale/component-primitives";
262
+
263
+ <Tabs.Root defaultValue="build.yml" onClose={({ value }) => close(value)}>
264
+ <ScrollArea.Root scrolls="horizontal">
265
+ <ScrollArea.Viewport focusable={false}>
266
+ <ScrollArea.Content>
267
+ <Tabs.List aria-label="Open files">
268
+ {files.map((file) => (
269
+ <Tabs.Trigger closable key={file} value={file}>
270
+ {file}
271
+ <Tabs.CloseTrigger>
272
+ <XIcon />
273
+ </Tabs.CloseTrigger>
274
+ </Tabs.Trigger>
275
+ ))}
276
+ <Tabs.Indicator />
277
+ </Tabs.List>
278
+ </ScrollArea.Content>
279
+ </ScrollArea.Viewport>
280
+ <ScrollArea.Scrollbar orientation="horizontal" />
281
+ </ScrollArea.Root>
282
+ {files.map((file) => (
283
+ <Tabs.Content key={file} value={file}>
284
+ {file}
285
+ </Tabs.Content>
286
+ ))}
107
287
  </Tabs.Root>;
108
288
  ```
109
289
 
110
- | Axis | Values | Default |
111
- | --------- | ------------------------------------------------------- | ------- |
112
- | `variant` | `line`, `enclosed`, `subtle`, `plain` | `line` |
113
- | `size` | `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `4xl` | `md` |
114
- | `justify` | `start`, `center`, `end`, `between`, `around`, `evenly` | none |
115
- | `fitted` | `true` | off |
116
-
117
- `fitted` shares the strip's width between the controls, which suits a set of two or three filling a
118
- panel. `justify` decides where they sit when they do not fill it.
119
-
120
- Each control names the panel it shows with `value`, and each panel names its control the same way.
121
- Nothing else here is yours to state, because the machine derives the rest.
122
-
123
- Which way the set runs is not an axis. The machine states it, writes it onto every part, and the
124
- recipe reads it, so the strip turns into a column and the bar moves to its inline edge without being
125
- stated twice.
126
-
127
- ### The machine's settings
128
-
129
- | Setting | What it does |
130
- | ----------------------- | ------------------------------------------------------- |
131
- | `value`, `defaultValue` | Sets the panel from outside, or which panel opens first |
132
- | `onValueChange` | Called each time the panel changes |
133
- | `orientation` | Runs the strip across or down |
134
- | `activationMode` | Chooses a panel on focus, or only on a press |
135
- | `loopFocus` | Joins the ends of the strip up |
136
- | `deselectable` | Lets a person close the panel they are on |
137
- | `id` | Names the machine, which builds its ARIA references |
138
-
139
- `activationMode` decides when a panel is chosen. It chooses on focus by default, so an arrow key
140
- both moves and selects. A set whose panels are expensive to draw sets it to `manual`, and then an
141
- arrow moves the focus and a press chooses.
142
-
143
- ### The accessibility the machine writes
144
-
145
- - **The strip is one set.** It carries the tablist role and reports which way it runs, so a screen
146
- reader announces the count and the arrows move inside it.
147
- - **The strip is one tab stop.** Only the control in force is reachable by Tab, and the arrows move
148
- between them, so a person tabbing through a page steps over the set rather than through it.
149
- - **Each panel is reachable.** A panel holding nothing focusable takes a tab stop of its own, so
150
- tabbing out of the strip moves to what was just chosen rather than past it.
151
- - **A panel nobody chose is removed.** It is out of the tab order and out of the accessibility tree.
152
-
153
- The bar that marks the control in force is positioned from measurements the machine takes, so the
154
- recipe states its thickness and its colour and never its place. It is hidden until there is
155
- something to measure, which keeps it from appearing at the start of the strip on the first render.
290
+ - `closable` on a `Tabs.Trigger` lets a person close the tab: Delete while it has focus, a press
291
+ with the middle button, or a press on its `Tabs.CloseTrigger`. The tab states
292
+ `aria-keyshortcuts="Delete"`. A disabled tab does not close.
293
+ - `onClose` on the root receives `{ value }`, typed `CloseDetails`. Remove the tab and its panel
294
+ there, because the root does not remove them.
295
+ - When the selected tab closes, the next enabled tab is selected, else the previous one, else none.
296
+ `onValueChange` runs before `onClose`. A close of another tab keeps the selection.
297
+ - When the closing tab has focus, focus moves to the next enabled tab, else the previous one. When
298
+ the last tab closes, focus falls to the document's body, so move it to what replaces the list.
299
+ - `Tabs.CloseTrigger` renders your glyph in a `span` hidden from assistive technology, titled
300
+ "Close" unless you pass `label`. It is at least 24px square at the default scale. A press on it
301
+ neither focuses nor selects its tab.
302
+ - `Tabs.selectionAfterClose(values, selected, closed)` returns the value to select after a close.
303
+ Use it to close a tab from outside the list, such as from a button or a menu.
304
+ - Compose the list in the primitives package's horizontal `ScrollArea` when the tabs can be wider
305
+ than their room. Pass `focusable={false}` to the viewport, because the tabs take focus. The
306
+ selected tab scrolls into view inside the scroll area on every selection, and the page does not
307
+ scroll.
308
+
309
+ ## Steps
310
+
311
+ `Steps` renders a flow in steps: an ordered list of the steps, the content of the current step and
312
+ the buttons that move between steps.
313
+
314
+ ```tsx
315
+ import { CheckIcon } from "lucide-react";
316
+
317
+ import { Button } from "@stealthscale/component-actions";
318
+ import { Steps } from "@stealthscale/component-disclosure";
319
+
320
+ <Steps.Root count={2}>
321
+ <Steps.List>
322
+ <Steps.Item index={0}>
323
+ <Steps.Trigger>
324
+ <Steps.Indicator>
325
+ <Steps.Status complete={<CheckIcon />} />
326
+ </Steps.Indicator>
327
+ <Steps.Title>Account</Steps.Title>
328
+ </Steps.Trigger>
329
+ <Steps.Separator />
330
+ </Steps.Item>
331
+ <Steps.Item index={1}>
332
+ <Steps.Trigger>
333
+ <Steps.Indicator />
334
+ <Steps.Title>Amount</Steps.Title>
335
+ </Steps.Trigger>
336
+ </Steps.Item>
337
+ </Steps.List>
338
+ <Steps.Content index={0}>The account the payout leaves from.</Steps.Content>
339
+ <Steps.Content index={1}>The amount and the invoices it pays.</Steps.Content>
340
+ <Steps.CompletedContent>Payout queued.</Steps.CompletedContent>
341
+ <Steps.PrevTrigger as={Button}>Back</Steps.PrevTrigger>
342
+ <Steps.NextTrigger as={Button}>Next</Steps.NextTrigger>
343
+ </Steps.Root>;
344
+ ```
345
+
346
+ | Axis | Values | Default |
347
+ | ---------------- | ----------------------------------------------------------------- | --------- |
348
+ | `variant` | `solid`, `subtle` | `solid` |
349
+ | `size` | `sm`, `md`, `lg` | `md` |
350
+ | `palette` | `primary`, `secondary`, `accent`, `neutral` and the four statuses | `neutral` |
351
+ | `labelPlacement` | `beside`, `below` | `beside` |
352
+
353
+ | Part | Element | What it renders |
354
+ | ------------------ | -------- | ------------------------------------------------------------- |
355
+ | `Root` | `div` | The root, and the machine its parts share |
356
+ | `List` | `ol` | The ordered list of the steps |
357
+ | `Item` | `li` | One step |
358
+ | `Trigger` | `button` | The button that moves the flow to the step |
359
+ | `Indicator` | `span` | The disc, with the step's number unless it has children |
360
+ | `Status` | none | The node for the step's state, or the step's number |
361
+ | `Title` | `span` | The step's name, after hidden words for its state |
362
+ | `Description` | `span` | The line under the title |
363
+ | `Separator` | `span` | The rule to the next step |
364
+ | `Content` | `div` | The content of one step, shown while the step is current |
365
+ | `CompletedContent` | `div` | The content shown once every step is completed |
366
+ | `NextTrigger` | `button` | The button that moves to the next step, and past the last one |
367
+ | `PrevTrigger` | `button` | The button that moves to the previous step |
368
+
369
+ - The root takes the machine's settings: `count`, `step`, `defaultStep`, `onStepChange`,
370
+ `onStepComplete`, `linear`, `isStepValid`, `isStepSkippable`, `onStepInvalid`, `orientation`,
371
+ `ids`, `id` and `dir`. `step` counts from zero, and `count` marks the flow as completed.
372
+ - The list is an `ol`, and `Title` renders "Completed: " or "Current: " visually hidden before the
373
+ name, as the W3C's tutorial on multi-page forms marks a progress list. Pass other words through
374
+ `completedLabel` and `currentLabel`. Render a `Title` in every item, because it contains the words
375
+ for the state.
376
+ - The machine's tab semantics are dropped: the list is not a `tablist`, a trigger is not a `tab` and
377
+ a content is not a `tabpanel`. Every trigger is in the tab order, and Tab moves between them.
378
+ - A press on a trigger moves to its step. Moving past the current step asks `isStepValid` first, and
379
+ a refusal calls `onStepInvalid`.
380
+ - With `linear`, a trigger moves back to a completed step, and the trigger of a later step is
381
+ disabled.
382
+ - `NextTrigger` and `PrevTrigger` set `aria-disabled` at the ends of the flow in place of
383
+ `disabled`, so the button keeps focus after the press that ended the flow. Pass the library's
384
+ `Button` through `as` for a button's look.
385
+ - `Steps.Status` swaps the disc's number by state. Pass `complete={<CheckIcon />}` for a check on
386
+ completed steps. An item without a trigger shows progress without navigation.
387
+ - `orientation="vertical"` stacks the steps in a column beside the content, with a rule under each
388
+ disc.
389
+ - `labelPlacement="below"` centres each title under its disc and shares the row equally between the
390
+ steps. With `beside`, the list measures its steps at their natural width whenever its size
391
+ changes, and places the titles below the discs while they do not fit.
392
+ - The discs measure 36, 40 and 44px at `sm`, `md` and `lg`. Under forced colors a completed disc
393
+ fills with `Highlight` and the current disc takes a `Highlight` ring.
394
+ - The machine's `getProgressProps` is not offered. Render the feedback package's `Progress` beside
395
+ the steps for a bar.
156
396
 
157
397
  ## Popover
158
398
 
159
- Opens a panel beside a control, composed as `Popover.Root` holding the control and the panel.
399
+ `Popover` renders a panel beside its trigger, with a title, a description and a close button.
160
400
 
161
401
  ```tsx
402
+ import { ChevronDownIcon, XIcon } from "lucide-react";
403
+
162
404
  import { Popover } from "@stealthscale/component-disclosure";
163
405
 
164
406
  <Popover.Root>
165
407
  <Popover.Trigger>
166
408
  Filters
167
409
  <Popover.Indicator>
168
- <ChevronIcon />
410
+ <ChevronDownIcon size="1em" />
169
411
  </Popover.Indicator>
170
412
  </Popover.Trigger>
171
413
  <Popover.Positioner>
@@ -175,58 +417,63 @@ import { Popover } from "@stealthscale/component-disclosure";
175
417
  </Popover.Arrow>
176
418
  <Popover.Title>Filter the list</Popover.Title>
177
419
  <Popover.Description>Only rows matching all of these are shown.</Popover.Description>
178
- <Popover.CloseTrigger>
179
- <CloseIcon />
420
+ <Popover.CloseTrigger aria-label="Close the filters">
421
+ <XIcon />
180
422
  </Popover.CloseTrigger>
181
423
  </Popover.Content>
182
424
  </Popover.Positioner>
183
425
  </Popover.Root>;
184
426
  ```
185
427
 
186
- | Axis | Values | Default |
187
- | --------- | ------------------------------------------------- | --------- |
188
- | `variant` | `surface`, `elevated`, `glass` | `surface` |
189
- | `size` | `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `4xl` | `md` |
190
-
191
- A popover carries more than a tooltip. It holds a heading, a paragraph and often a control, so it
192
- reads at body text and takes the room a panel needs. The size sets the panel's room, the heading's
193
- size and the paragraph's together.
194
-
195
- `Popover.Anchor` is for a panel positioned against something other than the control that opens it,
196
- such as a whole row. Draw it around that thing and the machine measures it instead.
197
-
198
- ### The machine's settings
199
-
200
- | Setting | What it does |
201
- | --------------------------------------------- | --------------------------------------------------------------- |
202
- | `open`, `defaultOpen` | Sets the state from outside, or opens it to begin with |
203
- | `onOpenChange` | Called each time the panel opens or shuts |
204
- | `modal` | Traps focus and hides the rest of the page from a screen reader |
205
- | `autoFocus`, `initialFocusEl`, `finalFocusEl` | Where focus goes on opening and on closing |
206
- | `closeOnEscape`, `closeOnInteractOutside` | What shuts the panel |
207
- | `persistentElements` | What a click inside does not count as outside |
208
- | `positioning` | Which side it opens on, and how far from the control |
209
- | `id` | Names the machine, which builds its ARIA references |
210
-
211
- ### The accessibility the machine writes
212
-
213
- - **The panel is a dialog.** It carries the dialog role, and the control says whether it is open and
214
- which panel it controls.
215
- - **The panel is announced by its own words.** The heading names it and the paragraph describes it,
216
- both wired by the machine, so a panel with a `Title` needs nothing else to be announced.
217
- - **Focus goes in and comes back.** The panel takes focus as it opens and returns it to the control
218
- as it shuts, so a keyboard never loses its place.
219
- - **Escape shuts it.** So does a click outside.
220
-
221
- Like the tooltip, nothing here portals. Wrap `Popover.Positioner` in the portal you want where the
222
- panel is clipped or stacked wrongly.
428
+ | Axis | Values | Default |
429
+ | --------- | ------------------------------ | --------- |
430
+ | `variant` | `surface`, `elevated`, `glass` | `surface` |
431
+ | `size` | `xs` to `4xl` | `md` |
432
+
433
+ | Part | Element | What it renders |
434
+ | -------------- | -------- | --------------------------------------------------------------- |
435
+ | `Root` | `div` | A `display: contents` element that provides the variants |
436
+ | `Anchor` | `div` | The element the panel is positioned against, if not the trigger |
437
+ | `Trigger` | `button` | The button that opens and closes the panel |
438
+ | `Indicator` | `span` | The mark that turns while the panel is open |
439
+ | `Positioner` | `div` | The element the machine positions beside the trigger |
440
+ | `Content` | `div` | The panel, with `role="dialog"` |
441
+ | `Title` | `h2` | The heading that names the panel |
442
+ | `Description` | `p` | The text that describes the panel |
443
+ | `CloseTrigger` | `button` | The button that closes the panel |
444
+ | `Arrow` | `div` | The element the machine places against the panel's edge |
445
+ | `ArrowTip` | `div` | The arrow's rotated square |
446
+
447
+ - The root takes the machine's settings: `open`, `defaultOpen`, `onOpenChange`, `modal`,
448
+ `autoFocus`, `initialFocusEl`, `finalFocusEl`, `closeOnEscape`, `closeOnInteractOutside`,
449
+ `persistentElements`, `positioning` and `id`.
450
+ - The panel is not in the document until it first opens, and leaves once its exit animation ends.
451
+ `lazyMount` and `unmountOnExit` are true by default. Pass `false` to keep the closed panel
452
+ rendered under `hidden`. The panel is `inert` while it leaves. `skipAnimationOnMount` and
453
+ `onExitComplete` go to the root as well.
454
+ - The trigger sets `aria-expanded` and `aria-controls`. While a `Title` is mounted the panel sets
455
+ `aria-labelledby` to it, and while a `Description` is mounted `aria-describedby`, in a panel that
456
+ mounts on opening too. A panel without a title takes `aria-label`.
457
+ - The panel takes focus as it opens and returns it to the trigger as it closes. Escape and a press
458
+ outside close it. `modal` traps focus and hides the rest of the page from a screen reader.
459
+ - The close trigger's accessible name is "close" unless you pass `aria-label`.
460
+ - The title is an `h2`. Pass another heading level through `as` to fit the page's outline.
461
+ - The size sets the panel's padding, the title and the description, and the panel is at least as
462
+ wide as the trigger. The title and the description read the body text at the size, the title in
463
+ the semibold weight.
464
+ - A press on the trigger of another popover or menu closes this panel and opens the other. A popover
465
+ opened from inside another popover's panel closes with it.
466
+ - The package does not portal. Wrap `Popover.Positioner` in a portal when an ancestor clips the
467
+ panel.
223
468
 
224
469
  ## Tooltip
225
470
 
226
- Shows a short label beside whatever a pointer rests on, composed as `Tooltip.Root` holding a control
227
- and the box it opens.
471
+ `Tooltip` renders a short label beside a control, shown on hover and on keyboard focus.
228
472
 
229
473
  ```tsx
474
+ import { SaveIcon } from "lucide-react";
475
+
476
+ import { IconButton } from "@stealthscale/component-actions";
230
477
  import { Tooltip } from "@stealthscale/component-disclosure";
231
478
 
232
479
  <Tooltip.Root>
@@ -244,75 +491,231 @@ import { Tooltip } from "@stealthscale/component-disclosure";
244
491
  </Tooltip.Root>;
245
492
  ```
246
493
 
247
- | Axis | Values | Default |
248
- | --------- | ------------------------------------------------- | ---------- |
249
- | `variant` | `inverted`, `surface` | `inverted` |
250
- | `size` | `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `4xl` | `md` |
494
+ | Axis | Values | Default |
495
+ | --------- | --------------------- | ---------- |
496
+ | `variant` | `inverted`, `surface` | `inverted` |
497
+ | `size` | `xs` to `4xl` | `md` |
498
+
499
+ | Part | Element | What it renders |
500
+ | ------------ | -------- | --------------------------------------------------------- |
501
+ | `Root` | `div` | A `display: contents` element that provides the variants |
502
+ | `Trigger` | `button` | The control the tooltip describes |
503
+ | `Positioner` | `div` | The element the machine positions beside the trigger |
504
+ | `Content` | `div` | The label, with `role="tooltip"` |
505
+ | `Arrow` | `div` | The element the machine places against the content's edge |
506
+ | `ArrowTip` | `div` | The arrow's rotated square |
507
+
508
+ - The root takes the machine's settings: `open`, `defaultOpen`, `onOpenChange`, `openDelay`,
509
+ `closeDelay`, `disabled`, `interactive`, `closeOnClick`, `closeOnEscape`, `closeOnScroll`,
510
+ `closeOnPointerDown`, `positioning` and `id`.
511
+ - The content is not in the document until it first opens, and leaves once its exit animation ends.
512
+ `lazyMount` and `unmountOnExit` are true by default, and the content is `inert` while it leaves.
513
+ - The trigger sets `aria-describedby` to the content's id, so a screen reader reads the label as the
514
+ control's description.
515
+ - Keyboard focus opens the tooltip, and pointer focus does not. Escape, a scroll and a press on the
516
+ control close it.
517
+ - The trigger is a `button` by default, so it takes keyboard focus. Pass another control with `as`.
518
+ - Set `interactive` when the content contains a link, so a pointer can move into it.
519
+ - The content sets `--tooltip-surface`, and the arrow tip reads it.
520
+ - The package does not portal. Wrap the positioner in a portal when an ancestor clips it:
251
521
 
252
- The root draws an element, which the machine has no part for, because the control and the box are
253
- siblings and the recipe passes its variants down from above them both. It is drawn with
254
- `display: contents`, so it takes part in no layout and a tooltip attached to a control inside a row
255
- leaves that row as it was.
522
+ ```tsx
523
+ import { Portal } from "@stealthscale/component-primitives";
256
524
 
257
- The box states its surface once as a custom property and the point reads it from there, so the two
258
- are never filled in different colours whichever look is picked.
525
+ <Portal>
526
+ <Tooltip.Positioner>…</Tooltip.Positioner>
527
+ </Portal>;
528
+ ```
259
529
 
260
- ### The machine's settings
530
+ ## Truncate
261
531
 
262
- | Setting | What it does |
263
- | ---------------------------------------------------------------------- | ------------------------------------------------------ |
264
- | `open`, `defaultOpen` | Sets the state from outside, or opens it to begin with |
265
- | `onOpenChange` | Called each time the box opens or shuts |
266
- | `openDelay`, `closeDelay` | How long a pointer rests before it opens, and after |
267
- | `disabled` | Stops it opening at all |
268
- | `interactive` | Keeps it open while a pointer is inside the box |
269
- | `closeOnClick`, `closeOnEscape`, `closeOnScroll`, `closeOnPointerDown` | What shuts it |
270
- | `positioning` | Which side it opens on, and how far from the control |
271
- | `id` | Names the machine, which builds its ARIA references |
532
+ `Truncate` clips text to a number of lines with an ellipsis, and shows the whole text in a tooltip
533
+ while something is cut off.
272
534
 
273
- Set `interactive` where the box holds a link. It is off by default, because a tooltip held open
274
- under a pointer covers whatever is behind it.
535
+ ```tsx
536
+ import { Truncate } from "@stealthscale/component-disclosure";
275
537
 
276
- ### Putting the box somewhere else
538
+ <Truncate>{shipment.note}</Truncate>;
539
+ <Truncate focusable lines={2}>
540
+ {ticket.description}
541
+ </Truncate>;
542
+ ```
277
543
 
278
- Nothing here portals. A page whose tooltip is clipped or stacked wrongly wraps the positioner in the
279
- portal it wants:
544
+ | Prop | Values | Default |
545
+ | ----------- | ------------------------ | -------- |
546
+ | `children` | the whole text, a string | required |
547
+ | `lines` | a positive whole number | `1` |
548
+ | `focusable` | `true`, `false` | `false` |
549
+
550
+ - The text is measured whenever it or its parent resizes. While something is cut off the text takes
551
+ `data-truncated` and the tooltip opens on hover. Text that fits renders no tooltip and no tab
552
+ stop.
553
+ - `focusable` gives clipped text a tab stop, and keyboard focus opens the tooltip.
554
+ - A clamp clips the paint and not the document, so a screen reader reads the whole text once. The
555
+ text drops the tooltip's `aria-describedby`, and the tooltip's content is `aria-hidden`.
556
+ - The tooltip opens under the start of the text, in a portal. A scroll does not close it, because a
557
+ Tab onto text below the fold scrolls the page as it opens the tooltip.
558
+ - In a flex row the text shrinks to the room its siblings leave and clips there. A word longer than
559
+ the line breaks and clips as well.
560
+ - The root is a `span`, which is valid inside a paragraph or a table cell. The text is a block box,
561
+ because a clamp does not apply to an inline one.
562
+ - The recipe has no axis. The text takes its size from the text around it, and the focus ring of a
563
+ tab stop is outside the text.
564
+ - A touch screen does not open the tooltip. Where a touch reader needs the whole text, show it in
565
+ full or behind a control.
566
+
567
+ Not offered: an expand control and a clamp at the start of the text.
568
+
569
+ ## ToggleTip
570
+
571
+ `ToggleTip` renders a short note beside its trigger, opened and closed by a press. It looks like a
572
+ tooltip and opens like a popover, so it works on a touch screen and can contain a link.
280
573
 
281
574
  ```tsx
575
+ import { InfoIcon } from "lucide-react";
576
+
577
+ import { IconButton } from "@stealthscale/component-actions";
578
+ import { ToggleTip } from "@stealthscale/component-disclosure";
282
579
  import { Portal } from "@stealthscale/component-primitives";
283
580
 
284
- <Portal>
285
- <Tooltip.Positioner>…</Tooltip.Positioner>
286
- </Portal>;
581
+ <ToggleTip.Root>
582
+ <ToggleTip.Trigger as={IconButton} aria-label="About chargebacks">
583
+ <InfoIcon />
584
+ </ToggleTip.Trigger>
585
+ <Portal>
586
+ <ToggleTip.Positioner>
587
+ <ToggleTip.Content>
588
+ <ToggleTip.Arrow>
589
+ <ToggleTip.ArrowTip />
590
+ </ToggleTip.Arrow>
591
+ A payment the cardholder's bank takes back after a dispute.
592
+ </ToggleTip.Content>
593
+ </ToggleTip.Positioner>
594
+ </Portal>
595
+ </ToggleTip.Root>;
287
596
  ```
288
597
 
289
- That keeps the choice of portal with the page rather than with the component, and it is why this
290
- package peers on no other component package.
598
+ | Axis | Values | Default |
599
+ | --------- | --------------------- | ---------- |
600
+ | `variant` | `inverted`, `surface` | `inverted` |
601
+ | `size` | `xs` to `4xl` | `md` |
602
+
603
+ | Part | Element | What it renders |
604
+ | ------------ | -------- | -------------------------------------------------------- |
605
+ | `Root` | `span` | A `display: contents` element that provides the variants |
606
+ | `Trigger` | `button` | The button that opens and closes the note |
607
+ | `Positioner` | `div` | The element the machine positions beside the trigger |
608
+ | `Content` | `div` | The note |
609
+ | `Arrow` | `div` | The element the machine places against the note's edge |
610
+ | `ArrowTip` | `div` | The arrow's rotated square |
611
+
612
+ - The root takes the popover machine's settings: `open`, `defaultOpen`, `onOpenChange`, `autoFocus`,
613
+ `initialFocusEl`, `finalFocusEl`, `closeOnEscape`, `closeOnInteractOutside`, `positioning` and
614
+ `id`.
615
+ - A press on the trigger opens the note, and a second press closes it. Escape and a press outside
616
+ close it too. Opening another toggle tip, popover or menu closes this note.
617
+ - The trigger keeps focus as the note opens, because `autoFocus` is false by default. The note
618
+ announces its text through the document's polite live region each time it opens, so a screen
619
+ reader user hears it without leaving the trigger.
620
+ - The trigger sets `aria-expanded` and `aria-controls`. The note has no role, because a note is not
621
+ a dialog: the trigger drops the machine's `aria-haspopup="dialog"` and the note its `dialog` role.
622
+ - Tab from the trigger moves into the note while it contains a link, and Tab out of the note closes
623
+ it. A link inside the note takes the note's ink and an underline.
624
+ - The note is not in the document until it first opens, and leaves once its exit animation ends.
625
+ `lazyMount` and `unmountOnExit` are true by default. `skipAnimationOnMount` and `onExitComplete`
626
+ go to the root as well.
627
+ - The note is at most 320px wide at the default scale, and narrower where the room beside the
628
+ trigger is less. The size sets its padding on the inset scale and its text on the label role.
629
+ - Use `Tooltip` for a control's label, `ToggleTip` for a note a reader asks for, and `Popover` for a
630
+ panel with a title and controls.
631
+ - The root is a `span`, which is valid inside a table cell or a heading. Wrap the positioner in a
632
+ portal there, because phrasing content cannot contain its `div`.
633
+
634
+ ## HoverCard
635
+
636
+ `HoverCard` renders a card beside a link that previews what the link leads to. The card opens while
637
+ a pointer rests on the link or the link has keyboard focus.
291
638
 
292
- ### The accessibility the machine writes
639
+ ```tsx
640
+ import { HoverCard } from "@stealthscale/component-disclosure";
641
+ import { Portal } from "@stealthscale/component-primitives";
293
642
 
294
- - **The words describe the control.** The control points at the box with `aria-describedby`, so a
295
- screen reader reads the words as part of reading the control rather than as something beside it.
296
- - **A keyboard opens it.** Focus opens the box, but only where the focus came from a keyboard, so
297
- clicking a control does not leave a tooltip hanging over the page.
298
- - **Escape shuts it.** So does scrolling, and pressing the control.
643
+ <HoverCard.Root>
644
+ <HoverCard.Trigger href="/people/ada">@ada</HoverCard.Trigger>
645
+ <Portal>
646
+ <HoverCard.Positioner>
647
+ <HoverCard.Content>
648
+ <HoverCard.Arrow>
649
+ <HoverCard.ArrowTip />
650
+ </HoverCard.Arrow>
651
+ Ada Lovelace, staff engineer on the compiler team.
652
+ </HoverCard.Content>
653
+ </HoverCard.Positioner>
654
+ </Portal>
655
+ </HoverCard.Root>;
656
+ ```
299
657
 
300
- The default element for the control is `button`, because a keyboard never reaches a tooltip attached
301
- to something a browser does not focus. Pass `as` for a control you have already built.
658
+ | Axis | Values | Default |
659
+ | --------- | ------------------------------ | --------- |
660
+ | `variant` | `surface`, `elevated`, `glass` | `surface` |
661
+ | `size` | `xs`, `sm`, `md`, `lg` | `md` |
662
+
663
+ | Part | Element | What it renders |
664
+ | ------------ | ------- | -------------------------------------------------------- |
665
+ | `Root` | `span` | A `display: contents` element that provides the variants |
666
+ | `Trigger` | `a` | The link the card previews |
667
+ | `Positioner` | `div` | The element the machine positions beside the trigger |
668
+ | `Content` | `div` | The card |
669
+ | `Arrow` | `div` | The element the machine places against the card's edge |
670
+ | `ArrowTip` | `div` | The arrow's rotated square |
671
+
672
+ - The root takes the machine's settings: `open`, `defaultOpen`, `onOpenChange`, `openDelay`,
673
+ `closeDelay`, `disabled`, `positioning`, `triggerValue`, `defaultTriggerValue`,
674
+ `onTriggerValueChange`, `ids`, `id` and `dir`, and the dismiss handlers `onInteractOutside`,
675
+ `onPointerDownOutside` and `onFocusOutside`.
676
+ - The card opens 600 ms after a pointer comes to rest on the link or the link takes focus. It closes
677
+ 300 ms after the pointer leaves both the link and the card, and at once when the link loses focus
678
+ while no pointer rests on it. `openDelay` and `closeDelay` set the two waits.
679
+ - A pointer can move from the link onto the card without closing it, so a reader can select the
680
+ card's text. Escape and a press outside close the card. The machine ignores touch input, so a tap
681
+ follows the link.
682
+ - The trigger is an `a`. Give it an `href`, because an `a` without one takes no keyboard focus and
683
+ its card never opens from the keyboard. The machine does not set an ARIA attribute on the link.
684
+ - The card has no role and is outside the tab order. The machine moves no focus into it and
685
+ announces nothing, so a screen reader user does not learn that it opened. Put nothing in the card
686
+ that the linked page does not show, and no control a reader needs.
687
+ - Two or more links can share one card. Give each `Trigger` a `value`. The root reports the value of
688
+ the link that opened the card through `onTriggerValueChange`, and the machine places the card
689
+ against that link. A pointer that moves to another link moves the open card at once. Place the
690
+ card beside a column of links, because a card below one link covers the next.
691
+ - The card is not in the document until it first opens, and leaves once its exit animation ends.
692
+ `lazyMount` and `unmountOnExit` are true by default, and the card is `inert` while it leaves.
693
+ `skipAnimationOnMount` and `onExitComplete` go to the root as well.
694
+ - The root is a `span`, so a card can open from a link inside a paragraph. Pass `as="div"` when the
695
+ root contains block content, such as a list of links.
696
+ - The link is in the link ink and underlined at rest. The card slides in from the side of the link
697
+ it opens on. It is at most `sizes.xs` wide, 320px at the default scale, and narrower where the
698
+ room beside the link is less. The size sets the card's padding on the inset scale: 8, 12, 16 and
699
+ 20px from `xs` to `lg`. The text reads the body role at the same size.
700
+ - The package does not portal. Wrap the positioner in a portal when the link is inside a paragraph
701
+ or an ancestor clips the card. A paragraph cannot contain the positioner's `div`.
302
702
 
303
703
  ## Menu
304
704
 
305
- Opens a list of things a reader chooses from, composed as `Menu.Root` holding the control and the
306
- panel of rows.
705
+ `Menu` renders a panel of actions and options, opened from a button, from a right click on a region,
706
+ or from a row of another menu.
307
707
 
308
708
  ```tsx
709
+ import { ChevronDownIcon } from "lucide-react";
710
+
711
+ import { Button } from "@stealthscale/component-actions";
309
712
  import { Menu } from "@stealthscale/component-disclosure";
310
713
 
311
714
  <Menu.Root>
312
- <Menu.Trigger>
715
+ <Menu.Trigger as={Button}>
313
716
  Actions
314
717
  <Menu.Indicator>
315
- <ChevronIcon />
718
+ <ChevronDownIcon size="1em" />
316
719
  </Menu.Indicator>
317
720
  </Menu.Trigger>
318
721
  <Menu.Positioner>
@@ -328,101 +731,173 @@ import { Menu } from "@stealthscale/component-disclosure";
328
731
  </Menu.Root>;
329
732
  ```
330
733
 
331
- | Axis | Values | Default |
332
- | ----------- | ------------------------------ | --------- |
333
- | `variant` | `surface`, `elevated`, `glass` | `surface` |
334
- | `size` | `sm`, `md`, `lg` | `md` |
335
- | `highlight` | `tint`, `fill`, `bar` | `tint` |
336
- | `inset` | `true` | unset |
337
-
338
- `highlight` decides how the row the reader is on is marked. `tint` and `fill` change the row's
339
- background. `bar` draws a line down the leading edge as well, which a reader who cannot separate the
340
- two colours still sees.
341
-
342
- `inset` leaves every row the gutter a mark is drawn in, for a menu whose rows lead with an icon. A
343
- row that carries a mark is inset whatever the axis says, so a list of options does not step sideways
344
- as the marks appear.
345
-
346
- The panel states no width of its own, so it is as wide as its widest row. Set
347
- `positioning: { sameWidth: true }` to match the control's width instead.
348
-
349
- ### Rows that carry a choice
350
-
351
- `Menu.OptionItem` draws a tick a reader turns on and off, or one of a set. The caller states both
352
- the kind and whether it is on. The machine reports a change and the caller decides what it means.
353
- That is how one radio set clears the rest.
734
+ | Axis | Values | Default |
735
+ | ----------- | ----------------------------------------------------------------- | --------- |
736
+ | `variant` | `surface`, `elevated`, `glass` | `surface` |
737
+ | `size` | `sm`, `md`, `lg` | `md` |
738
+ | `highlight` | `tint`, `fill`, `bar` | `tint` |
739
+ | `palette` | `primary`, `secondary`, `accent`, `neutral` and the four statuses | `neutral` |
740
+ | `inset` | `true` | off |
741
+
742
+ | Part | Element | What it renders |
743
+ | ----------------- | -------- | ------------------------------------------------------------ |
744
+ | `Root` | `div` | A `display: contents` element that provides the variants |
745
+ | `Trigger` | `button` | The button that opens and closes the menu |
746
+ | `ContextTrigger` | `div` | The region that opens the menu on a right click |
747
+ | `Indicator` | `span` | The mark that turns while the menu is open |
748
+ | `Positioner` | `div` | The element the machine positions beside the trigger |
749
+ | `Content` | `div` | The panel, around a scroll area whose viewport is the `menu` |
750
+ | `Item` | `div` | A row, with `role="menuitem"` |
751
+ | `OptionItem` | `div` | A checkbox or radio row |
752
+ | `TriggerItem` | `button` | A row that opens a submenu |
753
+ | `ItemGroup` | `div` | A group of rows, with `role="group"` |
754
+ | `ItemGroupLabel` | `div` | The label that names a group |
755
+ | `ItemMark` | `span` | The square at a row's start that shows an initial or an icon |
756
+ | `ItemLines` | `span` | The column that stacks a row's text over its description |
757
+ | `ItemText` | `span` | The text of a row |
758
+ | `ItemDescription` | `span` | The line under a row's text |
759
+ | `ItemIndicator` | `span` | The check at the end of a checked row |
760
+ | `ItemCommand` | `kbd` | The shortcut at the end of a row |
761
+ | `Separator` | `div` | The rule between two rows, with `role="separator"` |
762
+ | `Arrow` | `div` | The element the machine places against the panel's edge |
763
+ | `ArrowTip` | `div` | The arrow's rotated square |
764
+
765
+ - The root takes the machine's settings: `open`, `defaultOpen`, `onOpenChange`, `onSelect`,
766
+ `highlightedValue`, `defaultHighlightedValue`, `onHighlightChange`, `closeOnSelect`, `loopFocus`,
767
+ `typeahead`, `navigate`, `positioning`, `triggerValue`, `onTriggerValueChange`, `aria-label`, `id`
768
+ and `dir`, and the dismiss handlers such as `onEscapeKeyDown`.
769
+ - The panel is not in the document until it first opens, and leaves once its exit animation ends.
770
+ `lazyMount` and `unmountOnExit` are true by default. The panel is `inert` while it leaves, so a
771
+ highlight and Escape pressed in one frame leave focus on the trigger.
772
+ - The trigger sets `aria-haspopup="menu"`, `aria-expanded` and `aria-controls`. The panel takes
773
+ focus as it opens and keeps it. `aria-activedescendant` points a screen reader at the highlighted
774
+ row.
775
+ - The up and down arrows, Home and End move the highlight, and typing the start of a row's text
776
+ moves it to that row. Enter and Space select the highlighted row. Escape closes the menu and its
777
+ submenus and returns focus to the trigger.
778
+ - A menu that closes returns focus to its trigger while focus is in a menu's panel or on the body. A
779
+ press outside the panel that moves focus to another element leaves focus on that element, with
780
+ `open` controlled or not.
781
+ - A checkbox row keeps the menu open when selected, and every other row closes it. `closeOnSelect`
782
+ on a row overrides either.
783
+ - `Menu.OptionItem` takes `type`, `checked` and `onCheckedChange`. The caller keeps the checked
784
+ state and updates it in `onCheckedChange`.
785
+ - `Menu.ItemIndicator` renders at the row's end in any source order, and keeps its room while the
786
+ row is unchecked, so checking a row does not change the panel's width.
787
+ - `Menu.ItemGroup` and `Menu.ItemGroupLabel` take the same `value`, and the group sets
788
+ `aria-labelledby` to the label. `Menu.ItemMark` sets `aria-hidden`.
789
+ - `tone="critical"` sets a row in the error palette. The panel sets the `palette`, and every row and
790
+ the highlight inherit it.
791
+ - A row sizes the `svg` it starts with to the icon size one size smaller than the row. `inset` pads
792
+ a row that starts with neither an icon nor a `Menu.ItemMark` by the room an icon and its gap take,
793
+ so its text starts where the text of an icon row starts.
794
+ - A `Menu.Root` inside another menu's content is a submenu, and its `Menu.TriggerItem` is a row of
795
+ the parent. The submenu takes the parent's variants and `dir` unless it sets its own.
796
+ `Menu.TriggerItem` throws outside a submenu.
797
+ - A submenu opens beside the parent's panel, level with its row, at `right-start` in a left-to-right
798
+ document and at `left-start` in a right-to-left one. The machine sets that placement over the
799
+ submenu's own `positioning.placement`. `Menu.Indicator` mirrors in a right-to-left menu, so a
800
+ submenu row's chevron points to the side the submenu opens on.
801
+ - A press on the trigger of another menu or popover closes this menu and opens the other. A submenu
802
+ closes with its menu.
803
+ - `Menu.ContextTrigger` opens the menu at the pointer on a right click, and on a 700 ms press from
804
+ touch or a pen. It stops the browser's own menu. The machine positions a context menu once, as it
805
+ opens. Give the region `tabIndex={0}` or render it as a focusable element, so Shift+F10 opens the
806
+ menu from the keyboard.
807
+ - The panel is at least `sizes.44` wide and grows to its widest row. Pass
808
+ `positioning={{ sameWidth: true }}` to size the positioner to the trigger's width. The panel's
809
+ height stops at the room left in the window, and its rows scroll inside it.
810
+ - The rows scroll in the primitives package's scroll area inside the panel, with the theme's thin
811
+ bar at the panel's end edge, which follows the menu's `dir`. The scroll area's viewport is the
812
+ `menu`, so the element that has focus is the element that scrolls. A row the keys highlight
813
+ scrolls into view by the least distance, and a row the pointer highlights does not move. A
814
+ `Menu.Arrow` that is a direct child of `Menu.Content` renders outside the scroll area.
815
+ - `as` and `style` on `Menu.Content` go to the panel. Every other prop goes to the `menu`.
816
+ - The panel takes the `dropdown` z-index plus its depth in the nest, so a submenu renders above its
817
+ parent.
818
+ - The package does not portal. Wrap `Menu.Positioner` in a portal when an ancestor clips the panel.
819
+ Wrap a submenu's positioner in a portal too, because the parent's panel scrolls and clips a
820
+ submenu rendered inside it.
821
+
822
+ ## Menubar
823
+
824
+ `Menubar` renders a row of menus along the top of an application, such as an editor's File, Edit and
825
+ View. The bar is one tab stop. The arrow keys move along it. At most one menu is open.
354
826
 
355
827
  ```tsx
356
- <Menu.OptionItem checked={dense} onCheckedChange={setDense} type="checkbox" value="dense">
357
- <Menu.ItemIndicator>
358
- <CheckIcon />
359
- </Menu.ItemIndicator>
360
- <Menu.ItemText>Compact rows</Menu.ItemText>
361
- </Menu.OptionItem>
362
- ```
363
-
364
- The row passes its value down. `Menu.ItemText` and `Menu.ItemIndicator` take no props of their own.
365
- Group rows with `Menu.ItemGroup` and `Menu.ItemGroupLabel`. The two share one `value`.
366
-
367
- ### Submenus
368
-
369
- Write a `Menu.Root` inside the panel of another. The inner root finds the menu above it and joins
370
- the two machines. It draws itself in the variants that menu was given, unless it picks its own.
828
+ import { Menu, Menubar } from "@stealthscale/component-disclosure";
829
+ import { Portal } from "@stealthscale/component-primitives";
371
830
 
372
- ```tsx
373
- <Menu.Content>
374
- <Menu.Item value="new">New</Menu.Item>
375
- <Menu.Root>
376
- <Menu.TriggerItem>Share</Menu.TriggerItem>
377
- <Menu.Positioner>
378
- <Menu.Content>
379
- <Menu.Item value="email">Email</Menu.Item>
380
- </Menu.Content>
381
- </Menu.Positioner>
382
- </Menu.Root>
383
- </Menu.Content>
831
+ <Menubar.Root aria-label="Editor">
832
+ <Menubar.Menu value="file">
833
+ <Menubar.Trigger>File</Menubar.Trigger>
834
+ <Portal>
835
+ <Menubar.Content>
836
+ <Menu.Item value="new">New file</Menu.Item>
837
+ <Menu.Item value="open">Open…</Menu.Item>
838
+ </Menubar.Content>
839
+ </Portal>
840
+ </Menubar.Menu>
841
+ <Menubar.Menu value="edit">
842
+ <Menubar.Trigger>Edit</Menubar.Trigger>
843
+ <Portal>
844
+ <Menubar.Content>
845
+ <Menu.Item value="undo">Undo</Menu.Item>
846
+ <Menu.Item value="redo">Redo</Menu.Item>
847
+ </Menubar.Content>
848
+ </Portal>
849
+ </Menubar.Menu>
850
+ </Menubar.Root>;
384
851
  ```
385
852
 
386
- `Menu.TriggerItem` is both a row of the menu above and the control of the menu below. It opens when
387
- a pointer rests on it or the arrow towards the submenu is pressed, and it throws where the menu it
388
- belongs to opens from no other menu.
389
-
390
- ### Right-click menus
391
-
392
- `Menu.ContextTrigger` opens the menu at the point the pointer is at rather than beside a control. It
393
- responds to a long press as well as a right-click, and it stops the browser drawing its own menu
394
- over this one. Use it in place of `Menu.Trigger`.
395
-
396
- ### The machine's settings
397
-
398
- | Setting | What it does |
399
- | --------------------------------------- | ------------------------------------------------------ |
400
- | `open`, `defaultOpen` | Sets the state from outside, or opens it to begin with |
401
- | `onOpenChange` | Called each time the rows open or shut |
402
- | `onSelect` | Called with the row the reader chose |
403
- | `highlightedValue`, `onHighlightChange` | Sets the row the reader is on, or reports it changing |
404
- | `closeOnSelect` | Whether choosing a row shuts the menu |
405
- | `typeahead`, `loopFocus` | Whether typing jumps to a row, and whether arrows wrap |
406
- | `navigate` | Follows a row drawn as a link through a router |
407
- | `positioning` | Which side it opens on, and how far from the control |
408
- | `triggerValue`, `onTriggerValueChange` | Tracks which of several controls opened it |
409
- | `anchorPoint` | Opens it at a point rather than beside a control |
410
- | `id` | Names the machine, which builds its ARIA references |
411
-
412
- ### The accessibility the machine writes
413
-
414
- - **The panel is a menu.** It carries the menu role, and the control says whether it is open and
415
- which panel it opens.
416
- - **The focus stays on the panel.** The machine moves a highlight over the rows and points a screen
417
- reader at it with `aria-activedescendant`, which is what the menu pattern asks for.
418
- - **Every row responds to the keyboard.** The arrows move the highlight. The arrow towards a submenu
419
- opens it and the arrow back closes it. Typing jumps to a row by its words, and Escape shuts the
420
- menu and returns focus to the control.
421
- - **A row that carries a choice reports it.** The checkbox and radio menu item roles carry
422
- `aria-checked`, and the mark beside them is hidden so it is not read out twice.
423
-
424
- Nothing here portals. Wrap `Menu.Positioner` in the portal you want where the panel is clipped or
425
- stacked wrongly.
853
+ | Axis | Values | Default |
854
+ | --------- | ----------------------------------------------------------------- | --------- |
855
+ | `size` | `sm`, `md`, `lg` | `md` |
856
+ | `palette` | `primary`, `secondary`, `accent`, `neutral` and the four statuses | `neutral` |
857
+
858
+ The root passes `size`, `palette`, `variant` and `highlight` to every menu. The last two are axes of
859
+ the menu, with the menu's values and defaults.
860
+
861
+ | Part | Element | What it renders |
862
+ | --------- | -------- | -------------------------------------------------------------- |
863
+ | `Root` | `div` | The row that contains the bar of names and the fold trigger |
864
+ | `Menu` | `div` | A `display: contents` element around one menu of the bar |
865
+ | `Trigger` | `button` | A name of the bar, with `role="menuitem"`, that opens its menu |
866
+ | `Content` | `div` | The positioner and the panel of a menu, with `role="menu"` |
867
+
868
+ A panel's rows are `Menu` parts, such as `Menu.Item`, `Menu.OptionItem` and `Menu.Separator`. A
869
+ submenu is a `Menu.Root` around a `Menu.TriggerItem`.
870
+
871
+ - The bar is a `div` with `role="menubar"`. The root requires `aria-label`, the bar's accessible
872
+ name. `value`, `defaultValue` and `onValueChange` on the root read and set the `value` of the open
873
+ menu. The value is an empty string while every menu is closed.
874
+ - The bar is one tab stop. ArrowLeft and ArrowRight move focus along the names. Home and End move it
875
+ to the ends. A letter moves it to the next name that starts with that letter. `loop`, true by
876
+ default, continues a step past one end at the other.
877
+ - ArrowDown, Enter and Space on a name open its menu with the first row highlighted, and ArrowUp
878
+ opens it with the last. A press on a name opens its menu, and a press on the name of the open menu
879
+ closes it. While a menu is open, a mouse that moves onto another name opens that name's menu.
880
+ - ArrowRight in a panel opens the next menu with its first row highlighted, and ArrowLeft opens the
881
+ one before. While the highlighted row opens a submenu, the arrow towards the submenu opens it.
882
+ - Tab and Shift+Tab in a panel close the menu and move focus to the element after or before the bar.
883
+ Escape closes the menu and returns focus to its name.
884
+ - `dir="rtl"` lays the bar out from the right, swaps the arrows along the bar and in its panels, and
885
+ opens submenus to the left.
886
+ - The root measures its row. While the names do not fit at their natural width, the root sets
887
+ `data-crowded`, hides the bar and shows one trigger in its place: `foldIcon`, then `fold`, which
888
+ reads `Menu` by default. Its menu has one row per menu of the bar, which opens that menu as a
889
+ submenu and ends with `foldIndicator`.
890
+ - A name has the padding and text of a row of its menu, 27, 33 and 40px tall at `sm`, `md` and `lg`
891
+ at the default scale. It fills in the palette's subtle role under the pointer and in its muted
892
+ role while its menu is open, the fill of a highlighted row. Under forced colors an open name fills
893
+ with `Highlight`.
894
+ - `Menubar.Menu` takes the props of `Menu.Root` except `open`, `defaultOpen` and `onOpenChange`,
895
+ which the bar keeps. A variant set on a menu overrides the bar's.
896
+ - The package does not portal. Wrap each `Menubar.Content` in a portal, and each submenu's
897
+ positioner too.
898
+ - The menubar is not built on Zag's `@zag-js/menubar`, which is published only as 2.0 prereleases.
899
+ It does not open a menu on hover while every menu is closed, and a crowded bar folds rather than
900
+ scrolls.
426
901
 
427
902
  ## Licence
428
903