@primitiv-ui/web 0.1.56 → 0.1.57

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Side-effect entry: importing this module defines the fourteen Dropdown
3
+ * elements (`<primitiv-dropdown>`, `-trigger`, `-content`, the items, groups,
4
+ * label, separator and indicator, and the `-sub` parts) on the global
5
+ * `customElements` registry.
6
+ *
7
+ * ```html
8
+ * <script type="module">import "@primitiv-ui/web/define/dropdown";</script>
9
+ * ```
10
+ */
11
+ import { defineDropdown } from "../dropdown/elements.js";
12
+ defineDropdown();
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import "./accordion.js";
6
6
  import "./collapsible.js";
7
+ import "./dropdown.js";
7
8
  import "./modal.js";
8
9
  import "./popover.js";
9
10
  import "./tabs.js";
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import "./accordion.js";
6
6
  import "./collapsible.js";
7
+ import "./dropdown.js";
7
8
  import "./modal.js";
8
9
  import "./popover.js";
9
10
  import "./tabs.js";
@@ -0,0 +1,63 @@
1
+ /**
2
+ * What a {@link DropdownController} asks of whoever hosts it. The controller
3
+ * owns behaviour and never owns state (RFC 0033 D28): the custom elements keep
4
+ * the open state in a reflected `open` attribute, the React adapter keeps it in
5
+ * React, and each answers these calls from wherever it keeps it.
6
+ */
7
+ export type DropdownControllerHost = {
8
+ /** The state the controller cannot see for itself. */
9
+ state(): {
10
+ /** Whether the menu is open. */
11
+ open: boolean;
12
+ };
13
+ /**
14
+ * Apply a change the user asked for: `open` is the state afterwards. A close
15
+ * can be reported more than once for one dismissal (the browser's and the
16
+ * press's), so the host decides what a repeat means.
17
+ */
18
+ change(open: boolean): void;
19
+ /** The trigger: Escape hands focus back to it, and a press on it is not an outside press. */
20
+ trigger(): HTMLElement | null;
21
+ /**
22
+ * The element to hand `showPopover({ source })`, which makes it the menu's
23
+ * implicit anchor for CSS anchor positioning. Optional: without it the menu
24
+ * is shown with no source, and positioning is the consumer's to wire.
25
+ */
26
+ source?(): HTMLElement | null;
27
+ };
28
+ /**
29
+ * The DOM behaviour of a dropdown menu over a `popover="auto"` panel (WAI-ARIA
30
+ * APG Menu Button): the browser owns the top layer and light dismiss, and this
31
+ * adds the rest.
32
+ *
33
+ * - **Sync** — {@link DropdownController.sync | `sync()`} shows or hides the
34
+ * menu to match the host's state, moving focus to its first enabled item as
35
+ * it opens unless focus is already inside. A host calls it whenever that
36
+ * state may have changed.
37
+ * - **Dismissal** — the browser hiding the menu is reported, and so is a press
38
+ * outside it (not on the trigger, and not inside any popover, so a submenu
39
+ * keeps its parent open).
40
+ * - **Keyboard** — the arrows, Home and End move through the enabled items of
41
+ * the menu holding focus (this one or an open submenu inside it), Enter and
42
+ * Space click the focused item, typing focuses the item it starts, and Escape
43
+ * closes the menu and hands focus back to the trigger.
44
+ *
45
+ * Opening from the trigger, and what an item does when chosen, are the host's.
46
+ */
47
+ export declare class DropdownController {
48
+ #private;
49
+ constructor(menu: HTMLElement, host: DropdownControllerHost);
50
+ /**
51
+ * Start listening. Connecting again while connected does nothing, so a host
52
+ * whose lifecycle runs twice never stacks handlers.
53
+ */
54
+ connect(): void;
55
+ /**
56
+ * Stop listening, and forget the menu was showing: a host disconnects when it
57
+ * leaves the document, which hides a popover with no event, so the next sync
58
+ * shows it again.
59
+ */
60
+ disconnect(): void;
61
+ /** Show or hide the menu to match the host's state. */
62
+ sync(): void;
63
+ }
@@ -0,0 +1,130 @@
1
+ import { MENU_ITEM_SELECTOR, MENU_TYPEAHEAD_RESET_MS, menuKeydown, menuTypeahead } from "@primitiv-ui/core";
2
+ /**
3
+ * The DOM behaviour of a dropdown menu over a `popover="auto"` panel (WAI-ARIA
4
+ * APG Menu Button): the browser owns the top layer and light dismiss, and this
5
+ * adds the rest.
6
+ *
7
+ * - **Sync** — {@link DropdownController.sync | `sync()`} shows or hides the
8
+ * menu to match the host's state, moving focus to its first enabled item as
9
+ * it opens unless focus is already inside. A host calls it whenever that
10
+ * state may have changed.
11
+ * - **Dismissal** — the browser hiding the menu is reported, and so is a press
12
+ * outside it (not on the trigger, and not inside any popover, so a submenu
13
+ * keeps its parent open).
14
+ * - **Keyboard** — the arrows, Home and End move through the enabled items of
15
+ * the menu holding focus (this one or an open submenu inside it), Enter and
16
+ * Space click the focused item, typing focuses the item it starts, and Escape
17
+ * closes the menu and hands focus back to the trigger.
18
+ *
19
+ * Opening from the trigger, and what an item does when chosen, are the host's.
20
+ */
21
+ export class DropdownController {
22
+ #menu;
23
+ #host;
24
+ #connection = null;
25
+ /** Whether this controller showed the menu and has not hidden it since. */
26
+ #shown = false;
27
+ /** The typeahead query so far, and the timer that lapses it. */
28
+ #query = "";
29
+ #lapse;
30
+ constructor(menu, host) {
31
+ this.#menu = menu;
32
+ this.#host = host;
33
+ }
34
+ /**
35
+ * Start listening. Connecting again while connected does nothing, so a host
36
+ * whose lifecycle runs twice never stacks handlers.
37
+ */
38
+ connect() {
39
+ if (this.#connection)
40
+ return;
41
+ this.#connection = new AbortController();
42
+ const { signal } = this.#connection;
43
+ this.#menu.addEventListener("toggle", (event) => this.#toggle(event), { signal });
44
+ this.#menu.addEventListener("keydown", (event) => this.#keydown(event), { signal });
45
+ document.addEventListener("click", (event) => this.#press(event), { signal });
46
+ }
47
+ /**
48
+ * Stop listening, and forget the menu was showing: a host disconnects when it
49
+ * leaves the document, which hides a popover with no event, so the next sync
50
+ * shows it again.
51
+ */
52
+ disconnect() {
53
+ this.#connection?.abort();
54
+ this.#connection = null;
55
+ this.#shown = false;
56
+ }
57
+ /** Show or hide the menu to match the host's state. */
58
+ sync() {
59
+ const { open } = this.#host.state();
60
+ if (open && !this.#shown) {
61
+ this.#shown = true;
62
+ const source = this.#host.source?.();
63
+ if (source)
64
+ this.#menu.showPopover({ source });
65
+ else
66
+ this.#menu.showPopover();
67
+ if (!this.#menu.contains(document.activeElement))
68
+ this.#menu.querySelector(MENU_ITEM_SELECTOR)?.focus();
69
+ }
70
+ else if (!open && this.#shown) {
71
+ this.#shown = false;
72
+ try {
73
+ this.#menu.hidePopover();
74
+ }
75
+ catch {
76
+ // Already hidden, or no longer a popover.
77
+ }
78
+ }
79
+ }
80
+ #toggle(event) {
81
+ if (event.newState !== "closed")
82
+ return;
83
+ this.#shown = false;
84
+ this.#host.change(false);
85
+ }
86
+ #keydown(event) {
87
+ // The items of the menu holding focus: this one, or an open submenu inside
88
+ // it — never those of a deeper, closed submenu. A key a script dispatches on
89
+ // the menu while focus is elsewhere searches this menu.
90
+ const scope = document.activeElement.closest("[popover]") ?? this.#menu;
91
+ const items = [...scope.querySelectorAll(MENU_ITEM_SELECTOR)].filter((item) => item.closest("[popover]") === scope);
92
+ const active = items.indexOf(document.activeElement);
93
+ const outcome = menuKeydown({ key: event.key, active, count: items.length });
94
+ if (outcome) {
95
+ event.preventDefault();
96
+ if ("focus" in outcome)
97
+ items[outcome.focus].focus();
98
+ else if ("activate" in outcome)
99
+ items[outcome.activate].click();
100
+ else {
101
+ this.#host.change(false);
102
+ this.#host.trigger()?.focus();
103
+ }
104
+ return;
105
+ }
106
+ const typed = menuTypeahead({
107
+ query: this.#query,
108
+ key: event.key,
109
+ labels: items.map((item) => item.textContent),
110
+ active,
111
+ });
112
+ if (!typed)
113
+ return;
114
+ this.#query = typed.query;
115
+ clearTimeout(this.#lapse);
116
+ this.#lapse = setTimeout(() => (this.#query = ""), MENU_TYPEAHEAD_RESET_MS);
117
+ if (typed.focus === null)
118
+ return;
119
+ event.preventDefault();
120
+ items[typed.focus].focus();
121
+ }
122
+ #press(event) {
123
+ if (!this.#host.state().open)
124
+ return;
125
+ const target = event.target;
126
+ if (this.#host.trigger()?.contains(target) || target.closest("[popover]"))
127
+ return;
128
+ this.#host.change(false);
129
+ }
130
+ }
@@ -0,0 +1,34 @@
1
+ import type { DropdownControllerHost } from "./DropdownController.js";
2
+ /**
3
+ * What a {@link DropdownSubController} asks of whoever hosts it: a dropdown
4
+ * host's calls, for the submenu, plus the key that closes it.
5
+ */
6
+ export type DropdownSubControllerHost = DropdownControllerHost & {
7
+ /** The arrow that closes the submenu: the one pointing back at its parent, `ArrowLeft` in a left-to-right menu. */
8
+ closeKey(): string;
9
+ };
10
+ /**
11
+ * The DOM behaviour of a submenu over a `popover="auto"` panel nested inside
12
+ * its parent menu. The parent's {@link DropdownController} moves focus through
13
+ * the submenu's items, since their keys bubble to it; this adds what is the
14
+ * submenu's own.
15
+ *
16
+ * - **Sync** — {@link DropdownSubController.sync | `sync()`} shows or hides the
17
+ * submenu to match the host's state, moving focus to its first enabled item
18
+ * as it opens.
19
+ * - **Dismissal** — the browser hiding the submenu is reported, and its close
20
+ * key closes it and hands focus back to its trigger, without the parent menu
21
+ * seeing the key.
22
+ *
23
+ * Opening from the sub-trigger (a click, a hover or the open key) is the host's.
24
+ */
25
+ export declare class DropdownSubController {
26
+ #private;
27
+ constructor(menu: HTMLElement, host: DropdownSubControllerHost);
28
+ /** Start listening. Connecting again while connected does nothing. */
29
+ connect(): void;
30
+ /** Stop listening, and forget the submenu was showing, as {@link DropdownController.disconnect} does. */
31
+ disconnect(): void;
32
+ /** Show or hide the submenu to match the host's state. */
33
+ sync(): void;
34
+ }
@@ -0,0 +1,78 @@
1
+ import { MENU_ITEM_SELECTOR } from "@primitiv-ui/core";
2
+ /**
3
+ * The DOM behaviour of a submenu over a `popover="auto"` panel nested inside
4
+ * its parent menu. The parent's {@link DropdownController} moves focus through
5
+ * the submenu's items, since their keys bubble to it; this adds what is the
6
+ * submenu's own.
7
+ *
8
+ * - **Sync** — {@link DropdownSubController.sync | `sync()`} shows or hides the
9
+ * submenu to match the host's state, moving focus to its first enabled item
10
+ * as it opens.
11
+ * - **Dismissal** — the browser hiding the submenu is reported, and its close
12
+ * key closes it and hands focus back to its trigger, without the parent menu
13
+ * seeing the key.
14
+ *
15
+ * Opening from the sub-trigger (a click, a hover or the open key) is the host's.
16
+ */
17
+ export class DropdownSubController {
18
+ #menu;
19
+ #host;
20
+ #connection = null;
21
+ /** Whether this controller showed the submenu and has not hidden it since. */
22
+ #shown = false;
23
+ constructor(menu, host) {
24
+ this.#menu = menu;
25
+ this.#host = host;
26
+ }
27
+ /** Start listening. Connecting again while connected does nothing. */
28
+ connect() {
29
+ if (this.#connection)
30
+ return;
31
+ this.#connection = new AbortController();
32
+ const { signal } = this.#connection;
33
+ this.#menu.addEventListener("toggle", (event) => this.#toggle(event), { signal });
34
+ this.#menu.addEventListener("keydown", (event) => this.#keydown(event), { signal });
35
+ }
36
+ /** Stop listening, and forget the submenu was showing, as {@link DropdownController.disconnect} does. */
37
+ disconnect() {
38
+ this.#connection?.abort();
39
+ this.#connection = null;
40
+ this.#shown = false;
41
+ }
42
+ /** Show or hide the submenu to match the host's state. */
43
+ sync() {
44
+ const { open } = this.#host.state();
45
+ if (open && !this.#shown) {
46
+ this.#shown = true;
47
+ const source = this.#host.source?.();
48
+ if (source)
49
+ this.#menu.showPopover({ source });
50
+ else
51
+ this.#menu.showPopover();
52
+ this.#menu.querySelector(MENU_ITEM_SELECTOR)?.focus();
53
+ }
54
+ else if (!open && this.#shown) {
55
+ this.#shown = false;
56
+ try {
57
+ this.#menu.hidePopover();
58
+ }
59
+ catch {
60
+ // Already hidden, or no longer a popover.
61
+ }
62
+ }
63
+ }
64
+ #toggle(event) {
65
+ if (event.newState !== "closed")
66
+ return;
67
+ this.#shown = false;
68
+ this.#host.change(false);
69
+ }
70
+ #keydown(event) {
71
+ if (event.key !== this.#host.closeKey())
72
+ return;
73
+ event.preventDefault();
74
+ event.stopPropagation();
75
+ this.#host.change(false);
76
+ this.#host.trigger()?.focus();
77
+ }
78
+ }
@@ -0,0 +1,174 @@
1
+ import { ElementBase, type ElementRegistry } from "../internal/element.js";
2
+ import { PrimitivRootElement } from "../internal/RootElement.js";
3
+ export type { ElementRegistry };
4
+ /**
5
+ * `<primitiv-dropdown>` — the state owner of a dropdown menu (WAI-ARIA APG
6
+ * Menu Button): a trigger that opens a menu of items. The menu is a
7
+ * `popover="auto"` in the top layer, shown with the trigger as its source,
8
+ * which makes the trigger its implicit anchor for CSS anchor positioning.
9
+ * Choosing an item — any item, a submenu's too — closes the menu and hands
10
+ * focus back to the trigger.
11
+ *
12
+ * The root finds its own parts and reconciles their ARIA from its reflected
13
+ * state whenever a part arrives, leaves, or changes an attribute the root
14
+ * reads (the lifecycle is `PrimitivRootElement`'s).
15
+ *
16
+ * @customElement primitiv-dropdown
17
+ * @summary The state owner of a dropdown menu.
18
+ * @attr {boolean} open - Whether the menu is open. Reflected: it changes as the user opens and dismisses it. Set it (or the `open` property) to open or close it from script.
19
+ * @fires {CustomEvent<{ open: boolean }>} primitiv-dropdown-change - Bubbles, cancelable, fired before a user-driven change is applied — the trigger, Escape, a press outside or a chosen item — with the open state afterwards. Call `preventDefault()` to veto it.
20
+ * @fires {CustomEvent<null>} primitiv-dropdown-select - Bubbles, cancelable, fired on the item part when an item is chosen, after a checkbox or radio item has changed. Call `preventDefault()` to keep the menu open.
21
+ * @fires {CustomEvent<{ checked: boolean }>} primitiv-dropdown-checked-change - Bubbles, cancelable, fired on a checkbox item before it changes, with its state afterwards. Call `preventDefault()` to veto it.
22
+ * @fires {CustomEvent<{ value: string }>} primitiv-dropdown-value-change - Bubbles, cancelable, fired on a radio group before its value changes, with the value afterwards. Call `preventDefault()` to veto it.
23
+ */
24
+ export declare class PrimitivDropdownElement extends PrimitivRootElement {
25
+ #private;
26
+ protected readonly controller: {
27
+ connect(): void;
28
+ disconnect(): void;
29
+ };
30
+ protected readonly reconcileOn: readonly string[];
31
+ /** Whether the menu is open, reflected as the boolean `open` attribute. */
32
+ get open(): boolean;
33
+ set open(next: boolean);
34
+ protected reconcile(): void;
35
+ }
36
+ /**
37
+ * `<primitiv-dropdown-sub>` — the state owner of a submenu, inside a dropdown's
38
+ * menu: a sub-trigger row that opens a nested menu on a click, a hover, or the
39
+ * arrow pointing away from its parent (ArrowRight, or ArrowLeft right to
40
+ * left), and closes it on the arrow back, or when the pointer moves to another
41
+ * row of the parent menu.
42
+ *
43
+ * @customElement primitiv-dropdown-sub
44
+ * @summary The state owner of a submenu.
45
+ * @attr {boolean} open - Whether the submenu is open. Reflected.
46
+ * @fires {CustomEvent<{ open: boolean }>} primitiv-dropdown-sub-change - Bubbles, cancelable, fired before a user-driven change is applied, with the open state afterwards. Call `preventDefault()` to veto it.
47
+ */
48
+ export declare class PrimitivDropdownSubElement extends PrimitivRootElement {
49
+ #private;
50
+ protected readonly controller: {
51
+ connect(): void;
52
+ disconnect(): void;
53
+ };
54
+ protected readonly reconcileOn: readonly string[];
55
+ /** Whether the submenu is open, reflected as the boolean `open` attribute. */
56
+ get open(): boolean;
57
+ set open(next: boolean);
58
+ protected reconcile(): void;
59
+ }
60
+ /**
61
+ * `<primitiv-dropdown-trigger>` — an interactive part. It decorates exactly
62
+ * one child the consumer writes, a `<button>`, which toggles the menu and is
63
+ * its menu button (`aria-haspopup`, `aria-expanded`, `aria-controls`).
64
+ *
65
+ * @customElement primitiv-dropdown-trigger
66
+ */
67
+ export declare class PrimitivDropdownTriggerElement extends ElementBase {
68
+ }
69
+ /**
70
+ * `<primitiv-dropdown-content>` — the menu itself. Structural: the root gives
71
+ * it an id, `role="menu"` and `popover="auto"` (write it in your markup too, so
72
+ * it is hidden before the script loads).
73
+ *
74
+ * @customElement primitiv-dropdown-content
75
+ */
76
+ export declare class PrimitivDropdownContentElement extends ElementBase {
77
+ }
78
+ /**
79
+ * `<primitiv-dropdown-item>` — an interactive part: one item. It decorates
80
+ * exactly one child the consumer writes, an `<li>` (or a link), which gets
81
+ * `role="menuitem"`, `tabindex="-1"` and `data-highlighted` while the pointer
82
+ * is over it.
83
+ *
84
+ * @customElement primitiv-dropdown-item
85
+ * @attr {boolean} disabled - Skipped by the keyboard, and does nothing when clicked (`aria-disabled` on the child).
86
+ */
87
+ export declare class PrimitivDropdownItemElement extends ElementBase {
88
+ }
89
+ /**
90
+ * `<primitiv-dropdown-checkbox-item>` — an item that toggles. Its child gets
91
+ * `role="menuitemcheckbox"` and `aria-checked`.
92
+ *
93
+ * @customElement primitiv-dropdown-checkbox-item
94
+ * @attr {boolean} checked - Whether it is checked; `checked="indeterminate"` for mixed. Reflected.
95
+ * @attr {boolean} disabled - As on an item.
96
+ */
97
+ export declare class PrimitivDropdownCheckboxItemElement extends ElementBase {
98
+ }
99
+ /**
100
+ * `<primitiv-dropdown-radio-group>` — a group of radio items, one of which is
101
+ * checked. Structural: `role="group"`.
102
+ *
103
+ * @customElement primitiv-dropdown-radio-group
104
+ * @attr {string} value - The checked item's value. Reflected.
105
+ */
106
+ export declare class PrimitivDropdownRadioGroupElement extends ElementBase {
107
+ }
108
+ /**
109
+ * `<primitiv-dropdown-radio-item>` — an item checked when its value is its
110
+ * group's. Its child gets `role="menuitemradio"` and `aria-checked`.
111
+ *
112
+ * @customElement primitiv-dropdown-radio-item
113
+ * @attr {string} value - The value it sets on its group.
114
+ * @attr {boolean} disabled - As on an item.
115
+ */
116
+ export declare class PrimitivDropdownRadioItemElement extends ElementBase {
117
+ }
118
+ /**
119
+ * `<primitiv-dropdown-item-indicator>` — the check or dot inside a checkbox or
120
+ * radio item, `hidden` while unchecked. Structural: it gets `data-state`.
121
+ *
122
+ * @customElement primitiv-dropdown-item-indicator
123
+ * @attr {boolean} force-mount - Keep it shown while unchecked, to animate it.
124
+ */
125
+ export declare class PrimitivDropdownItemIndicatorElement extends ElementBase {
126
+ }
127
+ /**
128
+ * `<primitiv-dropdown-group>` — a labelled group of items. Structural:
129
+ * `role="group"`, named by the label inside it.
130
+ *
131
+ * @customElement primitiv-dropdown-group
132
+ */
133
+ export declare class PrimitivDropdownGroupElement extends ElementBase {
134
+ }
135
+ /**
136
+ * `<primitiv-dropdown-label>` — a group's label, not an item. Structural: the
137
+ * root gives it an id.
138
+ *
139
+ * @customElement primitiv-dropdown-label
140
+ */
141
+ export declare class PrimitivDropdownLabelElement extends ElementBase {
142
+ }
143
+ /**
144
+ * `<primitiv-dropdown-separator>` — a rule between items. Structural:
145
+ * `role="separator"`.
146
+ *
147
+ * @customElement primitiv-dropdown-separator
148
+ */
149
+ export declare class PrimitivDropdownSeparatorElement extends ElementBase {
150
+ }
151
+ /**
152
+ * `<primitiv-dropdown-sub-trigger>` — the row that opens a submenu. It
153
+ * decorates exactly one child, an `<li>`, which gets `role="menuitem"` and the
154
+ * submenu's `aria-haspopup`, `aria-expanded` and `aria-controls`.
155
+ *
156
+ * @customElement primitiv-dropdown-sub-trigger
157
+ * @attr {boolean} disabled - It does not open the submenu.
158
+ */
159
+ export declare class PrimitivDropdownSubTriggerElement extends ElementBase {
160
+ }
161
+ /**
162
+ * `<primitiv-dropdown-sub-content>` — the submenu itself, inside the parent
163
+ * menu. Structural, as the content: an id, `role="menu"`, `popover="auto"`.
164
+ *
165
+ * @customElement primitiv-dropdown-sub-content
166
+ */
167
+ export declare class PrimitivDropdownSubContentElement extends ElementBase {
168
+ }
169
+ /**
170
+ * Define the fourteen Dropdown elements on `registry` (the global
171
+ * `customElements` by default). Safe to call more than once, and a no-op where
172
+ * there is no registry (a server render).
173
+ */
174
+ export declare function defineDropdown(registry?: ElementRegistry | undefined): void;