@acusti/dropdown 0.56.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 +396 -28
- package/dist/Dropdown.d.ts +29 -13
- package/dist/Dropdown.js +1072 -333
- 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 +29 -8
- package/package.json +5 -8
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
|
|
@@ -143,8 +212,13 @@ type Props = {
|
|
|
143
212
|
*/
|
|
144
213
|
allowCreate?: boolean;
|
|
145
214
|
/**
|
|
146
|
-
* Boolean indicating if
|
|
147
|
-
* the value).
|
|
215
|
+
* Boolean indicating if submitting with no item active emits an empty
|
|
216
|
+
* value (i.e. clears the value). Only has an effect when the dropdown has a
|
|
217
|
+
* text input to source that value from — a searchable dropdown’s search
|
|
218
|
+
* input, or a text input inside a custom trigger. With no such input there
|
|
219
|
+
* is no value to submit, so submitting with nothing selected is a no-op
|
|
220
|
+
* regardless; clear such a dropdown with an explicit empty-valued item
|
|
221
|
+
* instead. Defaults to true.
|
|
148
222
|
*/
|
|
149
223
|
allowEmpty?: boolean;
|
|
150
224
|
/**
|
|
@@ -155,11 +229,6 @@ type Props = {
|
|
|
155
229
|
children: ReactNode | [ReactNode, ReactNode];
|
|
156
230
|
className?: string;
|
|
157
231
|
disabled?: boolean;
|
|
158
|
-
/**
|
|
159
|
-
* Group identifier string links dropdowns together into a menu
|
|
160
|
-
* (like macOS top menubar).
|
|
161
|
-
*/
|
|
162
|
-
group?: string;
|
|
163
232
|
/**
|
|
164
233
|
* Whether the dropdown contains items that can be selected.
|
|
165
234
|
* Defaults to true if children contain elements with data-ukt-item or data-ukt-value.
|
|
@@ -180,20 +249,18 @@ type Props = {
|
|
|
180
249
|
keepOpenOnSubmit?: boolean;
|
|
181
250
|
/**
|
|
182
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.
|
|
183
253
|
*/
|
|
184
254
|
label?: ReactNode;
|
|
185
|
-
/**
|
|
186
|
-
* Minimum height for the dropdown body in pixels. Defaults to 30.
|
|
187
|
-
*/
|
|
188
|
-
minHeightBody?: number;
|
|
189
|
-
/**
|
|
190
|
-
* Minimum width for the dropdown body in pixels.
|
|
191
|
-
*/
|
|
192
|
-
minWidthBody?: number;
|
|
193
255
|
/**
|
|
194
256
|
* Name attribute for the search input (requires isSearchable: true).
|
|
195
257
|
*/
|
|
196
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;
|
|
197
264
|
onClick?: (event: React.MouseEvent<HTMLElement>) => unknown;
|
|
198
265
|
onClose?: () => unknown;
|
|
199
266
|
onMouseDown?: (event: React.MouseEvent<HTMLElement>) => unknown;
|
|
@@ -231,6 +298,11 @@ type Item = {
|
|
|
231
298
|
element: HTMLElement | null;
|
|
232
299
|
event: Event | React.SyntheticEvent<HTMLElement>;
|
|
233
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 }>;
|
|
234
306
|
value: string;
|
|
235
307
|
};
|
|
236
308
|
```
|
|
@@ -401,7 +473,7 @@ CSS custom properties for the most common low-level placement controls:
|
|
|
401
473
|
|
|
402
474
|
- `--uktdd-body-position-area`
|
|
403
475
|
- `--uktdd-body-position-try-fallbacks`
|
|
404
|
-
- `--uktdd-body-
|
|
476
|
+
- `--uktdd-body-gap` (space between the trigger and the body)
|
|
405
477
|
- `--uktdd-body-min-height`
|
|
406
478
|
- `--uktdd-body-min-width`
|
|
407
479
|
- `--uktdd-body-max-height`
|
|
@@ -414,7 +486,7 @@ Example:
|
|
|
414
486
|
--uktdd-body-position-area: bottom span-left;
|
|
415
487
|
--uktdd-body-position-try-fallbacks:
|
|
416
488
|
--uktdd-top-right, --uktdd-bottom-left, --uktdd-top-left;
|
|
417
|
-
--uktdd-body-
|
|
489
|
+
--uktdd-body-gap: 8px;
|
|
418
490
|
}
|
|
419
491
|
|
|
420
492
|
.settings-dropdown .uktdropdown-body {
|
|
@@ -425,6 +497,18 @@ Example:
|
|
|
425
497
|
This approach keeps the public React API small while still allowing precise
|
|
426
498
|
placement and sizing control when a product surface needs it.
|
|
427
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
|
+
|
|
428
512
|
### End-Aligned, Content-Sized Menu
|
|
429
513
|
|
|
430
514
|
For menus attached to controls near the right edge of the viewport, such as
|
|
@@ -448,15 +532,281 @@ This keeps the menu:
|
|
|
448
532
|
Avoid hardcoding width for this pattern unless the product explicitly needs
|
|
449
533
|
a fixed-size menu.
|
|
450
534
|
|
|
451
|
-
|
|
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
|
|
452
713
|
|
|
453
|
-
|
|
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:
|
|
454
718
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
460
810
|
|
|
461
811
|
For accessibility, the component focuses on semantic HTML structure and
|
|
462
812
|
keyboard navigation. It works best when you use appropriate HTML elements
|
|
@@ -477,3 +827,21 @@ The open body element also receives a matching `role` and an `id` so screen
|
|
|
477
827
|
readers can associate the trigger with its popup. If your custom trigger
|
|
478
828
|
already specifies any of these ARIA props, your values win — the component
|
|
479
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
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import { CSSProperties, MouseEvent as ReactMouseEvent, ReactElement, ReactNode, SyntheticEvent } from 'react';
|
|
1
|
+
import { type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, type SyntheticEvent } from 'react';
|
|
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
|
};
|
|
@@ -12,8 +21,13 @@ export type Props = {
|
|
|
12
21
|
*/
|
|
13
22
|
allowCreate?: boolean;
|
|
14
23
|
/**
|
|
15
|
-
* Boolean indicating if
|
|
16
|
-
* the value).
|
|
24
|
+
* Boolean indicating if submitting with no item active emits an empty
|
|
25
|
+
* value (i.e. clears the value). Only has an effect when the dropdown has a
|
|
26
|
+
* text input to source that value from — a searchable dropdown’s search
|
|
27
|
+
* input, or a text input inside a custom trigger. With no such input there
|
|
28
|
+
* is no value to submit, so submitting with nothing selected is a no-op
|
|
29
|
+
* regardless; clear such a dropdown with an explicit empty-valued item
|
|
30
|
+
* instead. Defaults to true.
|
|
17
31
|
*/
|
|
18
32
|
allowEmpty?: boolean;
|
|
19
33
|
/**
|
|
@@ -22,18 +36,15 @@ export type Props = {
|
|
|
22
36
|
children: ChildrenTuple | ReactElement;
|
|
23
37
|
className?: string;
|
|
24
38
|
disabled?: boolean;
|
|
25
|
-
/**
|
|
26
|
-
* Group identifier string links dropdowns together into a menu
|
|
27
|
-
* (like macOS top menubar).
|
|
28
|
-
*/
|
|
29
|
-
group?: string;
|
|
30
39
|
hasItems?: boolean;
|
|
31
40
|
isOpenOnMount?: boolean;
|
|
32
41
|
isSearchable?: boolean;
|
|
33
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
|
+
*/
|
|
34
47
|
label?: ReactNode;
|
|
35
|
-
minHeightBody?: number;
|
|
36
|
-
minWidthBody?: number;
|
|
37
48
|
/**
|
|
38
49
|
* Only usable in conjunction with {isSearchable: true}.
|
|
39
50
|
* Used as search input’s name.
|
|
@@ -51,7 +62,12 @@ export type Props = {
|
|
|
51
62
|
* Used as search input’s placeholder.
|
|
52
63
|
*/
|
|
53
64
|
placeholder?: string;
|
|
54
|
-
|
|
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>;
|
|
55
71
|
/**
|
|
56
72
|
* Only usable in conjunction with {isSearchable: true}.
|
|
57
73
|
* Used as search input’s tabIndex.
|
|
@@ -65,5 +81,5 @@ export type Props = {
|
|
|
65
81
|
};
|
|
66
82
|
type ChildrenTuple = [ReactNode, ReactNode] | readonly [ReactNode, ReactNode];
|
|
67
83
|
type MaybeHTMLElement = HTMLElement | null;
|
|
68
|
-
export default function Dropdown(
|
|
69
|
-
export {};
|
|
84
|
+
export default function Dropdown(props: Props): import("react").JSX.Element;
|
|
85
|
+
export { default as Menubar, type MenubarProps } from './Menubar.js';
|