@acusti/dropdown 1.0.0-beta.0 → 1.0.0-beta.2

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
@@ -190,20 +190,33 @@ is the one you want.
190
190
  ## Color Scheme
191
191
 
192
192
  The default colors are drawn from CSS system colors (`Canvas`,
193
- `CanvasText`, `Highlight`, `HighlightText`) and `color-mix()` blends
194
- against `CanvasText`, rather than fixed light-mode values, so the dropdown
195
- looks native in whichever color scheme is in effect.
196
- `--uktdd-body-box-shadow` is the exception — a fixed `rgba(0, 0, 0, 0.25)`
197
- rather than a scheme-aware mix, since mixing in `CanvasText` turns it into
198
- a diffuse white glow in dark mode instead of a shadow. `color-scheme` (set
199
- via `--uktdd-color-scheme`, default `inherit`) is left to inherit from the
200
- host page rather than forced to `light dark`: the popover is page content,
201
- not OS chrome, so it matches whatever scheme the page itself has opted into
202
- — light, if the page hasn’t adopted dark mode — rather than following the
203
- OS/browser preference regardless of the page. Set `--uktdd-color-scheme` to
204
- `light dark` (or `light`/`dark`) to follow the user’s preference
205
- irrespective of the host page instead. Override any of the
206
- `--uktdd-body-bg-color*` / `--uktdd-body-color*` /
193
+ `CanvasText`, `Highlight`, `HighlightText`) rather than fixed light-mode
194
+ values, so the dropdown looks native in whichever color scheme is in
195
+ effect.
196
+
197
+ The tints layered over the body — `--uktdd-body-bg-color-selected`,
198
+ `--uktdd-body-border-color`, and `--uktdd-body-bg-color-path` — are
199
+ `color-mix()` blends that derive from the body’s own foreground and
200
+ background rather than from `CanvasText`/`Canvas` directly, so theming the
201
+ surface carries them with it: set `--uktdd-body-color` and
202
+ `--uktdd-body-bg-color` and the selected tint, hairline border, and
203
+ active-path step all re-derive from the pair you set. That matters because
204
+ a `var()` inside a custom property is substituted where that property is
205
+ declared, so a `:root` default blended against `CanvasText` would freeze to
206
+ the ambient color scheme before a consumer’s override could reach it — a
207
+ dark menu on a light page would keep a black hairline it can’t show. Each
208
+ one is still a plain custom property, so setting it directly overrides the
209
+ derived default outright. `--uktdd-body-box-shadow` is the exception — a
210
+ fixed `rgba(0, 0, 0, 0.25)` rather than a scheme-aware mix, since mixing in
211
+ `CanvasText` turns it into a diffuse white glow in dark mode instead of a
212
+ shadow. `color-scheme` (set via `--uktdd-color-scheme`, default `inherit`)
213
+ is left to inherit from the host page rather than forced to `light dark`:
214
+ the popover is page content, not OS chrome, so it matches whatever scheme
215
+ the page itself has opted into — light, if the page hasn’t adopted dark
216
+ mode — rather than following the OS/browser preference regardless of the
217
+ page. Set `--uktdd-color-scheme` to `light dark` (or `light`/`dark`) to
218
+ follow the user’s preference irrespective of the host page instead.
219
+ Override any of the `--uktdd-body-bg-color*` / `--uktdd-body-color*` /
207
220
  `--uktdd-body-box-shadow` custom properties to theme it further.
208
221
  `--uktdd-menubar-trigger-bg-color-active` blends against `currentColor`
209
222
  instead, so it stays visible against a menubar trigger that the consumer
@@ -254,6 +267,16 @@ type Props = {
254
267
  */
255
268
  children: ReactNode | [ReactNode, ReactNode];
256
269
  className?: string;
270
+ /**
271
+ * Prevents the dropdown from opening via user interaction: pointer,
272
+ * Enter/Space, hover (props.openOnHover), typing in a custom trigger’s
273
+ * text input, and Menubar navigation onto it alike. The generated trigger
274
+ * carries the native disabled attribute; a custom trigger can be any
275
+ * element, so it receives aria-disabled instead. Inside a Menubar the
276
+ * member is skipped. A dropdown disabled while already open still closes
277
+ * normally (as does one started open with props.isOpenOnMount, which
278
+ * disabled doesn't override).
279
+ */
257
280
  disabled?: boolean;
258
281
  /**
259
282
  * Whether the dropdown contains items that can be selected.
@@ -1003,7 +1026,23 @@ import Dropdown, { Menubar } from '@acusti/dropdown';
1003
1026
 
1004
1027
  Menubar behaviors:
1005
1028
 
1006
- - renders `role="menubar"`; each dropdown trigger is a menubar item
1029
+ - renders `role="menubar"`, and each member’s trigger is a real
1030
+ `role="menuitem"` that the bar owns directly — the wrapper elements
1031
+ between them are neutralized with `role="none"`, since a generic element
1032
+ in between would leave the menubar owning no menu items at all. A
1033
+ searchable member is exempt from the `menuitem` role: its trigger is a
1034
+ `combobox`, so it keeps its own semantics rather than being mislabelled.
1035
+ Its wrappers are still neutralized, but note that a `combobox` isn’t
1036
+ valid menubar content in the first place — no markup arrangement fixes
1037
+ that, so a search field belongs **outside** the `<Menubar>` (a container
1038
+ mixing menus and other controls is a `toolbar`, not a menubar)
1039
+ - the bar’s **menu items share a single tab stop**, as the APG menubar
1040
+ pattern expects: one `menuitem` trigger is tabbable and the rest are
1041
+ reached with ←/→. The tab stop follows the trigger you last focused, so
1042
+ tabbing away and back returns you where you were; it starts on (and falls
1043
+ back to) the first enabled member. A searchable member sits outside this
1044
+ — it’s a `combobox`, not a menu item, so it keeps its own native tab stop
1045
+ and never takes the bar’s
1007
1046
  - at most one menu in the bar is open at a time
1008
1047
  - opening a trigger’s menu (by click or keyboard) engages the bar
1009
1048
  (menu-mode). While engaged, hovering or focusing another trigger switches
@@ -1015,6 +1054,10 @@ Menubar behaviors:
1015
1054
  - a deliberate dismissal — **Escape**, a click outside the bar, or
1016
1055
  selecting an item — leaves menu-mode, after which hovering a trigger no
1017
1056
  longer opens its menu until a menu is opened again to re-engage the bar
1057
+ - a `disabled` member is passed over rather than landed on, the way macOS
1058
+ skips a disabled menu: ←/→ move to the next enabled trigger, hovering it
1059
+ leaves the open menu alone, and if no other member is enabled the open
1060
+ menu simply stays put
1018
1061
  - **←/→** move between menus, wrapping at the ends: with the bar focused
1019
1062
  they move focus between triggers, and while a menu is open they slide the
1020
1063
  open menu to the adjacent trigger — unless the active item is a parent
@@ -1054,7 +1097,8 @@ highlight into it.
1054
1097
  - **Escape**: Close the dropdown entirely — including any open submenus —
1055
1098
  and return focus to the trigger (macOS-style; use ← to back out one level
1056
1099
  at a time)
1057
- - **Arrow Up/Down**: Navigate between items in the current level
1100
+ - **Arrow Up/Down**: Navigate between items in the current level; with ⌥ or
1101
+ ⌘ held, jump to the first/last item of that level
1058
1102
  - **Arrow Right**: Open the highlighted parent item’s submenu (if not
1059
1103
  already disclosed) and highlight its first item; in a
1060
1104
  [`Menubar`](#menubar), if the highlighted item is a leaf, slide to the
@@ -1062,7 +1106,9 @@ highlight into it.
1062
1106
  - **Arrow Left**: Close the highlighted item’s level and return to its
1063
1107
  parent item; in a `Menubar` with the highlight at the top level of a
1064
1108
  menu, slide to the previous menu
1065
- - **Home/End**: Jump to first/last item in the current level
1109
+ - **Home/End**: Jump to first/last item in the current level. Like ←/→,
1110
+ they stand down while a text input has focus (a searchable dropdown’s own
1111
+ input, or one inside a custom trigger), where they move the caret instead
1066
1112
  - **Type characters**: Jump to the best-matching item in the current level
1067
1113
 
1068
1114
  For accessibility, the component focuses on semantic HTML structure and
@@ -1070,13 +1116,22 @@ keyboard navigation. It works best when you use appropriate HTML elements
1070
1116
  in your dropdown content (like `<ul>` and `<li>` for lists, `<button>`
1071
1117
  elements for actions, etc.).
1072
1118
 
1119
+ The ARIA it adds is a best effort from what it can control: it annotates
1120
+ the DOM it renders and the shapes it recognizes, but never overwrites a
1121
+ value you pass. So when you provide a completely custom trigger, it is
1122
+ possible to end up with ARIA that doesn’t hold together. The safest thing
1123
+ if you go full custom is to take over the ARIA as well.
1124
+
1073
1125
  ### ARIA attributes
1074
1126
 
1075
1127
  The trigger automatically receives `aria-haspopup`, `aria-expanded`, and
1076
- `aria-controls` pointing to the open body. The popup role is chosen based
1077
- on the dropdown’s mode:
1128
+ `aria-controls` pointing to the open body. The component does its best to
1129
+ also apply the correct roles and attributes with custom triggers, but if
1130
+ you are customizing in that way, consider also providing the appropriate
1131
+ ARIA. The popup role is chosen based on the dropdown’s mode:
1078
1132
 
1079
- - `aria-haspopup="listbox"` when `isSearchable` is true (combobox pattern)
1133
+ - `aria-haspopup="listbox"` when `isSearchable` is true (combobox pattern —
1134
+ see below)
1080
1135
  - `aria-haspopup="menu"` when `hasItems` is true (the default)
1081
1136
  - `aria-haspopup="dialog"` when `hasItems={false}` (interactive content)
1082
1137
 
@@ -1085,6 +1140,34 @@ readers can associate the trigger with its popup. If your custom trigger
1085
1140
  already specifies any of these ARIA props, your values win — the component
1086
1141
  only fills in what you haven’t set.
1087
1142
 
1143
+ The body is also named, via `aria-labelledby`, by whatever names the
1144
+ trigger — required for the `dialog` role and worth having on the others, so
1145
+ the popup is announced as “Alignment menu” rather than a bare “menu”. The
1146
+ name comes from `props.label` when you pass one (always text, and the same
1147
+ name the trigger takes), and otherwise from the trigger element itself. The
1148
+ component fills in the trigger’s `id` for this; a custom trigger with an
1149
+ `id` of its own keeps it, and the body points at that instead.
1150
+
1151
+ Two shapes end up with no name, both because the trigger has none to give:
1152
+
1153
+ - **A text input trigger.** `aria-labelledby` resolves a text input to its
1154
+ _value_, so pointing the popup at one would name it after whatever the
1155
+ user has typed. The component leaves the popup unnamed instead — for a
1156
+ searchable dropdown’s trigger (a text input by definition, generated or
1157
+ custom) and for a custom trigger that is itself an `<input>` or
1158
+ `<textarea>`. A text input _nested_ inside a custom trigger leaks its
1159
+ value the same way but isn’t detectable from the element alone, so it’s
1160
+ on you to pass `props.label` there.
1161
+ - **An empty generated trigger**: single-child syntax with no `props.label`
1162
+ renders a `<button>` with nothing in it, which has no accessible name of
1163
+ its own to lend. This matters most for `hasItems={false}`, where the
1164
+ popup is a `dialog` and an unnamed dialog is an ARIA violation.
1165
+
1166
+ In both cases the trigger is unnamed too, and `props.label` fixes both at
1167
+ once. (A custom trigger’s own `aria-label` names the trigger, but the popup
1168
+ can’t inherit it — `aria-labelledby` would resolve the input to its value —
1169
+ so pass `props.label` when you want the popup named as well.)
1170
+
1088
1171
  Item roles are filled in the same way when the body opens: items receive
1089
1172
  `role="option"` in a searchable (listbox) dropdown or `role="menuitem"` in
1090
1173
  a menu (always `menuitem` inside a submenu), and the `<ul>`/`<ol>` wrappers
@@ -1102,6 +1185,55 @@ which case the component leaves selection ARIA entirely to you, as with any
1102
1185
  ARIA you set. The persistent tint on the selected item is themeable via the
1103
1186
  `--uktdd-body-bg-color-selected` custom property.
1104
1187
 
1188
+ ### The searchable trigger is a combobox
1189
+
1190
+ A searchable dropdown’s trigger declares `role="combobox"` together with
1191
+ `aria-autocomplete="list"`, which describes what typing actually does here:
1192
+ suggestions appear in the popup, and nothing is completed inline in the
1193
+ input.
1194
+
1195
+ For a custom trigger, the role is filled in only when the trigger element
1196
+ is a single-line text input. A trigger that merely wraps an input is left
1197
+ alone. Same with a `<textarea>`, since a combobox is single-line. (A
1198
+ textarea trigger still counts as a text input for naming, so it can’t name
1199
+ the popup either.) As always, a `role` or `aria-autocomplete` you set
1200
+ yourself wins.
1201
+
1202
+ ### The active item and `aria-activedescendant`
1203
+
1204
+ DOM focus stays on the trigger for as long as the dropdown is open — the
1205
+ highlight moves via a `data-ukt-active` attribute rather than by moving
1206
+ focus. On its own that would make navigation silent to a screen reader, so
1207
+ the trigger carries `aria-activedescendant` pointing at the highlighted
1208
+ item, and it is kept in sync however the highlight moves: arrow keys,
1209
+ Home/End, typeahead, hover, diving into and out of submenus, and the
1210
+ reveal-on-open above. It’s removed when nothing is highlighted and when the
1211
+ dropdown closes, so it never dangles at an item that has unmounted.
1212
+
1213
+ Pointing at an item means items need ids, and the component mints them from
1214
+ the same `useId` behind the trigger and body ids, suffixed with the item’s
1215
+ **index path** — its index at each level from the root level down. So
1216
+ `<bodyId>-2-1` is the second item of the submenu belonging to the third
1217
+ top-level item. An item with an `id` of your own keeps it and is pointed at
1218
+ as-is.
1219
+
1220
+ Ids are minted when an item becomes active rather than in the open-time
1221
+ annotation pass, which means items rendered into an already-open body —
1222
+ async-loaded, or filtered by your own search — get one too, even though
1223
+ that pass doesn’t reach them.
1224
+
1225
+ Because an index path is a snapshot of a position, a generated id is
1226
+ **re-derived every time its item becomes active**, so filtering or
1227
+ reordering an open body doesn’t leave items holding ids that describe where
1228
+ they used to be. Treat generated ids as internal: they’re minted and
1229
+ consumed within a single open, and they move as your list moves. Ids you
1230
+ set yourself are never touched or recomputed.
1231
+
1232
+ The indices come from the navigable items, so a disabled item shifts the
1233
+ ids after it. If the highlighted item leaves the DOM entirely — filtered
1234
+ away while the body is open — the reference is cleared rather than left
1235
+ dangling.
1236
+
1105
1237
  Submenus and menubars extend the same pattern automatically:
1106
1238
 
1107
1239
  - parent items receive `aria-haspopup="menu"` and `aria-expanded`, and
@@ -41,6 +41,16 @@ export type Props = {
41
41
  */
42
42
  children: ChildrenTuple | ReactElement;
43
43
  className?: string;
44
+ /**
45
+ * Prevents the dropdown from opening via user interaction: pointer,
46
+ * Enter/Space, hover (props.openOnHover), typing in a custom trigger’s
47
+ * text input, and Menubar navigation onto it alike. The generated trigger
48
+ * carries the native disabled attribute; a custom trigger can be any
49
+ * element, so it receives aria-disabled instead. Inside a Menubar the
50
+ * member is skipped. A dropdown disabled while already open still closes
51
+ * normally (as does one started open with props.isOpenOnMount, which
52
+ * disabled doesn't override).
53
+ */
44
54
  disabled?: boolean;
45
55
  hasItems?: boolean;
46
56
  isOpenOnMount?: boolean;