@acusti/dropdown 1.0.0-alpha.3 → 1.0.0-alpha.5

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
@@ -123,13 +123,17 @@ CSS-first sizing model:
123
123
  - The dropdown body is an anchored shell
124
124
  - The inner content region becomes scrollable only when the content exceeds
125
125
  the available space
126
- - Placement fallbacks are handled with `position-try-order: most-height`
126
+ - The body opens in its authored direction whenever it fits there, falling
127
+ back through the other placements in order and only squeezing-to-scroll
128
+ when it fits nowhere at its natural size (see
129
+ [Changing the Default Direction](#changing-the-default-direction))
127
130
 
128
131
  This means the dropdown tends to:
129
132
 
130
133
  - stay content-sized when the contents are small
131
- - expand to the available viewport space when more room is needed
132
- - become scrollable when the contents exceed that space
134
+ - open in the direction you asked for, as long as that side can hold it
135
+ - expand to the available viewport space, and become scrollable, only when
136
+ the contents exceed the room the chosen side has
133
137
 
134
138
  Internally, the dropdown renders:
135
139
 
@@ -292,16 +296,33 @@ type Props = {
292
296
  */
293
297
  tabIndex?: number;
294
298
  /**
295
- * Current value of the search input (requires isSearchable: true).
296
- * Used for controlled components and change detection.
299
+ * The dropdown’s controlled value. Pass a bare identifier when an item’s
300
+ * stored value and its displayed label are the same, or a { label, value }
301
+ * pair when they differ (e.g. a human-readable label shown for a stored
302
+ * id) — the same { label, value } shape onSubmitItem reports back. Used for
303
+ * change detection (skipping onSubmitItem when the already-selected item is
304
+ * re-submitted); the label is shown as the search input’s value when
305
+ * isSearchable is true. A bare identifier resolves to its label from the
306
+ * matching child’s data-ukt-value in the body, so children whose value
307
+ * and label differ need no explicit label.
297
308
  */
298
- value?: string;
309
+ value?: ItemValue | string;
299
310
  };
300
311
  ```
301
312
 
302
- ### Item Type
313
+ ### Item Types
314
+
315
+ Both types are exported alongside the component:
303
316
 
304
317
  ```ts
318
+ /**
319
+ * A { label, value } pair naming an item: value is the stored value (the
320
+ * item’s data-ukt-value) and label is its displayed text. Accepted by the
321
+ * value prop when an item’s value and label differ; also the shape of an
322
+ * Item’s path entries.
323
+ */
324
+ type ItemValue = { label: string; value: string };
325
+
305
326
  type Item = {
306
327
  element: HTMLElement | null;
307
328
  event: Event | React.SyntheticEvent<HTMLElement>;
@@ -310,7 +331,7 @@ type Item = {
310
331
  * Ancestor parent items from the root level down to the item’s
311
332
  * immediate parent. Empty for top-level items.
312
333
  */
313
- path: Array<{ label: string; value: string }>;
334
+ path: Array<ItemValue>;
314
335
  value: string;
315
336
  };
316
337
  ```
@@ -514,9 +535,9 @@ Example:
514
535
 
515
536
  ```css
516
537
  .settings-dropdown {
517
- --uktdd-body-position-area: bottom span-left;
538
+ --uktdd-body-position-area: block-end span-inline-start;
518
539
  --uktdd-body-position-try-fallbacks:
519
- --uktdd-top-right, --uktdd-bottom-left, --uktdd-top-left;
540
+ --uktdd-top-end, --uktdd-bottom-start, --uktdd-top-start;
520
541
  --uktdd-body-gap: 8px;
521
542
  }
522
543
 
@@ -528,6 +549,65 @@ Example:
528
549
  This approach keeps the public React API small while still allowing precise
529
550
  placement and sizing control when a product surface needs it.
530
551
 
552
+ ### Changing the Default Direction
553
+
554
+ `--uktdd-body-position-area` and `--uktdd-body-position-try-fallbacks`
555
+ always change together. `--uktdd-body-position-area` is the primary
556
+ placement, used whenever it fits in the viewport — that’s what actually
557
+ determines the direction a dropdown opens by default. The fallbacks list is
558
+ only consulted when the primary placement doesn’t fit, so overriding it
559
+ alone changes nothing in the common case where the primary already fits.
560
+ Overriding the primary alone works, but leaves behind a fallback cascade
561
+ tuned for the old primary — in a cramped viewport, the dropdown can fall
562
+ back toward the direction you just moved away from.
563
+
564
+ The four `@position-try` blocks the component ships (`--uktdd-top-start`,
565
+ `--uktdd-top-end`, `--uktdd-bottom-start`, `--uktdd-bottom-end`) are named
566
+ for the aligned edge, not the direction the body extends toward: `start`
567
+ opens with the body’s inline-start edge flush with the trigger’s
568
+ inline-start edge (extending toward inline-end), and `end` is the mirror
569
+ image. This also means they’re RTL-safe — `start`/`end` follow the
570
+ document’s writing direction the way `left`/`right` wouldn’t. The values
571
+ pair `block-start`/`block-end` with `span-inline-start`/`span-inline-end`
572
+ rather than `top`/`bottom` — `position-area` doesn’t allow mixing a
573
+ physical keyword on one axis with a logical one on the other — though
574
+ `block-start`/`block-end` read as `top`/`bottom` in the near-universal
575
+ horizontal-tb writing mode.
576
+
577
+ Pick the row matching the direction you want, and set both variables to its
578
+ pair:
579
+
580
+ | Direction | `--uktdd-body-position-area` | `--uktdd-body-position-try-fallbacks` |
581
+ | ----------------------------------- | ------------------------------- | ------------------------------------------------------------- |
582
+ | Bottom, start-aligned (the default) | `block-end span-inline-end` | `--uktdd-top-start, --uktdd-bottom-end, --uktdd-top-end` |
583
+ | Bottom, end-aligned | `block-end span-inline-start` | `--uktdd-top-end, --uktdd-bottom-start, --uktdd-top-start` |
584
+ | Top, start-aligned | `block-start span-inline-end` | `--uktdd-bottom-start, --uktdd-top-end, --uktdd-bottom-end` |
585
+ | Top, end-aligned | `block-start span-inline-start` | `--uktdd-bottom-end, --uktdd-top-start, --uktdd-bottom-start` |
586
+
587
+ For example, to make a dropdown open upward and end-aligned (useful for a
588
+ trigger pinned to the bottom of the viewport, near the inline-end edge):
589
+
590
+ ```css
591
+ .bottom-toolbar-dropdown {
592
+ --uktdd-body-position-area: block-start span-inline-start;
593
+ --uktdd-body-position-try-fallbacks:
594
+ --uktdd-bottom-end, --uktdd-top-start, --uktdd-bottom-start;
595
+ }
596
+ ```
597
+
598
+ See the [`DirectionRecipes` story][] for a live demo of all four.
599
+
600
+ [`DirectionRecipes` story]:
601
+ https://uikit.acusti.ca/?path=/docs/uikit-controls-Dropdown--docs#direction-recipes
602
+
603
+ The primary placement wins whenever the body fits there at its natural
604
+ size, then the fallbacks are tried in order — the body opens in the
605
+ direction you asked for as long as that side can hold it, and never flips
606
+ to the opposite side just because the viewport happens to have more empty
607
+ space there. Only when the body fits in none of those placements does it
608
+ size itself to the block-axis space its preferred side has and let the
609
+ content scroll, so it never overflows the viewport or covers the trigger.
610
+
531
611
  Use `--uktdd-body-gap` for the space between the trigger and the body. It
532
612
  is applied as a symmetric `margin-block`, so the gap lands on whichever
533
613
  side the body attaches to and auto-reverses when `position-try` flips the
@@ -548,9 +628,9 @@ the menu size itself from its contents.
548
628
 
549
629
  ```css
550
630
  .avatar-menu {
551
- --uktdd-body-position-area: bottom span-left;
631
+ --uktdd-body-position-area: block-end span-inline-start;
552
632
  --uktdd-body-position-try-fallbacks:
553
- --uktdd-top-right, --uktdd-top-left, --uktdd-bottom-right;
633
+ --uktdd-top-end, --uktdd-top-start, --uktdd-bottom-end;
554
634
  }
555
635
  ```
556
636
 
@@ -570,8 +650,9 @@ tile. A `center` tile is only as wide as the trigger, so a body wider than
570
650
  the trigger always overflows it — and `position-try` refuses to select a
571
651
  fallback that overflows, so a `bottom center` → `top center` setup never
572
652
  flips. `span-all` centers over the trigger identically but spans the full
573
- width, so it flips cleanly and clamps to the viewport near an edge; the
574
- package’s `position-try-order: most-height` then picks the taller side:
653
+ width, so it flips cleanly and clamps to the viewport near an edge. The
654
+ menu opens below by default and flips above only when it doesn’t fit below;
655
+ list the flipped fallback so that flip is available:
575
656
 
576
657
  ```css
577
658
  .centered-menu {
@@ -620,7 +701,7 @@ Submenu semantics:
620
701
 
621
702
  - Parent items disclose; they never submit. `onSubmitItem` fires only for
622
703
  leaf items, and the payload’s `path` reports the leaf’s ancestor parent
623
- items (see the [`Item` type](#item-type) above), so leaves with the same
704
+ items (see the [`Item` type](#item-types) above), so leaves with the same
624
705
  value in different submenus are distinguishable.
625
706
  - Submenus nest to arbitrary depth — a submenu body can itself contain
626
707
  nested dropdowns.
@@ -647,7 +728,7 @@ Like items, submenus are ultimately declared in the DOM. A nested
647
728
  ```html
648
729
  <li data-ukt-item aria-haspopup="menu" aria-expanded="false">
649
730
  Align
650
- <ul data-ukt-submenu role="menu">
731
+ <ul data-ukt-submenu role="menu" popover="manual">
651
732
 
652
733
  </ul>
653
734
  </li>
@@ -659,6 +740,9 @@ compiles to the attribute form. Rules for direct authoring:
659
740
 
660
741
  - the `data-ukt-submenu` element must be a descendant of its parent item
661
742
  element
743
+ - the submenu discloses in the top layer as a popover; the component sets
744
+ `popover="manual"`, and the engine adds it if your markup omits it, so
745
+ you don’t manage `display` (visibility follows the popover open state)
662
746
  - in a level whose items include a submenu, mark all of that level’s items
663
747
  explicitly with `data-ukt-item` or `data-ukt-value`; purely flat levels —
664
748
  including flat submenu bodies — fall back to the automatic item-detection
@@ -686,8 +770,12 @@ already discloses on hover intent — see [Submenus](#submenus) — so
686
770
 
687
771
  Submenus reuse the dropdown’s anchor-positioning layout model: the expanded
688
772
  parent item is the anchor, and the submenu opens at its inline-end,
689
- top-aligned, falling back to the opposite side when out of bounds.
690
- Placement custom properties follow the established naming scheme:
773
+ top-aligned, falling back to the opposite side when out of bounds. Like the
774
+ body, a submenu renders in the top layer (`popover="manual"`), so its
775
+ containing block is the viewport — no ancestor (including the body’s own
776
+ top-layer box) can capture it and clip or shift its placement, which keeps
777
+ it flush to the parent item’s edge across browsers. Placement custom
778
+ properties follow the established naming scheme:
691
779
 
692
780
  - `--uktdd-submenu-position-area` (default: `inline-end span-block-end`)
693
781
  - `--uktdd-submenu-position-try-fallbacks`
@@ -709,18 +797,12 @@ theming menu colors.
709
797
 
710
798
  The active path is fully styleable: `data-ukt-active` marks one item per
711
799
  open level (a parent item stays active while you navigate its submenu), and
712
- `aria-expanded="true"` marks open parent items. The default submenu
713
- visibility rule, shown here for reference:
714
-
715
- ```css
716
- [data-ukt-submenu] {
717
- display: none;
718
- }
719
-
720
- [aria-expanded='true'] > [data-ukt-submenu] {
721
- display: block;
722
- }
723
- ```
800
+ `aria-expanded="true"` marks open parent items. Submenu visibility is not a
801
+ `display` rule you own: each submenu is a `popover="manual"` element the
802
+ component shows and hides (via `showPopover`/`hidePopover`) as its parent
803
+ item expands and collapses, so it discloses in the top layer and the UA’s
804
+ `:popover-open` state controls whether it renders. Don’t set `display` on
805
+ `[data-ukt-submenu]` — it would fight that state.
724
806
 
725
807
  Following macOS, the default styles distinguish the deepest active item —
726
808
  the one keyboard input operates on — from its ancestors on the active path.
@@ -879,6 +961,23 @@ readers can associate the trigger with its popup. If your custom trigger
879
961
  already specifies any of these ARIA props, your values win — the component
880
962
  only fills in what you haven’t set.
881
963
 
964
+ Item roles are filled in the same way when the body opens: items receive
965
+ `role="option"` in a searchable (listbox) dropdown or `role="menuitem"` in
966
+ a menu (always `menuitem` inside a submenu), and the `<ul>`/`<ol>` wrappers
967
+ around them receive `role="presentation"` so the listbox or menu owns its
968
+ items directly rather than through an intervening list. A natively
969
+ interactive item (a button, link, or input) or one with a role you set
970
+ yourself keeps its own role.
971
+
972
+ Opening also reveals the current selection: the item matching `props.value`
973
+ becomes the active item (so keyboard navigation starts from the current
974
+ selection) and is scrolled into view. In a searchable (listbox) dropdown it
975
+ additionally receives `aria-selected="true"` (`aria-selected` isn’t valid
976
+ on a `menuitem`) — unless you author `aria-selected` on items yourself, in
977
+ which case the component leaves selection ARIA entirely to you, as with any
978
+ ARIA you set. The persistent tint on the selected item is themeable via the
979
+ `--uktdd-body-bg-color-selected` custom property.
980
+
882
981
  Submenus and menubars extend the same pattern automatically:
883
982
 
884
983
  - parent items receive `aria-haspopup="menu"` and `aria-expanded`, and
@@ -7,10 +7,16 @@ export type Item = {
7
7
  * Ancestor parent items from the root level down to the item’s
8
8
  * immediate parent. Empty for top-level items.
9
9
  */
10
- path: Array<ItemPathEntry>;
10
+ path: Array<ItemValue>;
11
11
  value: string;
12
12
  };
13
- export type ItemPathEntry = {
13
+ /**
14
+ * A { label, value } pair naming an item: `value` is the stored value (the
15
+ * item’s data-ukt-value) and `label` is its displayed text. Accepted by the
16
+ * value prop when an item’s value and label differ; also the shape of an
17
+ * Item’s path entries.
18
+ */
19
+ export type ItemValue = {
14
20
  label: string;
15
21
  value: string;
16
22
  };
@@ -82,10 +88,18 @@ export type Props = {
82
88
  */
83
89
  tabIndex?: number;
84
90
  /**
85
- * Used as search input’s value if props.isSearchable === true
86
- * Used to determine if value has changed to avoid triggering onSubmitItem if not
91
+ * The dropdown’s controlled value. Pass a bare identifier when an item’s
92
+ * stored value and its displayed label are the same, or a { label, value }
93
+ * pair when they differ (e.g. a human-readable label shown for a stored
94
+ * id) — the same { label, value } shape onSubmitItem reports back. The
95
+ * value determines whether the value has changed, to avoid triggering
96
+ * onSubmitItem when the already-selected item is re-submitted; the label is
97
+ * used as the search input’s value when props.isSearchable === true. A bare
98
+ * identifier is resolved to its label from the matching child’s
99
+ * data-ukt-value in the body — so children whose value and label differ
100
+ * need no explicit label; a { label, value } pair states it.
87
101
  */
88
- value?: string;
102
+ value?: ItemValue | string;
89
103
  };
90
104
  type ChildrenTuple = [ReactNode, ReactNode] | readonly [ReactNode, ReactNode];
91
105
  type MaybeHTMLElement = HTMLElement | null;