@typed/ui 1.0.0-beta.4 → 1.0.0-beta.6
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 +53 -9
- package/dist/Alert.d.ts +78 -0
- package/dist/Alert.d.ts.map +1 -0
- package/dist/Alert.js +37 -0
- package/dist/Button.d.ts +144 -0
- package/dist/Button.d.ts.map +1 -0
- package/dist/Button.js +38 -0
- package/dist/Carousel.d.ts +547 -0
- package/dist/Carousel.d.ts.map +1 -0
- package/dist/Carousel.js +387 -0
- package/dist/Checkbox.d.ts +203 -0
- package/dist/Checkbox.d.ts.map +1 -0
- package/dist/Checkbox.js +140 -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 +516 -0
- package/dist/Combobox.d.ts.map +1 -0
- package/dist/Combobox.js +371 -0
- package/dist/Component.d.ts +127 -0
- package/dist/Component.d.ts.map +1 -0
- package/dist/Component.js +60 -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 +355 -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 +1695 -0
- package/dist/Form.d.ts.map +1 -0
- package/dist/Form.js +987 -0
- package/dist/Grid.d.ts +569 -0
- package/dist/Grid.d.ts.map +1 -0
- package/dist/Grid.js +379 -0
- package/dist/Group.d.ts +147 -0
- package/dist/Group.d.ts.map +1 -0
- package/dist/Group.js +63 -0
- package/dist/Heading.d.ts +86 -0
- package/dist/Heading.d.ts.map +1 -0
- package/dist/Heading.js +48 -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 +196 -53
- package/dist/Link.d.ts +67 -28
- package/dist/Link.d.ts.map +1 -1
- package/dist/Link.js +91 -37
- package/dist/Listbox.d.ts +437 -0
- package/dist/Listbox.d.ts.map +1 -0
- package/dist/Listbox.js +316 -0
- package/dist/Menu.d.ts +972 -0
- package/dist/Menu.d.ts.map +1 -0
- package/dist/Menu.js +731 -0
- package/dist/Menubar.d.ts +367 -0
- package/dist/Menubar.d.ts.map +1 -0
- package/dist/Menubar.js +265 -0
- package/dist/Meter.d.ts +217 -0
- package/dist/Meter.d.ts.map +1 -0
- package/dist/Meter.js +94 -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 +64 -0
- package/dist/NativeDialog.d.ts.map +1 -0
- package/dist/NativeDialog.js +50 -0
- package/dist/NativePopover.d.ts +41 -0
- package/dist/NativePopover.d.ts.map +1 -0
- package/dist/NativePopover.js +46 -0
- package/dist/Popover.d.ts +241 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/Popover.js +141 -0
- package/dist/RadioGroup.d.ts +432 -0
- package/dist/RadioGroup.d.ts.map +1 -0
- package/dist/RadioGroup.js +291 -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 +529 -0
- package/dist/Select.d.ts.map +1 -0
- package/dist/Select.js +407 -0
- package/dist/Separator.d.ts +54 -0
- package/dist/Separator.d.ts.map +1 -0
- package/dist/Separator.js +26 -0
- package/dist/Slider.d.ts +188 -0
- package/dist/Slider.d.ts.map +1 -0
- package/dist/Slider.js +96 -0
- package/dist/SpinButton.d.ts +188 -0
- package/dist/SpinButton.d.ts.map +1 -0
- package/dist/SpinButton.js +96 -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 +187 -0
- package/dist/Switch.d.ts.map +1 -0
- package/dist/Switch.js +116 -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 +591 -0
- package/dist/Tabs.d.ts.map +1 -0
- package/dist/Tabs.js +346 -0
- package/dist/Toolbar.d.ts +366 -0
- package/dist/Toolbar.d.ts.map +1 -0
- package/dist/Toolbar.js +245 -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 +591 -0
- package/dist/Tree.d.ts.map +1 -0
- package/dist/Tree.js +434 -0
- package/dist/TreeGrid.d.ts +645 -0
- package/dist/TreeGrid.d.ts.map +1 -0
- package/dist/TreeGrid.js +420 -0
- package/dist/VisuallyHidden.d.ts +54 -0
- package/dist/VisuallyHidden.d.ts.map +1 -0
- package/dist/VisuallyHidden.js +27 -0
- package/dist/WindowSplitter.d.ts +376 -0
- package/dist/WindowSplitter.d.ts.map +1 -0
- package/dist/WindowSplitter.js +222 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -0
- package/package.json +45 -18
- package/src/HttpRouter.test.ts +0 -256
- package/src/HttpRouter.ts +0 -165
- 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,731 @@
|
|
|
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
|
+
* @since 1.0.0
|
|
12
|
+
* @category modules
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
import * as Effect from "effect/Effect";
|
|
16
|
+
import * as Scope from "effect/Scope";
|
|
17
|
+
import * as Schema from "effect/Schema";
|
|
18
|
+
import { RefSubject } from "@typed/fx";
|
|
19
|
+
import { EventHandler, html, } from "@typed/template";
|
|
20
|
+
import * as Collection from "./Collection.js";
|
|
21
|
+
import * as Composite from "./Composite.js";
|
|
22
|
+
import * as Dom from "./Dom.js";
|
|
23
|
+
import * as NativePopover from "./NativePopover.js";
|
|
24
|
+
const invokers = new WeakMap();
|
|
25
|
+
const submenuOwners = new WeakMap();
|
|
26
|
+
/**
|
|
27
|
+
* Effect Schema used by makeState to encode, decode, and hydrate Menu state.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* ## Why
|
|
31
|
+
*
|
|
32
|
+
* A public schema makes hydration and serialized state use the same runtime validation as direct
|
|
33
|
+
* construction.
|
|
34
|
+
*
|
|
35
|
+
* ## Ownership and lifetime
|
|
36
|
+
*
|
|
37
|
+
* This declaration is data or schema metadata and acquires no resources.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* import * as Schema from "effect/Schema";
|
|
42
|
+
* import * as Menu from "@typed/ui/Menu";
|
|
43
|
+
*
|
|
44
|
+
* const decodeState = Schema.decodeUnknownEffect(Menu.StateSchema);
|
|
45
|
+
* ```
|
|
46
|
+
* @since 1.0.0
|
|
47
|
+
* @category schemas
|
|
48
|
+
*/
|
|
49
|
+
export const StateSchema = Schema.Struct({
|
|
50
|
+
id: Schema.String,
|
|
51
|
+
open: Schema.Boolean,
|
|
52
|
+
activeId: Schema.NullOr(Schema.String),
|
|
53
|
+
orientation: Schema.Literals(["vertical"]),
|
|
54
|
+
loop: Schema.Boolean,
|
|
55
|
+
rtl: Schema.Boolean,
|
|
56
|
+
virtualFocus: Schema.Boolean,
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* Creates hydrated Menu state. The caller supplies id; open defaults false, activeId null, loop
|
|
60
|
+
* true, and orientation vertical.
|
|
61
|
+
*
|
|
62
|
+
* @remarks
|
|
63
|
+
* ## Why
|
|
64
|
+
*
|
|
65
|
+
* State and collection ownership can be composed and tested independently from any renderer.
|
|
66
|
+
*
|
|
67
|
+
* ## Ownership and lifetime
|
|
68
|
+
*
|
|
69
|
+
* The returned Effect creates the RefSubject when run. That state is renderer-independent;
|
|
70
|
+
* collection registrations belong to the separate Scope that runs register or ref, not to state
|
|
71
|
+
* creation.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* import * as Effect from "effect/Effect";
|
|
76
|
+
* import * as Menu from "@typed/ui/Menu";
|
|
77
|
+
*
|
|
78
|
+
* const program = Effect.scoped(
|
|
79
|
+
* Effect.gen(function* () {
|
|
80
|
+
* const state = yield* Menu.makeState({ id: "file-menu" });
|
|
81
|
+
* const collection = yield* Menu.makeCollection();
|
|
82
|
+
* return { state: yield* state, collection: yield* collection };
|
|
83
|
+
* }),
|
|
84
|
+
* );
|
|
85
|
+
* ```
|
|
86
|
+
* @since 1.0.0
|
|
87
|
+
* @category constructors
|
|
88
|
+
*/
|
|
89
|
+
export function makeState(initial) {
|
|
90
|
+
return RefSubject.hydrate(StateSchema, {
|
|
91
|
+
id: initial.id,
|
|
92
|
+
open: initial.open ?? false,
|
|
93
|
+
activeId: initial.activeId ?? null,
|
|
94
|
+
orientation: "vertical",
|
|
95
|
+
loop: initial.loop ?? true,
|
|
96
|
+
rtl: false,
|
|
97
|
+
virtualFocus: false,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Creates a scoped Collection for Menu items.
|
|
102
|
+
*
|
|
103
|
+
* @remarks
|
|
104
|
+
* ## Why
|
|
105
|
+
*
|
|
106
|
+
* State and collection ownership can be composed and tested independently from any renderer.
|
|
107
|
+
*
|
|
108
|
+
* ## Ownership and lifetime
|
|
109
|
+
*
|
|
110
|
+
* The returned Effect allocates the RefSubject in the caller's Scope. Each later registration is
|
|
111
|
+
* owned by the Scope that runs register, independently of this construction Effect.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* import * as Effect from "effect/Effect";
|
|
116
|
+
* import * as Menu from "@typed/ui/Menu";
|
|
117
|
+
*
|
|
118
|
+
* const program = Effect.scoped(
|
|
119
|
+
* Effect.gen(function* () {
|
|
120
|
+
* const collection = yield* Menu.makeCollection();
|
|
121
|
+
* return yield* collection;
|
|
122
|
+
* }),
|
|
123
|
+
* );
|
|
124
|
+
* ```
|
|
125
|
+
* @since 1.0.0
|
|
126
|
+
* @category constructors
|
|
127
|
+
*/
|
|
128
|
+
export const makeCollection = Collection.makeState;
|
|
129
|
+
/**
|
|
130
|
+
* Updates only the menu's explicit open state.
|
|
131
|
+
*
|
|
132
|
+
* @remarks
|
|
133
|
+
* ## Why
|
|
134
|
+
*
|
|
135
|
+
* The operation exposes Menu's transition directly so callers can compose it in Effect programs
|
|
136
|
+
* and native event handlers.
|
|
137
|
+
*
|
|
138
|
+
* ## Ownership and lifetime
|
|
139
|
+
*
|
|
140
|
+
* The returned Effect performs the update or DOM side effect only when run, preserves the declared
|
|
141
|
+
* error and service channels, and retains no resources after completion.
|
|
142
|
+
*
|
|
143
|
+
* ## Example
|
|
144
|
+
*
|
|
145
|
+
* Import with `import { setOpen } from "@typed/ui/Menu";` Extend the [Menu.makeState runnable
|
|
146
|
+
* setup](/reference/%40typed%2Fui%2FMenu%23makeState). Inside the linked Effect program invoke
|
|
147
|
+
* `yield* setOpen(state, true)`, then read state to observe `open: true` without changing focus or
|
|
148
|
+
* menu identity.
|
|
149
|
+
* @since 1.0.0
|
|
150
|
+
* @category combinators
|
|
151
|
+
*/
|
|
152
|
+
export function setOpen(state, open) {
|
|
153
|
+
return RefSubject.update(state, (value) => ({ ...value, open }));
|
|
154
|
+
}
|
|
155
|
+
function triggerProps(options) {
|
|
156
|
+
const id = RefSubject.map(options.state, (state) => state.id);
|
|
157
|
+
const open = RefSubject.map(options.state, (state) => state.open);
|
|
158
|
+
return () => ({
|
|
159
|
+
type: "button",
|
|
160
|
+
popovertarget: id,
|
|
161
|
+
popovertargetaction: "toggle",
|
|
162
|
+
"aria-haspopup": "menu",
|
|
163
|
+
"aria-expanded": open,
|
|
164
|
+
onkeydown: EventHandler.make((event) => {
|
|
165
|
+
if (event.key !== "ArrowDown")
|
|
166
|
+
return;
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
Dom.currentTarget(event).click();
|
|
169
|
+
}),
|
|
170
|
+
ref: Dom.composeRefs(options.state, invokerRef(options.state)),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function invokerRef(state) {
|
|
174
|
+
return Effect.fn(function* (element) {
|
|
175
|
+
invokers.set(state, element);
|
|
176
|
+
const scope = yield* Effect.scope;
|
|
177
|
+
yield* Scope.addFinalizer(scope, Effect.sync(() => {
|
|
178
|
+
if (invokers.get(state) === element)
|
|
179
|
+
invokers.delete(state);
|
|
180
|
+
}));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Renders a native button targeting menu popover content and opens the menu on ArrowDown.
|
|
185
|
+
*
|
|
186
|
+
* @remarks
|
|
187
|
+
* ## Why
|
|
188
|
+
*
|
|
189
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
190
|
+
* through the shared DOM boundary.
|
|
191
|
+
*
|
|
192
|
+
* ## Ownership and lifetime
|
|
193
|
+
*
|
|
194
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
195
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
196
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
197
|
+
*
|
|
198
|
+
* ## Example
|
|
199
|
+
*
|
|
200
|
+
* Import with `import { Trigger } from "@typed/ui/Menu";` Extend the [Menu.makeState runnable
|
|
201
|
+
* setup](/reference/%40typed%2Fui%2FMenu%23makeState). Replace the linked program's final snapshot
|
|
202
|
+
* read with `Trigger({ state, content: "File" })`; render that Fx before the same Scope closes.
|
|
203
|
+
* @since 1.0.0
|
|
204
|
+
* @category components
|
|
205
|
+
*/
|
|
206
|
+
export function Trigger(options, host) {
|
|
207
|
+
return Dom.renderHost()(options, host, triggerProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Consumer-facing alias of the canonical Menu component with identical behavior and lifetime.
|
|
211
|
+
*
|
|
212
|
+
* @remarks
|
|
213
|
+
* ## Why
|
|
214
|
+
*
|
|
215
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
216
|
+
* through the shared DOM boundary.
|
|
217
|
+
*
|
|
218
|
+
* ## Ownership and lifetime
|
|
219
|
+
*
|
|
220
|
+
* The alias acquires nothing. Rendering it has exactly the canonical component's Scope and DOM
|
|
221
|
+
* ownership contract.
|
|
222
|
+
*
|
|
223
|
+
* ## Example
|
|
224
|
+
*
|
|
225
|
+
* Import with `import { Button } from "@typed/ui/Menu";` Extend the [Menu.makeState runnable
|
|
226
|
+
* setup](/reference/%40typed%2Fui%2FMenu%23makeState). Replace the linked program's final snapshot
|
|
227
|
+
* read with `Button({ state, content: "File" })`; render that Fx before the same Scope closes.
|
|
228
|
+
* @since 1.0.0
|
|
229
|
+
* @category components
|
|
230
|
+
*/
|
|
231
|
+
export const Button = Trigger;
|
|
232
|
+
function contentProps(options) {
|
|
233
|
+
const id = RefSubject.map(options.state, (state) => state.id);
|
|
234
|
+
let typeahead = { value: "", updatedAt: 0 };
|
|
235
|
+
let restoreParentFocus = false;
|
|
236
|
+
let restoreOwnerFocus = false;
|
|
237
|
+
let restoreInvokerFocus = false;
|
|
238
|
+
let invoker;
|
|
239
|
+
const onkeydown = options.collection === undefined
|
|
240
|
+
? undefined
|
|
241
|
+
: EventHandler.make(Effect.fn(function* (event) {
|
|
242
|
+
const direction = Composite.keyMove(event, { orientation: "vertical" });
|
|
243
|
+
if (direction !== undefined) {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
yield* Composite.moveAndFocus({ state: options.state, collection: options.collection, includeDisabled: true }, direction);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (event.key === "ArrowRight") {
|
|
249
|
+
const activeId = (yield* options.state).activeId;
|
|
250
|
+
const item = activeId === null
|
|
251
|
+
? undefined
|
|
252
|
+
: (yield* options.collection).find((item) => item.id === activeId);
|
|
253
|
+
const element = item?.submenu === true ? item.element : undefined;
|
|
254
|
+
const click = element === undefined ? undefined : Reflect.get(element, "click");
|
|
255
|
+
if (typeof click === "function") {
|
|
256
|
+
event.preventDefault();
|
|
257
|
+
yield* Effect.sync(() => click.call(element));
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (event.key === "ArrowLeft") {
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
const element = Dom.currentTarget(event);
|
|
264
|
+
const hidePopover = Reflect.get(element, "hidePopover");
|
|
265
|
+
if (typeof hidePopover === "function") {
|
|
266
|
+
if (options.parent === undefined && submenuOwners.has(options.state))
|
|
267
|
+
restoreOwnerFocus = true;
|
|
268
|
+
else if (options.parent === undefined)
|
|
269
|
+
restoreInvokerFocus = true;
|
|
270
|
+
else
|
|
271
|
+
restoreParentFocus = true;
|
|
272
|
+
yield* Effect.sync(() => hidePopover.call(element));
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
yield* setOpen(options.state, false);
|
|
276
|
+
if (options.parent === undefined && submenuOwners.has(options.state)) {
|
|
277
|
+
yield* submenuOwners.get(options.state)?.onArrowLeft ?? Effect.void;
|
|
278
|
+
}
|
|
279
|
+
else if (options.parent === undefined)
|
|
280
|
+
yield* Composite.focusElement(invoker ?? invokers.get(options.state));
|
|
281
|
+
else {
|
|
282
|
+
yield* RefSubject.update(options.parent.state, (state) => ({
|
|
283
|
+
...state,
|
|
284
|
+
activeId: options.parent.triggerId,
|
|
285
|
+
}));
|
|
286
|
+
yield* Composite.focusActive({
|
|
287
|
+
state: options.parent.state,
|
|
288
|
+
collection: options.parent.collection,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (event.key === "Escape") {
|
|
295
|
+
restoreInvokerFocus = true;
|
|
296
|
+
yield* setOpen(options.state, false);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (event.key === "Tab") {
|
|
300
|
+
yield* setOpen(options.state, false);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
304
|
+
const activeId = (yield* options.state).activeId;
|
|
305
|
+
const element = activeId === null
|
|
306
|
+
? undefined
|
|
307
|
+
: (yield* options.collection).find((item) => item.id === activeId)?.element;
|
|
308
|
+
const click = element === undefined ? undefined : Reflect.get(element, "click");
|
|
309
|
+
if (typeof click === "function") {
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
yield* Effect.sync(() => click.call(element));
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const key = Composite.typeaheadKey(event);
|
|
316
|
+
if (key === null)
|
|
317
|
+
return;
|
|
318
|
+
typeahead = Composite.updateTypeaheadBuffer(typeahead, key, Date.now());
|
|
319
|
+
const activeId = Composite.typeaheadFrom(yield* options.collection, typeahead.value, (yield* options.state).activeId, undefined, true);
|
|
320
|
+
if (activeId === null)
|
|
321
|
+
return;
|
|
322
|
+
event.preventDefault();
|
|
323
|
+
yield* RefSubject.update(options.state, (state) => ({ ...state, activeId }));
|
|
324
|
+
yield* Composite.focusActive({ state: options.state, collection: options.collection });
|
|
325
|
+
}));
|
|
326
|
+
const restoreFocus = Effect.gen(function* () {
|
|
327
|
+
if (restoreParentFocus && options.parent !== undefined) {
|
|
328
|
+
restoreParentFocus = false;
|
|
329
|
+
yield* RefSubject.update(options.parent.state, (state) => ({
|
|
330
|
+
...state,
|
|
331
|
+
activeId: options.parent.triggerId,
|
|
332
|
+
}));
|
|
333
|
+
yield* Composite.focusActive({
|
|
334
|
+
state: options.parent.state,
|
|
335
|
+
collection: options.parent.collection,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (restoreOwnerFocus) {
|
|
339
|
+
restoreOwnerFocus = false;
|
|
340
|
+
yield* submenuOwners.get(options.state)?.onArrowLeft ?? Effect.void;
|
|
341
|
+
}
|
|
342
|
+
if (restoreInvokerFocus) {
|
|
343
|
+
restoreInvokerFocus = false;
|
|
344
|
+
yield* Composite.focusElement(invoker ?? invokers.get(options.state));
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
const toggle = EventHandler.make(Effect.fn(function* (event) {
|
|
348
|
+
const open = Dom.toggleState(event) === "open";
|
|
349
|
+
const source = Reflect.get(event, "source");
|
|
350
|
+
if (isElement(source))
|
|
351
|
+
invoker = source;
|
|
352
|
+
const current = yield* options.state;
|
|
353
|
+
if (current.open === open) {
|
|
354
|
+
if (!open)
|
|
355
|
+
yield* restoreFocus;
|
|
356
|
+
return current;
|
|
357
|
+
}
|
|
358
|
+
if (open && options.collection !== undefined) {
|
|
359
|
+
const activeId = Composite.moveActiveId(yield* options.collection, current, "first", true);
|
|
360
|
+
const next = yield* RefSubject.update(options.state, (state) => ({
|
|
361
|
+
...state,
|
|
362
|
+
open,
|
|
363
|
+
activeId,
|
|
364
|
+
}));
|
|
365
|
+
yield* Composite.focusActive({ state: options.state, collection: options.collection });
|
|
366
|
+
yield* Composite.scrollActive({ state: options.state, collection: options.collection });
|
|
367
|
+
return next;
|
|
368
|
+
}
|
|
369
|
+
const next = yield* setOpen(options.state, open);
|
|
370
|
+
if (!open)
|
|
371
|
+
yield* restoreFocus;
|
|
372
|
+
return next;
|
|
373
|
+
}));
|
|
374
|
+
return ({ property }) => ({
|
|
375
|
+
id,
|
|
376
|
+
role: "menu",
|
|
377
|
+
popover: "manual",
|
|
378
|
+
"aria-label": property("label", undefined),
|
|
379
|
+
onkeydown,
|
|
380
|
+
ontoggle: toggle,
|
|
381
|
+
ref: Dom.composeRefs(options.state, NativePopover.ref(options.state)),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
function isElement(value) {
|
|
385
|
+
return typeof value === "object" && value !== null && "nodeType" in value;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Renders manual-popover menu content and coordinates focus, typeahead, Escape, and nested-menu
|
|
389
|
+
* traversal.
|
|
390
|
+
*
|
|
391
|
+
* @remarks
|
|
392
|
+
* ## Why
|
|
393
|
+
*
|
|
394
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
395
|
+
* through the shared DOM boundary.
|
|
396
|
+
*
|
|
397
|
+
* ## Ownership and lifetime
|
|
398
|
+
*
|
|
399
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
400
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
401
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
402
|
+
*
|
|
403
|
+
* ## Example
|
|
404
|
+
*
|
|
405
|
+
* Import with `import { Content } from "@typed/ui/Menu";` Extend the [Menu.makeState runnable
|
|
406
|
+
* setup](/reference/%40typed%2Fui%2FMenu%23makeState). Replace the linked program's final snapshot
|
|
407
|
+
* read with `Content({ state, label: "File", content: "Commands" })`; render that Fx before the
|
|
408
|
+
* same Scope closes.
|
|
409
|
+
* @since 1.0.0
|
|
410
|
+
* @category components
|
|
411
|
+
*/
|
|
412
|
+
export function Content(options, host) {
|
|
413
|
+
return Dom.renderHost()(options, host, contentProps(options), options.content, (props, content) => {
|
|
414
|
+
return html `<div ...${props}>${content}</div>`;
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Consumer-facing alias of the canonical Menu component with identical behavior and lifetime.
|
|
419
|
+
*
|
|
420
|
+
* @remarks
|
|
421
|
+
* ## Why
|
|
422
|
+
*
|
|
423
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
424
|
+
* through the shared DOM boundary.
|
|
425
|
+
*
|
|
426
|
+
* ## Ownership and lifetime
|
|
427
|
+
*
|
|
428
|
+
* The alias acquires nothing. Rendering it has exactly the canonical component's Scope and DOM
|
|
429
|
+
* ownership contract.
|
|
430
|
+
*
|
|
431
|
+
* ## Example
|
|
432
|
+
*
|
|
433
|
+
* Import with `import { Menu } from "@typed/ui/Menu";` Extend the [Menu.makeState runnable
|
|
434
|
+
* setup](/reference/%40typed%2Fui%2FMenu%23makeState). Replace the linked program's final snapshot
|
|
435
|
+
* read with `Menu({ state, label: "File", content: "Commands" })`; render that Fx before the same
|
|
436
|
+
* Scope closes.
|
|
437
|
+
* @since 1.0.0
|
|
438
|
+
* @category components
|
|
439
|
+
*/
|
|
440
|
+
export const Menu = Content;
|
|
441
|
+
function itemProps(options) {
|
|
442
|
+
const closesOnActivate = options.role === undefined || options.role === "menuitem";
|
|
443
|
+
const activate = options.disabled === true
|
|
444
|
+
? Effect.void
|
|
445
|
+
: RefSubject.update(options.state, (state) => ({
|
|
446
|
+
...state,
|
|
447
|
+
activeId: options.id,
|
|
448
|
+
open: closesOnActivate ? false : state.open,
|
|
449
|
+
}));
|
|
450
|
+
const focus = RefSubject.update(options.state, (state) => ({ ...state, activeId: options.id }));
|
|
451
|
+
const register = options.collection === undefined
|
|
452
|
+
? undefined
|
|
453
|
+
: Collection.ref(options.collection, {
|
|
454
|
+
id: options.id,
|
|
455
|
+
value: options.id,
|
|
456
|
+
textValue: options.textValue ?? options.id,
|
|
457
|
+
disabled: options.disabled,
|
|
458
|
+
});
|
|
459
|
+
return () => ({
|
|
460
|
+
id: options.id,
|
|
461
|
+
role: options.role ?? "menuitem",
|
|
462
|
+
"aria-disabled": options.disabled ?? false,
|
|
463
|
+
"aria-checked": options.role === "menuitem" || options.role === undefined
|
|
464
|
+
? undefined
|
|
465
|
+
: (options.checked ?? false),
|
|
466
|
+
tabindex: Composite.tabIndex(options.state, options.id),
|
|
467
|
+
onclick: activate,
|
|
468
|
+
onfocus: focus,
|
|
469
|
+
onmouseenter: focus,
|
|
470
|
+
ref: Dom.composeRefs(register, options.ref),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Renders and optionally registers a menu item; disabled items remain announced but do not
|
|
475
|
+
* activate.
|
|
476
|
+
*
|
|
477
|
+
* @remarks
|
|
478
|
+
* ## Why
|
|
479
|
+
*
|
|
480
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
481
|
+
* through the shared DOM boundary.
|
|
482
|
+
*
|
|
483
|
+
* ## Ownership and lifetime
|
|
484
|
+
*
|
|
485
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
486
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
487
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
488
|
+
*
|
|
489
|
+
* ## Example
|
|
490
|
+
*
|
|
491
|
+
* Import with `import { Item } from "@typed/ui/Menu";` Extend the [Menu.makeState runnable
|
|
492
|
+
* setup](/reference/%40typed%2Fui%2FMenu%23makeState). Replace the linked program's final snapshot
|
|
493
|
+
* read with `Item({ state, id: "save", content: "Save" })`; render that Fx before the same Scope
|
|
494
|
+
* closes.
|
|
495
|
+
* @since 1.0.0
|
|
496
|
+
* @category components
|
|
497
|
+
*/
|
|
498
|
+
export function Item(options, host) {
|
|
499
|
+
return Dom.renderHost()(options, host, itemProps(options), options.content, (props, content) => html `<div ...${props}>${content}</div>`);
|
|
500
|
+
}
|
|
501
|
+
function submenuTriggerProps(options) {
|
|
502
|
+
const submenuId = RefSubject.map(options.submenu, (state) => state.id);
|
|
503
|
+
const expanded = RefSubject.map(options.submenu, (state) => state.open);
|
|
504
|
+
const focusEffect = RefSubject.update(options.state, (state) => ({
|
|
505
|
+
...state,
|
|
506
|
+
activeId: options.id,
|
|
507
|
+
}));
|
|
508
|
+
const activateEffect = options.disabled === true ? Effect.void : focusEffect;
|
|
509
|
+
const register = options.collection === undefined
|
|
510
|
+
? undefined
|
|
511
|
+
: Collection.ref(options.collection, {
|
|
512
|
+
id: options.id,
|
|
513
|
+
value: options.id,
|
|
514
|
+
textValue: options.textValue ?? options.id,
|
|
515
|
+
disabled: options.disabled,
|
|
516
|
+
submenu: true,
|
|
517
|
+
});
|
|
518
|
+
return () => ({
|
|
519
|
+
id: options.id,
|
|
520
|
+
type: "button",
|
|
521
|
+
role: "menuitem",
|
|
522
|
+
popovertarget: submenuId,
|
|
523
|
+
popovertargetaction: "toggle",
|
|
524
|
+
"aria-haspopup": "menu",
|
|
525
|
+
"aria-expanded": expanded,
|
|
526
|
+
"aria-disabled": options.disabled ?? false,
|
|
527
|
+
tabindex: Composite.tabIndex(options.state, options.id),
|
|
528
|
+
onclick: activateEffect,
|
|
529
|
+
onfocus: focusEffect,
|
|
530
|
+
onmouseenter: options.disabled === true
|
|
531
|
+
? Effect.void
|
|
532
|
+
: Effect.andThen(activateEffect, setOpen(options.submenu, true)),
|
|
533
|
+
ref: Dom.composeRefs(register, Dom.composeRefs(options.ref, submenuOwnerRef(options.submenu, options.state, options.collection, options.id))),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
function submenuOwnerRef(submenu, state, collection, triggerId) {
|
|
537
|
+
return Effect.fn(function* () {
|
|
538
|
+
const owner = {
|
|
539
|
+
onArrowLeft: Effect.gen(function* () {
|
|
540
|
+
if (collection === undefined)
|
|
541
|
+
return;
|
|
542
|
+
if ((yield* state).orientation === "horizontal") {
|
|
543
|
+
yield* Composite.moveAndFocus({ state, collection, includeDisabled: true }, "previous");
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
yield* RefSubject.update(state, (value) => ({ ...value, activeId: triggerId }));
|
|
547
|
+
yield* Composite.focusActive({ state, collection });
|
|
548
|
+
}
|
|
549
|
+
}),
|
|
550
|
+
};
|
|
551
|
+
submenuOwners.set(submenu, owner);
|
|
552
|
+
const scope = yield* Effect.scope;
|
|
553
|
+
yield* Scope.addFinalizer(scope, Effect.sync(() => {
|
|
554
|
+
if (submenuOwners.get(submenu) === owner)
|
|
555
|
+
submenuOwners.delete(submenu);
|
|
556
|
+
}));
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Renders a menuitem that targets nested popover content and coordinates parent/child directional
|
|
561
|
+
* focus.
|
|
562
|
+
*
|
|
563
|
+
* @remarks
|
|
564
|
+
* ## Why
|
|
565
|
+
*
|
|
566
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
567
|
+
* through the shared DOM boundary.
|
|
568
|
+
*
|
|
569
|
+
* ## Ownership and lifetime
|
|
570
|
+
*
|
|
571
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
572
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
573
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
574
|
+
*
|
|
575
|
+
* ## Example
|
|
576
|
+
*
|
|
577
|
+
* Import with `import { SubmenuTrigger } from "@typed/ui/Menu";` Extend the [Menu.makeState
|
|
578
|
+
* runnable setup](/reference/%40typed%2Fui%2FMenu%23makeState). Replace the linked program's final
|
|
579
|
+
* snapshot read after `const submenu = yield* Menu.makeState({ id: "share-menu" })` with
|
|
580
|
+
* `SubmenuTrigger({ state, submenu, collection, id: "share", content: "Share" })`; render that Fx
|
|
581
|
+
* before the same Scope closes.
|
|
582
|
+
* @since 1.0.0
|
|
583
|
+
* @category components
|
|
584
|
+
*/
|
|
585
|
+
export function SubmenuTrigger(options, host) {
|
|
586
|
+
return Dom.renderHost()(options, host, submenuTriggerProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Renders Item with menuitemcheckbox semantics and caller-supplied checked state.
|
|
590
|
+
*
|
|
591
|
+
* @remarks
|
|
592
|
+
* ## Why
|
|
593
|
+
*
|
|
594
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
595
|
+
* through the shared DOM boundary.
|
|
596
|
+
*
|
|
597
|
+
* ## Ownership and lifetime
|
|
598
|
+
*
|
|
599
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
600
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
601
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
602
|
+
*
|
|
603
|
+
* ## Example
|
|
604
|
+
*
|
|
605
|
+
* Import with `import { CheckboxItem } from "@typed/ui/Menu";` Extend the [Menu.makeState runnable
|
|
606
|
+
* setup](/reference/%40typed%2Fui%2FMenu%23makeState). Replace the linked program's final snapshot
|
|
607
|
+
* read with `CheckboxItem({ state, id: "autosave", checked: true, content: "Autosave" })`; render
|
|
608
|
+
* that Fx before the same Scope closes.
|
|
609
|
+
* @since 1.0.0
|
|
610
|
+
* @category components
|
|
611
|
+
*/
|
|
612
|
+
export function CheckboxItem(options, host) {
|
|
613
|
+
return Item({ ...options, role: "menuitemcheckbox" }, host);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Renders Item with menuitemradio semantics and caller-supplied checked state.
|
|
617
|
+
*
|
|
618
|
+
* @remarks
|
|
619
|
+
* ## Why
|
|
620
|
+
*
|
|
621
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
622
|
+
* through the shared DOM boundary.
|
|
623
|
+
*
|
|
624
|
+
* ## Ownership and lifetime
|
|
625
|
+
*
|
|
626
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
627
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
628
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
629
|
+
*
|
|
630
|
+
* ## Example
|
|
631
|
+
*
|
|
632
|
+
* Import with `import { RadioItem } from "@typed/ui/Menu";` Extend the [Menu.makeState runnable
|
|
633
|
+
* setup](/reference/%40typed%2Fui%2FMenu%23makeState). Replace the linked program's final snapshot
|
|
634
|
+
* read with `RadioItem({ state, id: "compact", checked: false, content: "Compact" })`; render that
|
|
635
|
+
* Fx before the same Scope closes.
|
|
636
|
+
* @since 1.0.0
|
|
637
|
+
* @category components
|
|
638
|
+
*/
|
|
639
|
+
export function RadioItem(options, host) {
|
|
640
|
+
return Item({ ...options, role: "menuitemradio" }, host);
|
|
641
|
+
}
|
|
642
|
+
function groupProps(_options) {
|
|
643
|
+
return ({ property }) => ({
|
|
644
|
+
role: "group",
|
|
645
|
+
"aria-label": property("label", undefined),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Renders a labelled ARIA group inside menu content.
|
|
650
|
+
*
|
|
651
|
+
* @remarks
|
|
652
|
+
* ## Why
|
|
653
|
+
*
|
|
654
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
655
|
+
* through the shared DOM boundary.
|
|
656
|
+
*
|
|
657
|
+
* ## Ownership and lifetime
|
|
658
|
+
*
|
|
659
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
660
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
661
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
662
|
+
*
|
|
663
|
+
* @example
|
|
664
|
+
* ```ts
|
|
665
|
+
* import * as Menu from "@typed/ui/Menu";
|
|
666
|
+
*
|
|
667
|
+
* const view = Menu.Group({ label: "View", content: "Choices" });
|
|
668
|
+
* ```
|
|
669
|
+
* @since 1.0.0
|
|
670
|
+
* @category components
|
|
671
|
+
*/
|
|
672
|
+
export function Group(options, host) {
|
|
673
|
+
return Dom.renderHost()(options, host, groupProps(options), options.content, (props, content) => html `<div ...${props}>${content}</div>`);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Consumer-facing alias of the canonical Menu component with identical behavior and lifetime.
|
|
677
|
+
*
|
|
678
|
+
* @remarks
|
|
679
|
+
* ## Why
|
|
680
|
+
*
|
|
681
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
682
|
+
* through the shared DOM boundary.
|
|
683
|
+
*
|
|
684
|
+
* ## Ownership and lifetime
|
|
685
|
+
*
|
|
686
|
+
* The alias acquires nothing. Rendering it has exactly the canonical component's Scope and DOM
|
|
687
|
+
* ownership contract.
|
|
688
|
+
*
|
|
689
|
+
* ## Example
|
|
690
|
+
*
|
|
691
|
+
* Import with `import { Dismiss } from "@typed/ui/Menu";` Extend the [Menu.makeState runnable
|
|
692
|
+
* setup](/reference/%40typed%2Fui%2FMenu%23makeState). Replace the linked program's final snapshot
|
|
693
|
+
* read with `Dismiss({ state, id: "close", content: "Close" })`; render that Fx before the same
|
|
694
|
+
* Scope closes.
|
|
695
|
+
* @since 1.0.0
|
|
696
|
+
* @category components
|
|
697
|
+
*/
|
|
698
|
+
export const Dismiss = Item;
|
|
699
|
+
function separatorProps(_options) {
|
|
700
|
+
return ({ property }) => ({
|
|
701
|
+
role: "separator",
|
|
702
|
+
"aria-orientation": property("orientation", "horizontal"),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Renders a separator with explicit horizontal or vertical orientation.
|
|
707
|
+
*
|
|
708
|
+
* @remarks
|
|
709
|
+
* ## Why
|
|
710
|
+
*
|
|
711
|
+
* The component applies the family behavior while leaving callers free to supply a custom host
|
|
712
|
+
* through the shared DOM boundary.
|
|
713
|
+
*
|
|
714
|
+
* ## Ownership and lifetime
|
|
715
|
+
*
|
|
716
|
+
* The returned Fx installs DOM refs, native listeners, state subscriptions, and optional
|
|
717
|
+
* collection registrations only when rendered. The rendering Scope removes those resources;
|
|
718
|
+
* unrelated nodes and attributes remain caller-owned.
|
|
719
|
+
*
|
|
720
|
+
* @example
|
|
721
|
+
* ```ts
|
|
722
|
+
* import * as Menu from "@typed/ui/Menu";
|
|
723
|
+
*
|
|
724
|
+
* const view = Menu.Separator({ orientation: "horizontal" });
|
|
725
|
+
* ```
|
|
726
|
+
* @since 1.0.0
|
|
727
|
+
* @category components
|
|
728
|
+
*/
|
|
729
|
+
export function Separator(options, host) {
|
|
730
|
+
return Dom.renderHost()(options, host, separatorProps(options), "", (props) => html `<hr ...${props} />`);
|
|
731
|
+
}
|