@acusti/dropdown 0.57.0 → 1.0.0-alpha.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/README.md +389 -26
- package/dist/Dropdown.d.ts +21 -10
- package/dist/Dropdown.js +1066 -329
- package/dist/Dropdown.js.map +1 -1
- package/dist/Menubar.d.ts +7 -0
- package/dist/context.d.ts +25 -0
- package/dist/helpers.d.ts +24 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
`Dropdown` is a React component that renders a menu-like UI with a trigger
|
|
9
9
|
that the user clicks to disclose a dropdown anchored to that trigger. The
|
|
10
|
-
body of the dropdown can include any DOM
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
body of the dropdown can include any DOM. Dropdowns also compose: nest a
|
|
11
|
+
`Dropdown` inside another dropdown’s body to create a submenu, or wrap
|
|
12
|
+
sibling dropdowns in the `Menubar` component to combine them into a
|
|
13
|
+
multi-menu bar, like the system menu in the top toolbar of macOS.
|
|
13
14
|
|
|
14
15
|
The three primary design goals for the existence of this component:
|
|
15
16
|
|
|
@@ -26,7 +27,12 @@ The three primary design goals for the existence of this component:
|
|
|
26
27
|
`data-ukt-value="foo"` to specify that an element is a dropdown item
|
|
27
28
|
and the value of that item at the same time (otherwise, the value is
|
|
28
29
|
the text content of the dropdown item element)
|
|
29
|
-
4. To
|
|
30
|
+
4. To create a submenu, nest a `Dropdown` inside the dropdown body (or
|
|
31
|
+
author the equivalent [`data-ukt-submenu` markup](#submenus)
|
|
32
|
+
directly)
|
|
33
|
+
5. To combine dropdowns into a menubar, wrap them in
|
|
34
|
+
[`<Menubar>`](#menubar)
|
|
35
|
+
6. To style your dropdowns, use CSS; there are a
|
|
30
36
|
[collection of CSS custom properties](https://github.com/acusti/uikit/blob/main/packages/dropdown/src/Dropdown.css)
|
|
31
37
|
used internally to style them if that works best for you, or just
|
|
32
38
|
override the minimal default CSS as appropriate
|
|
@@ -78,6 +84,36 @@ function CustomTrigger() {
|
|
|
78
84
|
}
|
|
79
85
|
```
|
|
80
86
|
|
|
87
|
+
```tsx
|
|
88
|
+
import Dropdown, { Menubar } from '@acusti/dropdown';
|
|
89
|
+
|
|
90
|
+
// Submenus (nested dropdowns) combined into a menubar
|
|
91
|
+
function AppMenu() {
|
|
92
|
+
return (
|
|
93
|
+
<Menubar>
|
|
94
|
+
<Dropdown label="File">
|
|
95
|
+
<ul>
|
|
96
|
+
<li data-ukt-item>New</li>
|
|
97
|
+
<li data-ukt-item>Open…</li>
|
|
98
|
+
<Dropdown label="Open Recent">
|
|
99
|
+
<ul>
|
|
100
|
+
<li data-ukt-item>project-a</li>
|
|
101
|
+
<li data-ukt-item>project-b</li>
|
|
102
|
+
</ul>
|
|
103
|
+
</Dropdown>
|
|
104
|
+
</ul>
|
|
105
|
+
</Dropdown>
|
|
106
|
+
<Dropdown label="Edit">
|
|
107
|
+
<ul>
|
|
108
|
+
<li data-ukt-item>Undo</li>
|
|
109
|
+
<li data-ukt-item>Redo</li>
|
|
110
|
+
</ul>
|
|
111
|
+
</Dropdown>
|
|
112
|
+
</Menubar>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
81
117
|
## Layout Model
|
|
82
118
|
|
|
83
119
|
`Dropdown` uses CSS anchor positioning for placement and prefers a
|
|
@@ -125,12 +161,45 @@ For the most reliable anchor-positioning behavior:
|
|
|
125
161
|
- prefer CSS variable overrides over custom `top`/`left`/`right` inset
|
|
126
162
|
rules
|
|
127
163
|
|
|
164
|
+
The body renders in the top layer using the
|
|
165
|
+
[Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API),
|
|
166
|
+
which is what makes the anchor positioning robust. A top-layer element’s
|
|
167
|
+
containing block is always the viewport, so an ancestor with a `transform`,
|
|
168
|
+
`scale`, `filter`, `backdrop-filter`, `perspective`,
|
|
169
|
+
`will-change: transform`, or `contain` can’t capture the `position: fixed`
|
|
170
|
+
body. Were the body a normal descendant instead, any such ancestor would
|
|
171
|
+
become its containing block and its placement math would be measured
|
|
172
|
+
against that box rather than the viewport — `position-try` fallbacks stop
|
|
173
|
+
firing, and the base `position-area` can even resolve to the wrong side.
|
|
174
|
+
Because the body is in the top layer, none of that applies and no
|
|
175
|
+
consumer-side de-transforming is needed. The body uses `popover="manual"`,
|
|
176
|
+
so this component keeps control of dismissal: outside-click, focus, and
|
|
177
|
+
iframe handling are unchanged, and a searchable trigger’s input stays open
|
|
178
|
+
while you interact with it.
|
|
179
|
+
|
|
128
180
|
For placement recipes, see
|
|
129
181
|
[Placement Customization](#placement-customization-with-css-variables)
|
|
130
182
|
below. If your trigger sits near the right edge of the viewport, the
|
|
131
183
|
[End-Aligned, Content-Sized Menu](#end-aligned-content-sized-menu) example
|
|
132
184
|
is the one you want.
|
|
133
185
|
|
|
186
|
+
## Browser Support
|
|
187
|
+
|
|
188
|
+
`Dropdown` relies on two web-platform features, both now
|
|
189
|
+
[Baseline](https://web.dev/baseline) across Chrome, Edge, Firefox, and
|
|
190
|
+
Safari:
|
|
191
|
+
|
|
192
|
+
- **CSS anchor positioning** (`anchor-name`, `position-area`,
|
|
193
|
+
`@position-try`) places and re-flows the body —
|
|
194
|
+
[Baseline 2026](https://developer.mozilla.org/en-US/docs/Web/CSS/anchor-name)
|
|
195
|
+
- the **Popover API** (`popover` plus `showPopover()`) renders the body in
|
|
196
|
+
the top layer —
|
|
197
|
+
[Baseline 2024](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API)
|
|
198
|
+
|
|
199
|
+
These are _newly available_ Baseline features: they work in up-to-date
|
|
200
|
+
browser versions but not in ones predating that support, which the
|
|
201
|
+
component requires.
|
|
202
|
+
|
|
134
203
|
## API Reference
|
|
135
204
|
|
|
136
205
|
### Props
|
|
@@ -160,11 +229,6 @@ type Props = {
|
|
|
160
229
|
children: ReactNode | [ReactNode, ReactNode];
|
|
161
230
|
className?: string;
|
|
162
231
|
disabled?: boolean;
|
|
163
|
-
/**
|
|
164
|
-
* Group identifier string links dropdowns together into a menu
|
|
165
|
-
* (like macOS top menubar).
|
|
166
|
-
*/
|
|
167
|
-
group?: string;
|
|
168
232
|
/**
|
|
169
233
|
* Whether the dropdown contains items that can be selected.
|
|
170
234
|
* Defaults to true if children contain elements with data-ukt-item or data-ukt-value.
|
|
@@ -185,20 +249,18 @@ type Props = {
|
|
|
185
249
|
keepOpenOnSubmit?: boolean;
|
|
186
250
|
/**
|
|
187
251
|
* Label content for the trigger button (when using single child syntax).
|
|
252
|
+
* For a nested (submenu) Dropdown, this is the parent item’s content.
|
|
188
253
|
*/
|
|
189
254
|
label?: ReactNode;
|
|
190
|
-
/**
|
|
191
|
-
* Minimum height for the dropdown body in pixels. Defaults to 30.
|
|
192
|
-
*/
|
|
193
|
-
minHeightBody?: number;
|
|
194
|
-
/**
|
|
195
|
-
* Minimum width for the dropdown body in pixels.
|
|
196
|
-
*/
|
|
197
|
-
minWidthBody?: number;
|
|
198
255
|
/**
|
|
199
256
|
* Name attribute for the search input (requires isSearchable: true).
|
|
200
257
|
*/
|
|
201
258
|
name?: string;
|
|
259
|
+
/**
|
|
260
|
+
* Called when the active (highlighted) item changes. Receives the
|
|
261
|
+
* same payload as onSubmitItem.
|
|
262
|
+
*/
|
|
263
|
+
onActiveItem?: (payload: Item) => void;
|
|
202
264
|
onClick?: (event: React.MouseEvent<HTMLElement>) => unknown;
|
|
203
265
|
onClose?: () => unknown;
|
|
204
266
|
onMouseDown?: (event: React.MouseEvent<HTMLElement>) => unknown;
|
|
@@ -236,6 +298,11 @@ type Item = {
|
|
|
236
298
|
element: HTMLElement | null;
|
|
237
299
|
event: Event | React.SyntheticEvent<HTMLElement>;
|
|
238
300
|
label: string;
|
|
301
|
+
/**
|
|
302
|
+
* Ancestor parent items from the root level down to the item’s
|
|
303
|
+
* immediate parent. Empty for top-level items.
|
|
304
|
+
*/
|
|
305
|
+
path: Array<{ label: string; value: string }>;
|
|
239
306
|
value: string;
|
|
240
307
|
};
|
|
241
308
|
```
|
|
@@ -406,7 +473,7 @@ CSS custom properties for the most common low-level placement controls:
|
|
|
406
473
|
|
|
407
474
|
- `--uktdd-body-position-area`
|
|
408
475
|
- `--uktdd-body-position-try-fallbacks`
|
|
409
|
-
- `--uktdd-body-
|
|
476
|
+
- `--uktdd-body-gap` (space between the trigger and the body)
|
|
410
477
|
- `--uktdd-body-min-height`
|
|
411
478
|
- `--uktdd-body-min-width`
|
|
412
479
|
- `--uktdd-body-max-height`
|
|
@@ -419,7 +486,7 @@ Example:
|
|
|
419
486
|
--uktdd-body-position-area: bottom span-left;
|
|
420
487
|
--uktdd-body-position-try-fallbacks:
|
|
421
488
|
--uktdd-top-right, --uktdd-bottom-left, --uktdd-top-left;
|
|
422
|
-
--uktdd-body-
|
|
489
|
+
--uktdd-body-gap: 8px;
|
|
423
490
|
}
|
|
424
491
|
|
|
425
492
|
.settings-dropdown .uktdropdown-body {
|
|
@@ -430,6 +497,18 @@ Example:
|
|
|
430
497
|
This approach keeps the public React API small while still allowing precise
|
|
431
498
|
placement and sizing control when a product surface needs it.
|
|
432
499
|
|
|
500
|
+
Use `--uktdd-body-gap` for the space between the trigger and the body. It
|
|
501
|
+
is applied as a symmetric `margin-block`, so the gap lands on whichever
|
|
502
|
+
side the body attaches to and auto-reverses when `position-try` flips the
|
|
503
|
+
body above the trigger. A margin establishes no containing block, so
|
|
504
|
+
`--uktdd-body-gap` is safe on dropdowns with submenus.
|
|
505
|
+
|
|
506
|
+
For an offset a gap can’t express — a horizontal nudge, or deliberately
|
|
507
|
+
overlapping the trigger — set `translate` on `.uktdropdown-body` directly.
|
|
508
|
+
A `translate` makes the body a containing block for its fixed-position
|
|
509
|
+
submenus, which constrains and clips them, so reserve it for dropdowns
|
|
510
|
+
without submenus.
|
|
511
|
+
|
|
433
512
|
### End-Aligned, Content-Sized Menu
|
|
434
513
|
|
|
435
514
|
For menus attached to controls near the right edge of the viewport, such as
|
|
@@ -453,15 +532,281 @@ This keeps the menu:
|
|
|
453
532
|
Avoid hardcoding width for this pattern unless the product explicitly needs
|
|
454
533
|
a fixed-size menu.
|
|
455
534
|
|
|
456
|
-
|
|
535
|
+
### Centered Menus
|
|
536
|
+
|
|
537
|
+
To center a menu over its trigger, use a `span-all` tile, not a `center`
|
|
538
|
+
tile. A `center` tile is only as wide as the trigger, so a body wider than
|
|
539
|
+
the trigger always overflows it — and `position-try` refuses to select a
|
|
540
|
+
fallback that overflows, so a `bottom center` → `top center` setup never
|
|
541
|
+
flips. `span-all` centers over the trigger identically but spans the full
|
|
542
|
+
width, so it flips cleanly and clamps to the viewport near an edge; the
|
|
543
|
+
package’s `position-try-order: most-height` then picks the taller side:
|
|
544
|
+
|
|
545
|
+
```css
|
|
546
|
+
.centered-menu {
|
|
547
|
+
--uktdd-body-position-area: bottom span-all;
|
|
548
|
+
--uktdd-body-position-try-fallbacks: --centered-menu-top;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/* define the flipped fallback in your own CSS */
|
|
552
|
+
@position-try --centered-menu-top {
|
|
553
|
+
position-area: top span-all;
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
## Submenus
|
|
558
|
+
|
|
559
|
+
A `Dropdown` is a disclosure node: a trigger plus a disclosed list.
|
|
560
|
+
Disclosure nodes compose — nest them for depth (submenus), or wrap siblings
|
|
561
|
+
in [`Menubar`](#menubar) for breadth (a menu bar). A submenu is nothing
|
|
562
|
+
more than a `Dropdown` nested inside another dropdown’s body: the nested
|
|
563
|
+
dropdown renders as a **parent item**, an item that discloses a child menu
|
|
564
|
+
instead of submitting a value.
|
|
565
|
+
|
|
566
|
+
```tsx
|
|
567
|
+
import Dropdown from '@acusti/dropdown';
|
|
568
|
+
|
|
569
|
+
function FormatMenu() {
|
|
570
|
+
return (
|
|
571
|
+
<Dropdown label="Format" onSubmitItem={applyFormat}>
|
|
572
|
+
<ul>
|
|
573
|
+
<li data-ukt-item>Bold</li>
|
|
574
|
+
<li data-ukt-item>Italic</li>
|
|
575
|
+
<Dropdown label="Align">
|
|
576
|
+
<ul>
|
|
577
|
+
<li data-ukt-value="left">Left</li>
|
|
578
|
+
<li data-ukt-value="center">Center</li>
|
|
579
|
+
<li data-ukt-value="right">Right</li>
|
|
580
|
+
</ul>
|
|
581
|
+
</Dropdown>
|
|
582
|
+
</ul>
|
|
583
|
+
</Dropdown>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Submenu semantics:
|
|
589
|
+
|
|
590
|
+
- Parent items disclose; they never submit. `onSubmitItem` fires only for
|
|
591
|
+
leaf items, and the payload’s `path` reports the leaf’s ancestor parent
|
|
592
|
+
items (see the [`Item` type](#item-type) above), so leaves with the same
|
|
593
|
+
value in different submenus are distinguishable.
|
|
594
|
+
- Submenus nest to arbitrary depth — a submenu body can itself contain
|
|
595
|
+
nested dropdowns.
|
|
596
|
+
- A parent item highlights immediately when it becomes active — via pointer
|
|
597
|
+
or arrow keys — and discloses its submenu after a short intent delay,
|
|
598
|
+
with the highlight staying on the parent (macOS-style: moving quickly
|
|
599
|
+
past parent items never flashes their submenus open, but pausing on one
|
|
600
|
+
for even a moment shows its submenu). Activating a sibling closes it. →
|
|
601
|
+
opens the submenu immediately and moves the highlight into it — see
|
|
602
|
+
[Keyboard Navigation](#keyboard-navigation--accessibility).
|
|
603
|
+
- A parent item’s open state is carried by its `aria-expanded` attribute,
|
|
604
|
+
which is also the styling hook for submenu visibility.
|
|
605
|
+
- With the pointer, an open submenu tracks mouse intent: moving diagonally
|
|
606
|
+
from the parent item toward the submenu keeps it open even as the pointer
|
|
607
|
+
crosses sibling items along the way (the macOS “safe triangle” between
|
|
608
|
+
the pointer and the submenu’s near edge). Moving straight onto a sibling
|
|
609
|
+
— or pausing mid-diagonal — switches to that item as usual.
|
|
610
|
+
|
|
611
|
+
### The `data-ukt-submenu` protocol
|
|
612
|
+
|
|
613
|
+
Like items, submenus are ultimately declared in the DOM. A nested
|
|
614
|
+
`Dropdown` renders this markup:
|
|
615
|
+
|
|
616
|
+
```html
|
|
617
|
+
<li data-ukt-item aria-haspopup="menu" aria-expanded="false">
|
|
618
|
+
Align
|
|
619
|
+
<ul data-ukt-submenu role="menu">
|
|
620
|
+
…
|
|
621
|
+
</ul>
|
|
622
|
+
</li>
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
You can author that markup directly instead of nesting a `Dropdown`
|
|
626
|
+
component; the two forms behave identically because the component form
|
|
627
|
+
compiles to the attribute form. Rules for direct authoring:
|
|
628
|
+
|
|
629
|
+
- the `data-ukt-submenu` element must be a descendant of its parent item
|
|
630
|
+
element
|
|
631
|
+
- in a level whose items include a submenu, mark all of that level’s items
|
|
632
|
+
explicitly with `data-ukt-item` or `data-ukt-value`; purely flat levels —
|
|
633
|
+
including flat submenu bodies — fall back to the automatic item-detection
|
|
634
|
+
heuristic
|
|
635
|
+
- a parent item’s label is its text content excluding the submenu subtree
|
|
636
|
+
|
|
637
|
+
### Props on a nested Dropdown
|
|
638
|
+
|
|
639
|
+
Most props keep their meaning, scoped to the submenu:
|
|
640
|
+
|
|
641
|
+
- `label` (or the first of two children): the parent item’s content
|
|
642
|
+
- `onSubmitItem`: fires for submissions within that subtree only
|
|
643
|
+
- `onActiveItem`, `onOpen`, `onClose`: scoped to the submenu
|
|
644
|
+
- `disabled`: disables the parent item
|
|
645
|
+
- `className`/`style`: applied to the parent item element
|
|
646
|
+
|
|
647
|
+
Props that only make sense at the top level (`allowCreate`, `allowEmpty`,
|
|
648
|
+
`isOpenOnMount`, `isSearchable`, `keepOpenOnSubmit`, `name`, `placeholder`,
|
|
649
|
+
`tabIndex`, `value`) are ignored on a nested dropdown and warn
|
|
650
|
+
(unconditionally, matching the children-count misuse error).
|
|
651
|
+
|
|
652
|
+
### Submenu placement and styling
|
|
653
|
+
|
|
654
|
+
Submenus reuse the dropdown’s anchor-positioning layout model: the expanded
|
|
655
|
+
parent item is the anchor, and the submenu opens at its inline-end,
|
|
656
|
+
top-aligned, falling back to the opposite side when out of bounds.
|
|
657
|
+
Placement custom properties follow the established naming scheme:
|
|
658
|
+
|
|
659
|
+
- `--uktdd-submenu-position-area` (default: `inline-end span-block-end`)
|
|
660
|
+
- `--uktdd-submenu-position-try-fallbacks`
|
|
661
|
+
- `--uktdd-submenu-gap` (space between the parent item and the submenu;
|
|
662
|
+
applied as a symmetric `margin-inline`, the inline-axis counterpart to
|
|
663
|
+
`--uktdd-body-gap`, so it auto-reverses when the submenu flips to the
|
|
664
|
+
opposite side)
|
|
665
|
+
|
|
666
|
+
Parent items render a macOS-style disclosure chevron, drawn in CSS with the
|
|
667
|
+
item’s `::after` pseudo-element and colored with `currentColor` so it
|
|
668
|
+
follows the item’s highlight color. Restyle or remove it by overriding
|
|
669
|
+
`[aria-haspopup='menu']::after`.
|
|
670
|
+
|
|
671
|
+
Submenu bodies set `color: var(--uktdd-body-color)` (default: `canvastext`)
|
|
672
|
+
explicitly — without it, an active parent item’s highlight text color would
|
|
673
|
+
inherit into its submenu’s items, because the submenu is the parent item’s
|
|
674
|
+
DOM child. Set `--uktdd-body-color` alongside `--uktdd-body-bg-color` when
|
|
675
|
+
theming menu colors.
|
|
676
|
+
|
|
677
|
+
The active path is fully styleable: `data-ukt-active` marks one item per
|
|
678
|
+
open level (a parent item stays active while you navigate its submenu), and
|
|
679
|
+
`aria-expanded="true"` marks open parent items. The default submenu
|
|
680
|
+
visibility rule, shown here for reference:
|
|
681
|
+
|
|
682
|
+
```css
|
|
683
|
+
[data-ukt-submenu] {
|
|
684
|
+
display: none;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
[aria-expanded='true'] > [data-ukt-submenu] {
|
|
688
|
+
display: block;
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
Following macOS, the default styles distinguish the deepest active item —
|
|
693
|
+
the one keyboard input operates on — from its ancestors on the active path.
|
|
694
|
+
The deepest item gets the primary highlight
|
|
695
|
+
(`--uktdd-body-bg-color-hover`); an ancestor parent item — active with the
|
|
696
|
+
highlight deeper inside its submenu, or with the pointer anywhere inside
|
|
697
|
+
its open submenu, even over a separator or disabled item — gets a muted
|
|
698
|
+
highlight (`--uktdd-body-bg-color-path`, gray by default). Surfacing back
|
|
699
|
+
out with ← closes the submenu and restores the parent’s primary highlight.
|
|
700
|
+
The default rule, shown here for reference:
|
|
701
|
+
|
|
702
|
+
```css
|
|
703
|
+
/* an active item is on the path — not deepest — while a deeper
|
|
704
|
+
highlight exists or the pointer is inside its submenu */
|
|
705
|
+
[data-ukt-active]:has([data-ukt-submenu] [data-ukt-active]),
|
|
706
|
+
[data-ukt-active]:has([data-ukt-submenu]:hover) {
|
|
707
|
+
background-color: var(--uktdd-body-bg-color-path);
|
|
708
|
+
color: var(--uktdd-body-color-path);
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
### Nested non-menu dropdowns
|
|
457
713
|
|
|
458
|
-
|
|
714
|
+
Nesting means submenu only for menus. A nested `Dropdown` with
|
|
715
|
+
`hasItems={false}` isn’t a menu, so it renders as an independent anchored
|
|
716
|
+
dropdown instead of a parent item — for example, an ℹ️ button next to an
|
|
717
|
+
input in a form dropdown that opens an info popover about the input:
|
|
459
718
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
719
|
+
```tsx
|
|
720
|
+
<Dropdown hasItems={false}>
|
|
721
|
+
Preferences
|
|
722
|
+
<div>
|
|
723
|
+
<label>
|
|
724
|
+
Width <input name="width" />
|
|
725
|
+
</label>
|
|
726
|
+
<Dropdown hasItems={false}>
|
|
727
|
+
<button aria-label="About width">ℹ️</button>
|
|
728
|
+
<p>Width accepts any CSS length.</p>
|
|
729
|
+
</Dropdown>
|
|
730
|
+
</div>
|
|
731
|
+
</Dropdown>
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
The nested dropdown owns its own interactions: opening it, clicking inside
|
|
735
|
+
it, and navigating it never close or submit the outer dropdown, and Escape
|
|
736
|
+
closes the innermost open dropdown first — a second Escape closes the outer
|
|
737
|
+
one.
|
|
738
|
+
|
|
739
|
+
## Menubar
|
|
740
|
+
|
|
741
|
+
`Menubar` combines sibling dropdowns into a single menu, like the system
|
|
742
|
+
menu in the top toolbar of macOS. Each dropdown’s trigger becomes a menubar
|
|
743
|
+
item. See the [Quick Start](#quick-start) for a complete example:
|
|
744
|
+
|
|
745
|
+
```tsx
|
|
746
|
+
import Dropdown, { Menubar } from '@acusti/dropdown';
|
|
747
|
+
|
|
748
|
+
<Menubar>
|
|
749
|
+
<Dropdown label="File">…</Dropdown>
|
|
750
|
+
<Dropdown label="Edit">…</Dropdown>
|
|
751
|
+
<Dropdown label="View">…</Dropdown>
|
|
752
|
+
</Menubar>;
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
Menubar behaviors:
|
|
756
|
+
|
|
757
|
+
- renders `role="menubar"`; each dropdown trigger is a menubar item
|
|
758
|
+
- at most one menu in the bar is open at a time
|
|
759
|
+
- once any menu is open, hovering another trigger switches to that menu
|
|
760
|
+
without a click (matching macOS menubar behavior)
|
|
761
|
+
- **←/→** move between menus, wrapping at the ends: with the bar focused
|
|
762
|
+
they move focus between triggers, and while a menu is open they slide the
|
|
763
|
+
open menu to the adjacent trigger — unless the active item is a parent
|
|
764
|
+
item, in which case **→** dives into its submenu instead
|
|
765
|
+
- **↓/Enter/Space** open the focused trigger’s menu
|
|
766
|
+
- **Escape** closes the open menu — including any open submenus — and
|
|
767
|
+
returns focus to its menubar item
|
|
768
|
+
|
|
769
|
+
The trigger of the open menu gets an active-state background so it reads as
|
|
770
|
+
selected, like the highlighted item in the macOS menu bar. The default
|
|
771
|
+
tints over whatever background the trigger already has (via
|
|
772
|
+
`--uktdd-menubar-trigger-bg-color-active`, applied to
|
|
773
|
+
`.uktmenubar .uktdropdown.is-open .uktdropdown-trigger`), so it works
|
|
774
|
+
regardless of how the trigger is styled; override the custom property, or
|
|
775
|
+
the rule itself, to restyle it.
|
|
776
|
+
|
|
777
|
+
`Menubar` supersedes the `group` prop that appeared in earlier versions of
|
|
778
|
+
the `Props` type: `group` was a string-ID way to associate dropdowns that
|
|
779
|
+
was never wired up, and composition expresses the relationship directly.
|
|
780
|
+
|
|
781
|
+
## Keyboard Navigation & Accessibility
|
|
782
|
+
|
|
783
|
+
The dropdown implements full keyboard navigation. The model has one rule:
|
|
784
|
+
navigate along the current menu level’s axis, dive into a submenu
|
|
785
|
+
perpendicular to it, surface back out with the opposite key. Menus are
|
|
786
|
+
vertical, so ↑/↓ navigate and →/← dive/surface; a menubar is horizontal, so
|
|
787
|
+
←/→ navigate and ↓ dives.
|
|
788
|
+
|
|
789
|
+
Keys operate on the **current level**: the level of the deepest highlighted
|
|
790
|
+
item. Note that this can be one level shallower than the deepest open
|
|
791
|
+
submenu — pausing on a parent item discloses its submenu after a short
|
|
792
|
+
intent delay while the highlight stays on the parent, and only → moves the
|
|
793
|
+
highlight into it.
|
|
794
|
+
|
|
795
|
+
- **Enter/Space**: Open the dropdown; with an item highlighted, select it
|
|
796
|
+
(leaf items) or open its submenu (parent items)
|
|
797
|
+
- **Escape**: Close the dropdown entirely — including any open submenus —
|
|
798
|
+
and return focus to the trigger (macOS-style; use ← to back out one level
|
|
799
|
+
at a time)
|
|
800
|
+
- **Arrow Up/Down**: Navigate between items in the current level
|
|
801
|
+
- **Arrow Right**: Open the highlighted parent item’s submenu (if not
|
|
802
|
+
already disclosed) and highlight its first item; in a
|
|
803
|
+
[`Menubar`](#menubar), if the highlighted item is a leaf, slide to the
|
|
804
|
+
next menu instead
|
|
805
|
+
- **Arrow Left**: Close the highlighted item’s level and return to its
|
|
806
|
+
parent item; in a `Menubar` with the highlight at the top level of a
|
|
807
|
+
menu, slide to the previous menu
|
|
808
|
+
- **Home/End**: Jump to first/last item in the current level
|
|
809
|
+
- **Type characters**: Jump to the best-matching item in the current level
|
|
465
810
|
|
|
466
811
|
For accessibility, the component focuses on semantic HTML structure and
|
|
467
812
|
keyboard navigation. It works best when you use appropriate HTML elements
|
|
@@ -482,3 +827,21 @@ The open body element also receives a matching `role` and an `id` so screen
|
|
|
482
827
|
readers can associate the trigger with its popup. If your custom trigger
|
|
483
828
|
already specifies any of these ARIA props, your values win — the component
|
|
484
829
|
only fills in what you haven’t set.
|
|
830
|
+
|
|
831
|
+
Submenus and menubars extend the same pattern automatically:
|
|
832
|
+
|
|
833
|
+
- parent items receive `aria-haspopup="menu"` and `aria-expanded`, and
|
|
834
|
+
their submenu container receives `role="menu"` and a linked `id`
|
|
835
|
+
- the `Menubar` container receives `role="menubar"`
|
|
836
|
+
|
|
837
|
+
As with the trigger, any of these you specify yourself win.
|
|
838
|
+
|
|
839
|
+
One deliberate deviation from the
|
|
840
|
+
[APG menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/): APG
|
|
841
|
+
specifies that Escape closes one menu level per press, while this component
|
|
842
|
+
closes the whole menu at once (matching macOS native menus and Radix UI).
|
|
843
|
+
Level-by-level closing is already covered by ←, so a one-level Escape would
|
|
844
|
+
be redundant; closing everything preserves Escape’s universal “dismiss this
|
|
845
|
+
context” meaning. The part of the APG guidance that matters for assistive
|
|
846
|
+
technology is kept: closing the menu always returns focus to the element
|
|
847
|
+
that opened it.
|
package/dist/Dropdown.d.ts
CHANGED
|
@@ -2,6 +2,15 @@ import { type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactEleme
|
|
|
2
2
|
export type Item = {
|
|
3
3
|
element: MaybeHTMLElement;
|
|
4
4
|
event: Event | SyntheticEvent<HTMLElement>;
|
|
5
|
+
label: string;
|
|
6
|
+
/**
|
|
7
|
+
* Ancestor parent items from the root level down to the item’s
|
|
8
|
+
* immediate parent. Empty for top-level items.
|
|
9
|
+
*/
|
|
10
|
+
path: Array<ItemPathEntry>;
|
|
11
|
+
value: string;
|
|
12
|
+
};
|
|
13
|
+
export type ItemPathEntry = {
|
|
5
14
|
label: string;
|
|
6
15
|
value: string;
|
|
7
16
|
};
|
|
@@ -27,18 +36,15 @@ export type Props = {
|
|
|
27
36
|
children: ChildrenTuple | ReactElement;
|
|
28
37
|
className?: string;
|
|
29
38
|
disabled?: boolean;
|
|
30
|
-
/**
|
|
31
|
-
* Group identifier string links dropdowns together into a menu
|
|
32
|
-
* (like macOS top menubar).
|
|
33
|
-
*/
|
|
34
|
-
group?: string;
|
|
35
39
|
hasItems?: boolean;
|
|
36
40
|
isOpenOnMount?: boolean;
|
|
37
41
|
isSearchable?: boolean;
|
|
38
42
|
keepOpenOnSubmit?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Label content for the trigger button (when using single child syntax).
|
|
45
|
+
* For a nested (submenu) Dropdown, this is the parent item’s content.
|
|
46
|
+
*/
|
|
39
47
|
label?: ReactNode;
|
|
40
|
-
minHeightBody?: number;
|
|
41
|
-
minWidthBody?: number;
|
|
42
48
|
/**
|
|
43
49
|
* Only usable in conjunction with {isSearchable: true}.
|
|
44
50
|
* Used as search input’s name.
|
|
@@ -56,7 +62,12 @@ export type Props = {
|
|
|
56
62
|
* Used as search input’s placeholder.
|
|
57
63
|
*/
|
|
58
64
|
placeholder?: string;
|
|
59
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Applied to the dropdown root element. Also accepts the component’s CSS
|
|
67
|
+
* custom properties (e.g. `--uktdd-body-min-width`) for per-instance
|
|
68
|
+
* placement and sizing, which plain `CSSProperties` rejects.
|
|
69
|
+
*/
|
|
70
|
+
style?: CSSProperties & Record<`--${string}`, string | number | undefined>;
|
|
60
71
|
/**
|
|
61
72
|
* Only usable in conjunction with {isSearchable: true}.
|
|
62
73
|
* Used as search input’s tabIndex.
|
|
@@ -70,5 +81,5 @@ export type Props = {
|
|
|
70
81
|
};
|
|
71
82
|
type ChildrenTuple = [ReactNode, ReactNode] | readonly [ReactNode, ReactNode];
|
|
72
83
|
type MaybeHTMLElement = HTMLElement | null;
|
|
73
|
-
export default function Dropdown(
|
|
74
|
-
export {};
|
|
84
|
+
export default function Dropdown(props: Props): import("react").JSX.Element;
|
|
85
|
+
export { default as Menubar, type MenubarProps } from './Menubar.js';
|