@typed/ui 1.0.0-beta.1 → 1.0.0-beta.11
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/LICENSE +21 -0
- package/README.md +54 -10
- package/dist/Alert.d.ts +72 -0
- package/dist/Alert.d.ts.map +1 -0
- package/dist/Alert.js +40 -0
- package/dist/Button.d.ts +100 -0
- package/dist/Button.d.ts.map +1 -0
- package/dist/Button.js +42 -0
- package/dist/Carousel.d.ts +346 -0
- package/dist/Carousel.d.ts.map +1 -0
- package/dist/Carousel.js +264 -0
- package/dist/Checkbox.d.ts +168 -0
- package/dist/Checkbox.d.ts.map +1 -0
- package/dist/Checkbox.js +146 -0
- package/dist/Collection.d.ts +254 -0
- package/dist/Collection.d.ts.map +1 -0
- package/dist/Collection.js +218 -0
- package/dist/Combobox.d.ts +367 -0
- package/dist/Combobox.d.ts.map +1 -0
- package/dist/Combobox.js +300 -0
- package/dist/Composite.d.ts +823 -0
- package/dist/Composite.d.ts.map +1 -0
- package/dist/Composite.js +615 -0
- package/dist/Dialog.d.ts +544 -0
- package/dist/Dialog.d.ts.map +1 -0
- package/dist/Dialog.js +357 -0
- package/dist/Disclosure.d.ts +219 -0
- package/dist/Disclosure.d.ts.map +1 -0
- package/dist/Disclosure.js +128 -0
- package/dist/Dom/Events.d.ts +122 -0
- package/dist/Dom/Events.d.ts.map +1 -0
- package/dist/Dom/Events.js +192 -0
- package/dist/Dom/Props.d.ts +161 -0
- package/dist/Dom/Props.d.ts.map +1 -0
- package/dist/Dom/Props.js +110 -0
- package/dist/Dom/Refs.d.ts +58 -0
- package/dist/Dom/Refs.d.ts.map +1 -0
- package/dist/Dom/Refs.js +61 -0
- package/dist/Dom/Render.d.ts +59 -0
- package/dist/Dom/Render.d.ts.map +1 -0
- package/dist/Dom/Render.js +71 -0
- package/dist/Dom/Types.d.ts +570 -0
- package/dist/Dom/Types.d.ts.map +1 -0
- package/dist/Dom/Types.js +1 -0
- package/dist/Dom/index.d.ts +20 -0
- package/dist/Dom/index.d.ts.map +1 -0
- package/dist/Dom/index.js +8 -0
- package/dist/Dom.d.ts +14 -0
- package/dist/Dom.d.ts.map +1 -0
- package/dist/Dom.js +13 -0
- package/dist/Focusable.d.ts +85 -0
- package/dist/Focusable.d.ts.map +1 -0
- package/dist/Focusable.js +35 -0
- package/dist/Form.d.ts +1727 -0
- package/dist/Form.d.ts.map +1 -0
- package/dist/Form.js +1142 -0
- package/dist/Grid.d.ts +388 -0
- package/dist/Grid.d.ts.map +1 -0
- package/dist/Grid.js +284 -0
- package/dist/Group.d.ts +128 -0
- package/dist/Group.d.ts.map +1 -0
- package/dist/Group.js +71 -0
- package/dist/Heading.d.ts +87 -0
- package/dist/Heading.d.ts.map +1 -0
- package/dist/Heading.js +58 -0
- package/dist/Hovercard.d.ts +297 -0
- package/dist/Hovercard.d.ts.map +1 -0
- package/dist/Hovercard.js +188 -0
- package/dist/HttpRouter.d.ts +129 -6
- package/dist/HttpRouter.d.ts.map +1 -1
- package/dist/HttpRouter.js +198 -55
- package/dist/Link.d.ts +63 -28
- package/dist/Link.d.ts.map +1 -1
- package/dist/Link.js +84 -37
- package/dist/Listbox.d.ts +305 -0
- package/dist/Listbox.d.ts.map +1 -0
- package/dist/Listbox.js +245 -0
- package/dist/Menu.d.ts +663 -0
- package/dist/Menu.d.ts.map +1 -0
- package/dist/Menu.js +569 -0
- package/dist/Menubar.d.ts +249 -0
- package/dist/Menubar.d.ts.map +1 -0
- package/dist/Menubar.js +207 -0
- package/dist/Meter.d.ts +157 -0
- package/dist/Meter.d.ts.map +1 -0
- package/dist/Meter.js +87 -0
- package/dist/NativeDetails.d.ts +41 -0
- package/dist/NativeDetails.d.ts.map +1 -0
- package/dist/NativeDetails.js +40 -0
- package/dist/NativeDialog.d.ts +66 -0
- package/dist/NativeDialog.d.ts.map +1 -0
- package/dist/NativeDialog.js +88 -0
- package/dist/NativePopover.d.ts +43 -0
- package/dist/NativePopover.d.ts.map +1 -0
- package/dist/NativePopover.js +84 -0
- package/dist/Popover.d.ts +240 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/Popover.js +140 -0
- package/dist/RadioGroup.d.ts +330 -0
- package/dist/RadioGroup.d.ts.map +1 -0
- package/dist/RadioGroup.js +241 -0
- package/dist/Role.d.ts +64 -0
- package/dist/Role.d.ts.map +1 -0
- package/dist/Role.js +27 -0
- package/dist/Select.d.ts +418 -0
- package/dist/Select.d.ts.map +1 -0
- package/dist/Select.js +357 -0
- package/dist/Separator.d.ts +58 -0
- package/dist/Separator.d.ts.map +1 -0
- package/dist/Separator.js +32 -0
- package/dist/Slider.d.ts +141 -0
- package/dist/Slider.d.ts.map +1 -0
- package/dist/Slider.js +101 -0
- package/dist/SpinButton.d.ts +141 -0
- package/dist/SpinButton.d.ts.map +1 -0
- package/dist/SpinButton.js +101 -0
- package/dist/Storybook.d.ts +76 -0
- package/dist/Storybook.d.ts.map +1 -0
- package/dist/Storybook.js +102 -0
- package/dist/Switch.d.ts +148 -0
- package/dist/Switch.d.ts.map +1 -0
- package/dist/Switch.js +110 -0
- package/dist/Tab.d.ts +26 -0
- package/dist/Tab.d.ts.map +1 -0
- package/dist/Tab.js +25 -0
- package/dist/Tabs.d.ts +411 -0
- package/dist/Tabs.d.ts.map +1 -0
- package/dist/Tabs.js +262 -0
- package/dist/Toolbar.d.ts +248 -0
- package/dist/Toolbar.d.ts.map +1 -0
- package/dist/Toolbar.js +187 -0
- package/dist/Tooltip.d.ts +296 -0
- package/dist/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip.js +172 -0
- package/dist/Tree.d.ts +405 -0
- package/dist/Tree.d.ts.map +1 -0
- package/dist/Tree.js +333 -0
- package/dist/TreeGrid.d.ts +426 -0
- package/dist/TreeGrid.d.ts.map +1 -0
- package/dist/TreeGrid.js +308 -0
- package/dist/VisuallyHidden.d.ts +68 -0
- package/dist/VisuallyHidden.d.ts.map +1 -0
- package/dist/VisuallyHidden.js +44 -0
- package/dist/WindowSplitter.d.ts +336 -0
- package/dist/WindowSplitter.d.ts.map +1 -0
- package/dist/WindowSplitter.js +305 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -0
- package/package.json +48 -20
- package/src/HttpRouter.test.ts +0 -294
- package/src/HttpRouter.ts +0 -168
- package/src/Link.test.ts +0 -84
- package/src/Link.ts +0 -107
- package/src/index.ts +0 -2
package/dist/Menu.js
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu coordinates trigger, manual native popover content, registered items, and nested submenu
|
|
3
|
+
* state. Keyboard handling covers opening, Escape, directional parent/child traversal, roving
|
|
4
|
+
* focus, and typeahead without synthetic events.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* The module keeps policy, state transitions, and DOM rendering separable so applications can use
|
|
8
|
+
* the state and pure operations without mounting UI, or supply custom hosts without replacing native
|
|
9
|
+
* events and browser-owned focus.
|
|
10
|
+
*
|
|
11
|
+
* Learn the interaction in the [Menu guide](/explore/ui-menu).
|
|
12
|
+
*
|
|
13
|
+
* @since 1.0.0
|
|
14
|
+
* @category modules
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
import * as Effect from "effect/Effect";
|
|
18
|
+
import * as Scope from "effect/Scope";
|
|
19
|
+
import * as Schema from "effect/Schema";
|
|
20
|
+
import { RefSubject } from "@typed/fx";
|
|
21
|
+
import { EventHandler, html, } from "@typed/template";
|
|
22
|
+
import * as Collection from "./Collection.js";
|
|
23
|
+
import * as Composite from "./Composite.js";
|
|
24
|
+
import * as Dom from "./Dom.js";
|
|
25
|
+
import * as NativePopover from "./NativePopover.js";
|
|
26
|
+
const invokers = new WeakMap();
|
|
27
|
+
const submenuOwners = new WeakMap();
|
|
28
|
+
/**
|
|
29
|
+
* Effect Schema used by makeState to encode, decode, and hydrate Menu state.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* import * as Schema from "effect/Schema";
|
|
35
|
+
* import * as Menu from "@typed/ui/Menu";
|
|
36
|
+
*
|
|
37
|
+
* const decodeState = Schema.decodeUnknownEffect(Menu.StateSchema);
|
|
38
|
+
* ```
|
|
39
|
+
* @since 1.0.0
|
|
40
|
+
* @category Popup state
|
|
41
|
+
*/
|
|
42
|
+
export const StateSchema = Schema.Struct({
|
|
43
|
+
id: Schema.String,
|
|
44
|
+
open: Schema.Boolean,
|
|
45
|
+
activeId: Schema.NullOr(Schema.String),
|
|
46
|
+
orientation: Schema.Literals(["vertical"]),
|
|
47
|
+
loop: Schema.Boolean,
|
|
48
|
+
rtl: Schema.Boolean,
|
|
49
|
+
virtualFocus: Schema.Boolean,
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Creates hydrated Menu state. The caller supplies id; open defaults false, activeId null, loop
|
|
53
|
+
* true, and orientation vertical.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* The returned Effect creates the RefSubject when run. That state is renderer-independent;
|
|
57
|
+
* collection registrations belong to the separate Scope that runs register or ref, not to state
|
|
58
|
+
* creation.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* import * as Effect from "effect/Effect";
|
|
63
|
+
* import * as Menu from "@typed/ui/Menu";
|
|
64
|
+
*
|
|
65
|
+
* const program = Effect.scoped(
|
|
66
|
+
* Effect.gen(function* () {
|
|
67
|
+
* const state = yield* Menu.makeState({ id: "file-menu" });
|
|
68
|
+
* const collection = yield* Menu.makeCollection();
|
|
69
|
+
* return { state: yield* state, collection: yield* collection };
|
|
70
|
+
* }),
|
|
71
|
+
* );
|
|
72
|
+
* ```
|
|
73
|
+
* @since 1.0.0
|
|
74
|
+
* @category Popup state
|
|
75
|
+
*/
|
|
76
|
+
export function makeState(initial) {
|
|
77
|
+
return RefSubject.hydrate(StateSchema, {
|
|
78
|
+
id: initial.id,
|
|
79
|
+
open: initial.open ?? false,
|
|
80
|
+
activeId: initial.activeId ?? null,
|
|
81
|
+
orientation: "vertical",
|
|
82
|
+
loop: initial.loop ?? true,
|
|
83
|
+
rtl: false,
|
|
84
|
+
virtualFocus: false,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Creates a scoped Collection for Menu items.
|
|
89
|
+
*
|
|
90
|
+
* @remarks
|
|
91
|
+
* The returned Effect allocates the RefSubject in the caller's Scope. Each later registration is
|
|
92
|
+
* owned by the Scope that runs register, independently of this construction Effect.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* import * as Effect from "effect/Effect";
|
|
97
|
+
* import * as Menu from "@typed/ui/Menu";
|
|
98
|
+
*
|
|
99
|
+
* const program = Effect.scoped(
|
|
100
|
+
* Effect.gen(function* () {
|
|
101
|
+
* const collection = yield* Menu.makeCollection();
|
|
102
|
+
* return yield* collection;
|
|
103
|
+
* }),
|
|
104
|
+
* );
|
|
105
|
+
* ```
|
|
106
|
+
* @since 1.0.0
|
|
107
|
+
* @category Command registration
|
|
108
|
+
*/
|
|
109
|
+
export const makeCollection = Collection.makeState;
|
|
110
|
+
/**
|
|
111
|
+
* Updates only the menu's explicit open state.
|
|
112
|
+
*
|
|
113
|
+
* @remarks
|
|
114
|
+
* The operation exposes Menu's transition directly so callers can compose it in Effect programs
|
|
115
|
+
* and native event handlers.
|
|
116
|
+
*
|
|
117
|
+
* @since 1.0.0
|
|
118
|
+
* @category Popup state
|
|
119
|
+
*/
|
|
120
|
+
export function setOpen(state, open) {
|
|
121
|
+
return RefSubject.update(state, (value) => ({ ...value, open }));
|
|
122
|
+
}
|
|
123
|
+
function triggerProps(options) {
|
|
124
|
+
const id = RefSubject.map(options.state, (state) => state.id);
|
|
125
|
+
const open = RefSubject.map(options.state, (state) => state.open);
|
|
126
|
+
return () => ({
|
|
127
|
+
type: "button",
|
|
128
|
+
popovertarget: id,
|
|
129
|
+
popovertargetaction: "toggle",
|
|
130
|
+
"aria-haspopup": "menu",
|
|
131
|
+
"aria-expanded": open,
|
|
132
|
+
onkeydown: EventHandler.make((event) => {
|
|
133
|
+
if (event.key !== "ArrowDown")
|
|
134
|
+
return;
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
Dom.currentTarget(event).click();
|
|
137
|
+
}),
|
|
138
|
+
ref: Dom.composeRefs(options.state, invokerRef(options.state)),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function invokerRef(state) {
|
|
142
|
+
return Effect.fn(function* (element) {
|
|
143
|
+
invokers.set(state, element);
|
|
144
|
+
const scope = yield* Effect.scope;
|
|
145
|
+
yield* Scope.addFinalizer(scope, Effect.sync(() => {
|
|
146
|
+
if (invokers.get(state) === element)
|
|
147
|
+
invokers.delete(state);
|
|
148
|
+
}));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Renders a native button targeting menu popover content and opens the menu on ArrowDown.
|
|
153
|
+
*
|
|
154
|
+
* @remarks
|
|
155
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
156
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
157
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
158
|
+
*
|
|
159
|
+
* @since 1.0.0
|
|
160
|
+
* @category Opening a menu
|
|
161
|
+
*/
|
|
162
|
+
export function Trigger(options, host) {
|
|
163
|
+
return Dom.renderHost()(options, host, triggerProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Consumer-facing alias of the canonical Menu component with identical behavior and lifetime.
|
|
167
|
+
*
|
|
168
|
+
* @remarks
|
|
169
|
+
* The alias acquires nothing. Rendering it has exactly the canonical component's Scope and DOM
|
|
170
|
+
* ownership contract.
|
|
171
|
+
*
|
|
172
|
+
* @since 1.0.0
|
|
173
|
+
* @category Opening a menu
|
|
174
|
+
*/
|
|
175
|
+
export const Button = Trigger;
|
|
176
|
+
function contentProps(options) {
|
|
177
|
+
const id = RefSubject.map(options.state, (state) => state.id);
|
|
178
|
+
let typeahead = { value: "", updatedAt: 0 };
|
|
179
|
+
let restoreParentFocus = false;
|
|
180
|
+
let restoreOwnerFocus = false;
|
|
181
|
+
let restoreInvokerFocus = false;
|
|
182
|
+
let invoker;
|
|
183
|
+
const onkeydown = options.collection === undefined
|
|
184
|
+
? undefined
|
|
185
|
+
: EventHandler.make(Effect.fn(function* (event) {
|
|
186
|
+
const direction = Composite.keyMove(event, { orientation: "vertical" });
|
|
187
|
+
if (direction !== undefined) {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
yield* Composite.moveAndFocus({ state: options.state, collection: options.collection, includeDisabled: true }, direction);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (event.key === "ArrowRight") {
|
|
193
|
+
const activeId = (yield* options.state).activeId;
|
|
194
|
+
const item = activeId === null
|
|
195
|
+
? undefined
|
|
196
|
+
: (yield* options.collection).find((item) => item.id === activeId);
|
|
197
|
+
const element = item?.submenu === true ? item.element : undefined;
|
|
198
|
+
const click = element === undefined ? undefined : Reflect.get(element, "click");
|
|
199
|
+
if (typeof click === "function") {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
yield* Effect.sync(() => click.call(element));
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (event.key === "ArrowLeft") {
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
const element = Dom.currentTarget(event);
|
|
208
|
+
const hidePopover = Reflect.get(element, "hidePopover");
|
|
209
|
+
if (typeof hidePopover === "function") {
|
|
210
|
+
if (options.parent === undefined && submenuOwners.has(options.state))
|
|
211
|
+
restoreOwnerFocus = true;
|
|
212
|
+
else if (options.parent === undefined)
|
|
213
|
+
restoreInvokerFocus = true;
|
|
214
|
+
else
|
|
215
|
+
restoreParentFocus = true;
|
|
216
|
+
yield* Effect.sync(() => hidePopover.call(element));
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
yield* setOpen(options.state, false);
|
|
220
|
+
if (options.parent === undefined && submenuOwners.has(options.state)) {
|
|
221
|
+
yield* submenuOwners.get(options.state)?.onArrowLeft ?? Effect.void;
|
|
222
|
+
}
|
|
223
|
+
else if (options.parent === undefined)
|
|
224
|
+
yield* Composite.focusElement(invoker ?? invokers.get(options.state));
|
|
225
|
+
else {
|
|
226
|
+
yield* RefSubject.update(options.parent.state, (state) => ({
|
|
227
|
+
...state,
|
|
228
|
+
activeId: options.parent.triggerId,
|
|
229
|
+
}));
|
|
230
|
+
yield* Composite.focusActive({
|
|
231
|
+
state: options.parent.state,
|
|
232
|
+
collection: options.parent.collection,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (event.key === "Escape") {
|
|
239
|
+
restoreInvokerFocus = true;
|
|
240
|
+
yield* setOpen(options.state, false);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (event.key === "Tab") {
|
|
244
|
+
yield* setOpen(options.state, false);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
248
|
+
const activeId = (yield* options.state).activeId;
|
|
249
|
+
const element = activeId === null
|
|
250
|
+
? undefined
|
|
251
|
+
: (yield* options.collection).find((item) => item.id === activeId)?.element;
|
|
252
|
+
const click = element === undefined ? undefined : Reflect.get(element, "click");
|
|
253
|
+
if (typeof click === "function") {
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
yield* Effect.sync(() => click.call(element));
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const key = Composite.typeaheadKey(event);
|
|
260
|
+
if (key === null)
|
|
261
|
+
return;
|
|
262
|
+
typeahead = Composite.updateTypeaheadBuffer(typeahead, key, Date.now());
|
|
263
|
+
const activeId = Composite.typeaheadFrom(yield* options.collection, typeahead.value, (yield* options.state).activeId, undefined, true);
|
|
264
|
+
if (activeId === null)
|
|
265
|
+
return;
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
yield* RefSubject.update(options.state, (state) => ({ ...state, activeId }));
|
|
268
|
+
yield* Composite.focusActive({ state: options.state, collection: options.collection });
|
|
269
|
+
}));
|
|
270
|
+
const restoreFocus = Effect.gen(function* () {
|
|
271
|
+
if (restoreParentFocus && options.parent !== undefined) {
|
|
272
|
+
restoreParentFocus = false;
|
|
273
|
+
yield* RefSubject.update(options.parent.state, (state) => ({
|
|
274
|
+
...state,
|
|
275
|
+
activeId: options.parent.triggerId,
|
|
276
|
+
}));
|
|
277
|
+
yield* Composite.focusActive({
|
|
278
|
+
state: options.parent.state,
|
|
279
|
+
collection: options.parent.collection,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (restoreOwnerFocus) {
|
|
283
|
+
restoreOwnerFocus = false;
|
|
284
|
+
yield* submenuOwners.get(options.state)?.onArrowLeft ?? Effect.void;
|
|
285
|
+
}
|
|
286
|
+
if (restoreInvokerFocus) {
|
|
287
|
+
restoreInvokerFocus = false;
|
|
288
|
+
yield* Composite.focusElement(invoker ?? invokers.get(options.state));
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
const toggle = EventHandler.make(Effect.fn(function* (event) {
|
|
292
|
+
const open = Dom.toggleState(event) === "open";
|
|
293
|
+
const source = Reflect.get(event, "source");
|
|
294
|
+
if (isElement(source))
|
|
295
|
+
invoker = source;
|
|
296
|
+
const current = yield* options.state;
|
|
297
|
+
if (current.open === open) {
|
|
298
|
+
if (!open)
|
|
299
|
+
yield* restoreFocus;
|
|
300
|
+
return current;
|
|
301
|
+
}
|
|
302
|
+
if (open && options.collection !== undefined) {
|
|
303
|
+
const activeId = Composite.moveActiveId(yield* options.collection, current, "first", true);
|
|
304
|
+
const next = yield* RefSubject.update(options.state, (state) => ({
|
|
305
|
+
...state,
|
|
306
|
+
open,
|
|
307
|
+
activeId,
|
|
308
|
+
}));
|
|
309
|
+
yield* Composite.focusActive({ state: options.state, collection: options.collection });
|
|
310
|
+
yield* Composite.scrollActive({ state: options.state, collection: options.collection });
|
|
311
|
+
return next;
|
|
312
|
+
}
|
|
313
|
+
const next = yield* setOpen(options.state, open);
|
|
314
|
+
if (!open)
|
|
315
|
+
yield* restoreFocus;
|
|
316
|
+
return next;
|
|
317
|
+
}));
|
|
318
|
+
return ({ property }) => ({
|
|
319
|
+
id,
|
|
320
|
+
role: "menu",
|
|
321
|
+
popover: "manual",
|
|
322
|
+
"aria-label": property("label", undefined),
|
|
323
|
+
onkeydown,
|
|
324
|
+
ontoggle: toggle,
|
|
325
|
+
ref: Dom.composeRefs(options.state, NativePopover.ref(options.state)),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
function isElement(value) {
|
|
329
|
+
return typeof value === "object" && value !== null && "nodeType" in value;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Renders manual-popover menu content and coordinates focus, typeahead, Escape, and nested-menu
|
|
333
|
+
* traversal.
|
|
334
|
+
*
|
|
335
|
+
* @remarks
|
|
336
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
337
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
338
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
339
|
+
*
|
|
340
|
+
* @since 1.0.0
|
|
341
|
+
* @category Menu surface
|
|
342
|
+
*/
|
|
343
|
+
export function Content(options, host) {
|
|
344
|
+
return Dom.renderHost()(options, host, contentProps(options), options.content, (props, content) => {
|
|
345
|
+
return html `<div ...${props}>${content}</div>`;
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Consumer-facing alias of the canonical Menu component with identical behavior and lifetime.
|
|
350
|
+
*
|
|
351
|
+
* @remarks
|
|
352
|
+
* The alias acquires nothing. Rendering it has exactly the canonical component's Scope and DOM
|
|
353
|
+
* ownership contract.
|
|
354
|
+
*
|
|
355
|
+
* @since 1.0.0
|
|
356
|
+
* @category Menu surface
|
|
357
|
+
*/
|
|
358
|
+
export const Menu = Content;
|
|
359
|
+
function itemProps(options) {
|
|
360
|
+
const closesOnActivate = options.role === undefined || options.role === "menuitem";
|
|
361
|
+
const activate = options.disabled === true
|
|
362
|
+
? Effect.void
|
|
363
|
+
: RefSubject.update(options.state, (state) => ({
|
|
364
|
+
...state,
|
|
365
|
+
activeId: options.id,
|
|
366
|
+
open: closesOnActivate ? false : state.open,
|
|
367
|
+
}));
|
|
368
|
+
const focus = RefSubject.update(options.state, (state) => ({ ...state, activeId: options.id }));
|
|
369
|
+
const register = options.collection === undefined
|
|
370
|
+
? undefined
|
|
371
|
+
: Collection.ref(options.collection, {
|
|
372
|
+
id: options.id,
|
|
373
|
+
value: options.id,
|
|
374
|
+
textValue: options.textValue ?? options.id,
|
|
375
|
+
disabled: options.disabled,
|
|
376
|
+
});
|
|
377
|
+
return () => ({
|
|
378
|
+
id: options.id,
|
|
379
|
+
role: options.role ?? "menuitem",
|
|
380
|
+
"aria-disabled": options.disabled ?? false,
|
|
381
|
+
"aria-checked": options.role === "menuitem" || options.role === undefined
|
|
382
|
+
? undefined
|
|
383
|
+
: (options.checked ?? false),
|
|
384
|
+
tabindex: Composite.tabIndex(options.state, options.id),
|
|
385
|
+
onclick: activate,
|
|
386
|
+
onfocus: focus,
|
|
387
|
+
onmouseenter: focus,
|
|
388
|
+
ref: Dom.composeRefs(register, options.ref),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Renders and optionally registers a menu item; disabled items remain announced but do not
|
|
393
|
+
* activate.
|
|
394
|
+
*
|
|
395
|
+
* @remarks
|
|
396
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
397
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
398
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
399
|
+
*
|
|
400
|
+
* @since 1.0.0
|
|
401
|
+
* @category Commands
|
|
402
|
+
*/
|
|
403
|
+
export function Item(options, host) {
|
|
404
|
+
return Dom.renderHost()(options, host, itemProps(options), options.content, (props, content) => html `<div ...${props}>${content}</div>`);
|
|
405
|
+
}
|
|
406
|
+
function submenuTriggerProps(options) {
|
|
407
|
+
const submenuId = RefSubject.map(options.submenu, (state) => state.id);
|
|
408
|
+
const expanded = RefSubject.map(options.submenu, (state) => state.open);
|
|
409
|
+
const focusEffect = RefSubject.update(options.state, (state) => ({
|
|
410
|
+
...state,
|
|
411
|
+
activeId: options.id,
|
|
412
|
+
}));
|
|
413
|
+
const activateEffect = options.disabled === true ? Effect.void : focusEffect;
|
|
414
|
+
const register = options.collection === undefined
|
|
415
|
+
? undefined
|
|
416
|
+
: Collection.ref(options.collection, {
|
|
417
|
+
id: options.id,
|
|
418
|
+
value: options.id,
|
|
419
|
+
textValue: options.textValue ?? options.id,
|
|
420
|
+
disabled: options.disabled,
|
|
421
|
+
submenu: true,
|
|
422
|
+
});
|
|
423
|
+
return () => ({
|
|
424
|
+
id: options.id,
|
|
425
|
+
type: "button",
|
|
426
|
+
role: "menuitem",
|
|
427
|
+
popovertarget: submenuId,
|
|
428
|
+
popovertargetaction: "toggle",
|
|
429
|
+
"aria-haspopup": "menu",
|
|
430
|
+
"aria-expanded": expanded,
|
|
431
|
+
"aria-disabled": options.disabled ?? false,
|
|
432
|
+
tabindex: Composite.tabIndex(options.state, options.id),
|
|
433
|
+
onclick: activateEffect,
|
|
434
|
+
onfocus: focusEffect,
|
|
435
|
+
onmouseenter: options.disabled === true
|
|
436
|
+
? Effect.void
|
|
437
|
+
: Effect.andThen(activateEffect, setOpen(options.submenu, true)),
|
|
438
|
+
ref: Dom.composeRefs(register, Dom.composeRefs(options.ref, submenuOwnerRef(options.submenu, options.state, options.collection, options.id))),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
function submenuOwnerRef(submenu, state, collection, triggerId) {
|
|
442
|
+
return Effect.fn(function* () {
|
|
443
|
+
const owner = {
|
|
444
|
+
onArrowLeft: Effect.gen(function* () {
|
|
445
|
+
if (collection === undefined)
|
|
446
|
+
return;
|
|
447
|
+
if ((yield* state).orientation === "horizontal") {
|
|
448
|
+
yield* Composite.moveAndFocus({ state, collection, includeDisabled: true }, "previous");
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
yield* RefSubject.update(state, (value) => ({ ...value, activeId: triggerId }));
|
|
452
|
+
yield* Composite.focusActive({ state, collection });
|
|
453
|
+
}
|
|
454
|
+
}),
|
|
455
|
+
};
|
|
456
|
+
submenuOwners.set(submenu, owner);
|
|
457
|
+
const scope = yield* Effect.scope;
|
|
458
|
+
yield* Scope.addFinalizer(scope, Effect.sync(() => {
|
|
459
|
+
if (submenuOwners.get(submenu) === owner)
|
|
460
|
+
submenuOwners.delete(submenu);
|
|
461
|
+
}));
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Renders a menuitem that targets nested popover content and coordinates parent/child directional
|
|
466
|
+
* focus.
|
|
467
|
+
*
|
|
468
|
+
* @remarks
|
|
469
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
470
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
471
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
472
|
+
*
|
|
473
|
+
* @since 1.0.0
|
|
474
|
+
* @category Nested menus
|
|
475
|
+
*/
|
|
476
|
+
export function SubmenuTrigger(options, host) {
|
|
477
|
+
return Dom.renderHost()(options, host, submenuTriggerProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Renders Item with menuitemcheckbox semantics and caller-supplied checked state.
|
|
481
|
+
*
|
|
482
|
+
* @remarks
|
|
483
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
484
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
485
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
486
|
+
*
|
|
487
|
+
* @since 1.0.0
|
|
488
|
+
* @category Checked commands
|
|
489
|
+
*/
|
|
490
|
+
export function CheckboxItem(options, host) {
|
|
491
|
+
return Item({ ...options, role: "menuitemcheckbox" }, host);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Renders Item with menuitemradio semantics and caller-supplied checked state.
|
|
495
|
+
*
|
|
496
|
+
* @remarks
|
|
497
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
498
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
499
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
500
|
+
*
|
|
501
|
+
* @since 1.0.0
|
|
502
|
+
* @category Checked commands
|
|
503
|
+
*/
|
|
504
|
+
export function RadioItem(options, host) {
|
|
505
|
+
return Item({ ...options, role: "menuitemradio" }, host);
|
|
506
|
+
}
|
|
507
|
+
function groupProps(_options) {
|
|
508
|
+
return ({ property }) => ({
|
|
509
|
+
role: "group",
|
|
510
|
+
"aria-label": property("label", undefined),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Renders a labelled ARIA group inside menu content.
|
|
515
|
+
*
|
|
516
|
+
* @remarks
|
|
517
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
518
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
519
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
520
|
+
*
|
|
521
|
+
* @example
|
|
522
|
+
* ```ts
|
|
523
|
+
* import * as Menu from "@typed/ui/Menu";
|
|
524
|
+
*
|
|
525
|
+
* const view = Menu.Group({ label: "View", content: "Choices" });
|
|
526
|
+
* ```
|
|
527
|
+
* @since 1.0.0
|
|
528
|
+
* @category Command grouping
|
|
529
|
+
*/
|
|
530
|
+
export function Group(options, host) {
|
|
531
|
+
return Dom.renderHost()(options, host, groupProps(options), options.content, (props, content) => html `<div ...${props}>${content}</div>`);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Consumer-facing alias of the canonical Menu component with identical behavior and lifetime.
|
|
535
|
+
*
|
|
536
|
+
* @remarks
|
|
537
|
+
* The alias acquires nothing. Rendering it has exactly the canonical component's Scope and DOM
|
|
538
|
+
* ownership contract.
|
|
539
|
+
*
|
|
540
|
+
* @since 1.0.0
|
|
541
|
+
* @category Commands
|
|
542
|
+
*/
|
|
543
|
+
export const Dismiss = Item;
|
|
544
|
+
function separatorProps(_options) {
|
|
545
|
+
return ({ property }) => ({
|
|
546
|
+
role: "separator",
|
|
547
|
+
"aria-orientation": property("orientation", "horizontal"),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Renders a separator with explicit horizontal or vertical orientation.
|
|
552
|
+
*
|
|
553
|
+
* @remarks
|
|
554
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
555
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
556
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
557
|
+
*
|
|
558
|
+
* @example
|
|
559
|
+
* ```ts
|
|
560
|
+
* import * as Menu from "@typed/ui/Menu";
|
|
561
|
+
*
|
|
562
|
+
* const view = Menu.Separator({ orientation: "horizontal" });
|
|
563
|
+
* ```
|
|
564
|
+
* @since 1.0.0
|
|
565
|
+
* @category Command grouping
|
|
566
|
+
*/
|
|
567
|
+
export function Separator(options, host) {
|
|
568
|
+
return Dom.renderHost()(options, host, separatorProps(options), "", (props) => html `<hr ...${props} />`);
|
|
569
|
+
}
|