@lumiose/ui 0.0.0-stage → 0.1.0

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.
Files changed (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/accordion/accordion.css +20 -0
  4. package/dist/accordion/accordion.script.ts +66 -0
  5. package/dist/accordion/accordion.tsx +288 -0
  6. package/dist/animation/animation.ts +34 -0
  7. package/dist/autocomplete/autocomplete.tsx +239 -0
  8. package/dist/avatar/avatar.css +54 -0
  9. package/dist/avatar/avatar.script.ts +96 -0
  10. package/dist/avatar/avatar.tsx +110 -0
  11. package/dist/button/button.script.ts +35 -0
  12. package/dist/button/button.tsx +40 -0
  13. package/dist/checkbox/checkbox.script.ts +225 -0
  14. package/dist/checkbox/checkbox.tsx +156 -0
  15. package/dist/checkbox/context.ts +17 -0
  16. package/dist/checkbox-group/checkbox-group.tsx +111 -0
  17. package/dist/collapsible/collapsible.css +12 -0
  18. package/dist/collapsible/collapsible.script.ts +7 -0
  19. package/dist/collapsible/collapsible.tsx +153 -0
  20. package/dist/collapsible/panel.ts +115 -0
  21. package/dist/combobox/combobox.css +37 -0
  22. package/dist/combobox/combobox.script.ts +974 -0
  23. package/dist/combobox/combobox.tsx +556 -0
  24. package/dist/combobox/context.ts +46 -0
  25. package/dist/context-menu/context-menu.css +16 -0
  26. package/dist/context-menu/context-menu.script.ts +172 -0
  27. package/dist/context-menu/context-menu.tsx +178 -0
  28. package/dist/dialog/dialog.css +35 -0
  29. package/dist/dialog/dialog.script.ts +269 -0
  30. package/dist/dialog/dialog.tsx +214 -0
  31. package/dist/drawer/drawer.css +38 -0
  32. package/dist/drawer/drawer.script.ts +207 -0
  33. package/dist/drawer/drawer.tsx +113 -0
  34. package/dist/field/context.ts +156 -0
  35. package/dist/field/field.css +16 -0
  36. package/dist/field/field.script.ts +422 -0
  37. package/dist/field/field.tsx +465 -0
  38. package/dist/fieldset/fieldset.tsx +41 -0
  39. package/dist/form/form.tsx +50 -0
  40. package/dist/form/native.ts +21 -0
  41. package/dist/form/reset.ts +53 -0
  42. package/dist/index.ts +261 -0
  43. package/dist/input/input.tsx +67 -0
  44. package/dist/layer/layer.ts +191 -0
  45. package/dist/locale/digits.ts +21 -0
  46. package/dist/menu/menu.css +22 -0
  47. package/dist/menu/menu.script.ts +591 -0
  48. package/dist/menu/menu.tsx +520 -0
  49. package/dist/menubar/menubar.css +16 -0
  50. package/dist/menubar/menubar.script.ts +195 -0
  51. package/dist/menubar/menubar.tsx +67 -0
  52. package/dist/meter/meter.tsx +134 -0
  53. package/dist/navigation-menu/navigation-menu.css +14 -0
  54. package/dist/navigation-menu/navigation-menu.script.ts +466 -0
  55. package/dist/navigation-menu/navigation-menu.tsx +266 -0
  56. package/dist/number-field/format.ts +70 -0
  57. package/dist/number-field/number-field.css +38 -0
  58. package/dist/number-field/number-field.script.ts +490 -0
  59. package/dist/number-field/number-field.tsx +403 -0
  60. package/dist/otp-field/otp-field.css +45 -0
  61. package/dist/otp-field/otp-field.script.ts +330 -0
  62. package/dist/otp-field/otp-field.tsx +320 -0
  63. package/dist/placement/place.ts +148 -0
  64. package/dist/placement/placement.css +155 -0
  65. package/dist/placement/placement.ts +60 -0
  66. package/dist/popover/hover.ts +178 -0
  67. package/dist/popover/popover.css +20 -0
  68. package/dist/popover/popover.script.ts +256 -0
  69. package/dist/popover/popover.tsx +317 -0
  70. package/dist/preview-card/preview-card.css +7 -0
  71. package/dist/preview-card/preview-card.script.ts +136 -0
  72. package/dist/preview-card/preview-card.tsx +128 -0
  73. package/dist/progress/progress.tsx +155 -0
  74. package/dist/progress/value.ts +23 -0
  75. package/dist/radio-group/radio-group.tsx +156 -0
  76. package/dist/scroll-area/scroll-area.css +72 -0
  77. package/dist/scroll-area/scroll-area.script.ts +281 -0
  78. package/dist/scroll-area/scroll-area.tsx +118 -0
  79. package/dist/select/select.css +25 -0
  80. package/dist/select/select.tsx +171 -0
  81. package/dist/separator/separator.css +12 -0
  82. package/dist/separator/separator.tsx +38 -0
  83. package/dist/slider/format.ts +33 -0
  84. package/dist/slider/slider.css +93 -0
  85. package/dist/slider/slider.script.ts +355 -0
  86. package/dist/slider/slider.tsx +374 -0
  87. package/dist/styles.css +30 -0
  88. package/dist/switch/switch.tsx +92 -0
  89. package/dist/tabs/tabs.css +54 -0
  90. package/dist/tabs/tabs.script.ts +225 -0
  91. package/dist/tabs/tabs.tsx +272 -0
  92. package/dist/themes/default.css +2111 -0
  93. package/dist/toast/command.ts +3 -0
  94. package/dist/toast/toast.css +68 -0
  95. package/dist/toast/toast.script.ts +516 -0
  96. package/dist/toast/toast.tsx +432 -0
  97. package/dist/toggle/context.ts +16 -0
  98. package/dist/toggle/toggle.tsx +99 -0
  99. package/dist/toggle-group/toggle-group.script.ts +166 -0
  100. package/dist/toggle-group/toggle-group.tsx +117 -0
  101. package/dist/toolbar/context.ts +18 -0
  102. package/dist/toolbar/toolbar.css +12 -0
  103. package/dist/toolbar/toolbar.script.ts +123 -0
  104. package/dist/toolbar/toolbar.tsx +180 -0
  105. package/dist/tooltip/tooltip.css +8 -0
  106. package/dist/tooltip/tooltip.script.ts +222 -0
  107. package/dist/tooltip/tooltip.tsx +149 -0
  108. package/docs/README.md +174 -0
  109. package/docs/accordion.md +208 -0
  110. package/docs/autocomplete.md +161 -0
  111. package/docs/avatar.md +198 -0
  112. package/docs/button.md +98 -0
  113. package/docs/checkbox-group.md +112 -0
  114. package/docs/checkbox.md +133 -0
  115. package/docs/collapsible.md +165 -0
  116. package/docs/combobox.md +369 -0
  117. package/docs/context-menu.md +197 -0
  118. package/docs/dialog.md +256 -0
  119. package/docs/drawer.md +204 -0
  120. package/docs/field.md +295 -0
  121. package/docs/fieldset.md +92 -0
  122. package/docs/form.md +131 -0
  123. package/docs/input.md +87 -0
  124. package/docs/installation.md +65 -0
  125. package/docs/llms-full.txt +6923 -0
  126. package/docs/llms.txt +53 -0
  127. package/docs/menu.md +365 -0
  128. package/docs/menubar.md +186 -0
  129. package/docs/meter.md +103 -0
  130. package/docs/navigation-menu.md +257 -0
  131. package/docs/number-field.md +181 -0
  132. package/docs/otp-field.md +160 -0
  133. package/docs/popover.md +271 -0
  134. package/docs/preview-card.md +130 -0
  135. package/docs/progress.md +115 -0
  136. package/docs/radio-group.md +127 -0
  137. package/docs/scroll-area.md +147 -0
  138. package/docs/select.md +236 -0
  139. package/docs/separator.md +116 -0
  140. package/docs/slider.md +184 -0
  141. package/docs/switch.md +98 -0
  142. package/docs/tabs.md +254 -0
  143. package/docs/theming.md +132 -0
  144. package/docs/toast.md +311 -0
  145. package/docs/toggle-group.md +119 -0
  146. package/docs/toggle.md +94 -0
  147. package/docs/toolbar.md +138 -0
  148. package/docs/tooltip.md +128 -0
  149. package/package.json +102 -3
@@ -0,0 +1,520 @@
1
+ // Menu (decision-62, TASK-357): a popover menu with the APG Menu Button contract. The trigger opens
2
+ // the popup through `popovertarget` and CSS anchor positioning places it, so it opens in server HTML
3
+ // with no JavaScript. `lu-menu` (./menu.script.ts, loaded through the client-script import of
4
+ // decision-63) adds the keyboard, focus and item behaviour the platform lacks. The DOM is the only
5
+ // state: the popup's `toggle` event and the items' `checkedchange` events report every change, so
6
+ // `onOpenChange`, `onCheckedChange` and `onValueChange` hear what the keyboard, a click or a prop
7
+ // did. Ported from Timeless's approach (https://timeless.build), with Base UI's anatomy.
8
+ import {
9
+ createContext,
10
+ effect,
11
+ isServer,
12
+ onCleanup,
13
+ ref,
14
+ signal,
15
+ useContext,
16
+ useId,
17
+ untrack,
18
+ type Signal,
19
+ } from "lumiose";
20
+ import type { MenuCheckedChangeDetail, MenuElement, MenuOpenChangeDetail } from "./menu.script.ts";
21
+ import menuScript from "./menu.script.ts?client";
22
+ import { anchorName, placement, withStyle, type Align, type Side } from "../placement/placement.ts";
23
+ import { OUTSIDE_TOOLBAR, ToolbarContext } from "../toolbar/context.ts";
24
+
25
+ declare module "lumiose/jsx-runtime" {
26
+ namespace JSX {
27
+ interface IntrinsicElements {
28
+ /**
29
+ * Menu's element, rendered by `Menu.Root` and `Menu.SubmenuRoot`. Property `open`; event
30
+ * `openchange` (`detail.open`); its checkbox and radio items fire `checkedchange`
31
+ * (`detail.checked`).
32
+ */
33
+ "lu-menu": JSX.HTMLProps<MenuElement> & { open?: boolean };
34
+ }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * `Menu.Root` renders `<lu-menu>`, an element with no box that holds the menu's state and adds its
40
+ * keyboard and item behaviour. `bind:open={signal}` stands in for `open` and `onOpenChange`.
41
+ */
42
+ export interface MenuRootProps {
43
+ /**
44
+ * Opens (`true`) or closes (`false`) the menu when it changes, in the browser. Pair it with
45
+ * `onOpenChange` to keep a signal in sync; leave both out and the menu keeps its own state.
46
+ * Server HTML always starts closed: a popover only opens from its trigger or script.
47
+ */
48
+ open?: boolean;
49
+ /** Called after the menu opens or closes, whatever opened or closed it. */
50
+ onOpenChange?: (open: boolean) => void;
51
+ /** The other parts: a `Trigger` (or `SubmenuTrigger`) and the `Popup`. */
52
+ children?: JSX.Element;
53
+ }
54
+
55
+ /** A submenu: `Root`'s props, inside a `Menu.Popup`. */
56
+ export type MenuSubmenuRootProps = MenuRootProps;
57
+
58
+ /**
59
+ * The `<button>` that opens the menu (`popovertarget`, `aria-haspopup="menu"`); `aria-expanded`
60
+ * follows the menu where the client script runs.
61
+ */
62
+ export type MenuTriggerProps = JSX.IntrinsicElements["button"];
63
+
64
+ /**
65
+ * The `<div role="menu" popover>` of items, placed next to its trigger by CSS anchor positioning
66
+ * and labelled by the trigger.
67
+ */
68
+ export type MenuPopupProps = JSX.IntrinsicElements["div"] & {
69
+ /**
70
+ * The trigger's side the popup goes on: `"bottom"` (default for a menu), `"top"`, `"left"`,
71
+ * `"right"`, `"inline-start"` or `"inline-end"` (default for a submenu). It flips to the other
72
+ * side when it does not fit.
73
+ */
74
+ side?: Side;
75
+ /** How the popup aligns with the trigger along that side. Default `"start"`. */
76
+ align?: Align;
77
+ /** The gap between the trigger and the popup, in CSS pixels. */
78
+ sideOffset?: number;
79
+ /** A shift along the side, away from the aligned edge, in CSS pixels. */
80
+ alignOffset?: number;
81
+ };
82
+
83
+ interface ItemProps {
84
+ /** Unavailable: it can be highlighted, but it does not run (`aria-disabled`). */
85
+ disabled?: boolean;
86
+ /** The text typeahead matches, when the item's text content is not it. */
87
+ label?: string;
88
+ /**
89
+ * Whether running the item closes the menu. Default `true` for items and links, `false` for
90
+ * checkbox and radio items.
91
+ */
92
+ closeOnClick?: boolean;
93
+ }
94
+
95
+ /**
96
+ * A `<button role="menuitem">`: a command. Running it closes the menu and returns focus to the
97
+ * trigger before its `onClick` runs.
98
+ */
99
+ export type MenuItemProps = Omit<JSX.IntrinsicElements["button"], "disabled"> & ItemProps;
100
+
101
+ /** An `<a role="menuitem">`: a link. A Ctrl, Cmd or Shift click or a download leaves the menu open. */
102
+ export type MenuLinkItemProps = JSX.IntrinsicElements["a"] & Omit<ItemProps, "disabled">;
103
+
104
+ /**
105
+ * An element's props for a part that has a bindable prop. Lumiose marks intrinsic elements' props
106
+ * with a symbol key and offers no `bind:` on props that carry it; leaving the symbol out offers
107
+ * `bind:checked` and `bind:value` on the part.
108
+ */
109
+ type BindableProps<Tag extends keyof JSX.IntrinsicElements, Omitted extends string = never> = Omit<
110
+ JSX.IntrinsicElements[Tag],
111
+ symbol | Omitted
112
+ >;
113
+
114
+ /** A `<button role="menuitemcheckbox">` with `aria-checked`. It stays open when it toggles. */
115
+ export type MenuCheckboxItemProps = BindableProps<"button", "disabled"> &
116
+ ItemProps & {
117
+ /** Checks (`true`) or unchecks it when it changes. Pair it with `onCheckedChange`. */
118
+ checked?: boolean;
119
+ /** Whether it starts checked when `checked` is left out. */
120
+ defaultChecked?: boolean;
121
+ /** Called after a click, Enter or Space checks or unchecks it. */
122
+ onCheckedChange?: (checked: boolean) => void;
123
+ };
124
+
125
+ /** A `<div role="group">` of items, named by its `GroupLabel`. */
126
+ export type MenuGroupProps = JSX.IntrinsicElements["div"];
127
+
128
+ /** A `<div role="presentation">` that names its group. */
129
+ export type MenuGroupLabelProps = JSX.IntrinsicElements["div"];
130
+
131
+ /**
132
+ * A `<div role="group">` of radio items, of which one is checked. `bind:value={signal}` stands in
133
+ * for `value` and `onValueChange`.
134
+ */
135
+ export type MenuRadioGroupProps = BindableProps<"div"> & {
136
+ /** Checks the radio item with this `value` when it changes. Pair it with `onValueChange`. */
137
+ value?: string;
138
+ /** The radio item checked at first when `value` is left out. */
139
+ defaultValue?: string;
140
+ /** Called with the `value` of the radio item a click, Enter or Space checks. */
141
+ onValueChange?: (value: string) => void;
142
+ };
143
+
144
+ /** A `<button role="menuitemradio">` with `aria-checked`, inside a `RadioGroup`. */
145
+ export type MenuRadioItemProps = Omit<JSX.IntrinsicElements["button"], "disabled" | "value"> &
146
+ ItemProps & {
147
+ /** The value its `RadioGroup` takes when it is checked. */
148
+ value: string;
149
+ };
150
+
151
+ /** A `<div role="separator">` between items or groups. */
152
+ export type MenuSeparatorProps = JSX.IntrinsicElements["div"];
153
+
154
+ /**
155
+ * The `<button role="menuitem" aria-haspopup="menu">` that opens a submenu: an item of the parent
156
+ * `Popup`, inside its `SubmenuRoot`.
157
+ */
158
+ export type MenuSubmenuTriggerProps = Omit<JSX.IntrinsicElements["button"], "disabled"> &
159
+ Omit<ItemProps, "closeOnClick">;
160
+
161
+ /** The ids and counts Menu's parts share; ContextMenu provides it too, to reuse the parts. */
162
+ export interface MenuContextValue {
163
+ popupId: string;
164
+ triggerId: string;
165
+ /** The id the parts share; the anchor name that ties the popup to its trigger derives from it. */
166
+ id: string;
167
+ submenu: boolean;
168
+ /** How many triggers are rendered: the popup is labelled by the trigger only when there is one. */
169
+ triggers: Signal<number>;
170
+ /** The orientation of the Menubar a top-level menu is in; `undefined` outside one. */
171
+ menubar?: "horizontal" | "vertical";
172
+ }
173
+
174
+ /** What a Menubar tells the Menus inside it (../menubar/menubar.tsx). */
175
+ export interface MenubarContextValue {
176
+ orientation: "horizontal" | "vertical";
177
+ }
178
+
179
+ export const MenubarContext = createContext<MenubarContextValue>();
180
+
181
+ interface GroupContextValue {
182
+ labelId: string;
183
+ labels: Signal<number>;
184
+ }
185
+
186
+ export const MenuContext = createContext<MenuContextValue>();
187
+ const GroupContext = createContext<GroupContextValue>();
188
+ const RadioContext = createContext<{ value: () => string | undefined }>();
189
+
190
+ function useMenu(): MenuContextValue {
191
+ const context = useContext(MenuContext);
192
+ if (!context) throw new Error("Menu parts must be inside <Menu.Root>.");
193
+ return context;
194
+ }
195
+
196
+ /** Counts a rendered part in `count` while it is mounted. */
197
+ function register(count: Signal<number>) {
198
+ count.update((n) => n + 1);
199
+ onCleanup(() => count.update((n) => n - 1));
200
+ }
201
+
202
+ /** The item attributes shared by every kind of item. */
203
+ function itemAttributes({ disabled, label, closeOnClick }: ItemProps) {
204
+ return {
205
+ "aria-disabled": disabled || undefined,
206
+ "data-label": label,
207
+ "data-close-on-click": closeOnClick === undefined ? undefined : String(closeOnClick),
208
+ };
209
+ }
210
+
211
+ /** The state and element of `Root` and `SubmenuRoot`, rendered by either. */
212
+ function menuRoot(props: MenuRootProps, submenu: boolean) {
213
+ // Adds the keyboard and item behaviour where JavaScript runs; the page loads it once (decision-63).
214
+ menuScript();
215
+ const host = ref<MenuElement>();
216
+ const id = useId();
217
+ // A top-level menu of a Menubar: not a submenu, and not inside another menu.
218
+ const bar =
219
+ submenu || useContext(MenuContext) !== undefined ? undefined : useContext(MenubarContext);
220
+ const context: MenuContextValue = {
221
+ popupId: `${id}-popup`,
222
+ triggerId: `${id}-trigger`,
223
+ id,
224
+ submenu,
225
+ triggers: signal(0),
226
+ menubar: bar?.orientation,
227
+ };
228
+
229
+ // The `open` prop drives the element; its `openchange` event reports back, so a signal written by
230
+ // `onOpenChange` sees the same value again and nothing loops. A menu opened from the server HTML
231
+ // before the island hydrated stays open, and the signal hears of it.
232
+ let mounted = false;
233
+ effect(() => {
234
+ const element = host();
235
+ const wanted = props.open;
236
+ if (!element?.isConnected || wanted === undefined) return;
237
+ if (!mounted && !wanted && element.open) untrack(() => props.onOpenChange?.(true));
238
+ else element.open = wanted;
239
+ mounted = true;
240
+ });
241
+
242
+ return (
243
+ <MenuContext value={context}>
244
+ <lu-menu
245
+ ref={host}
246
+ role={context.menubar ? "none" : undefined}
247
+ on:openchange={(event: CustomEvent<MenuOpenChangeDetail>) =>
248
+ props.onOpenChange?.(event.detail.open)
249
+ }
250
+ >
251
+ {props.children}
252
+ </lu-menu>
253
+ </MenuContext>
254
+ );
255
+ }
256
+
257
+ function Root(props: MenuRootProps) {
258
+ return menuRoot(props, false);
259
+ }
260
+
261
+ function SubmenuRoot(props: MenuSubmenuRootProps) {
262
+ return menuRoot(props, true);
263
+ }
264
+
265
+ // Each part spreads the caller's props first, so they override the defaults, and sets the wiring
266
+ // last, so the role, `id`, `popovertarget` and the anchor stay consistent between the parts.
267
+
268
+ function Trigger({ children, style, ...rest }: MenuTriggerProps) {
269
+ const menu = useMenu();
270
+ // A caller's `id` names it; the parts after it point at that (TASK-441 M39).
271
+ if (rest.id) menu.triggerId = rest.id;
272
+ register(menu.triggers);
273
+ // A disabled Toolbar disables it: focusable, it opens nothing (TASK-437 M13).
274
+ const toolbar = useContext(ToolbarContext);
275
+ const off = () => toolbar?.disabled() === true;
276
+ return (
277
+ <button
278
+ type="button"
279
+ {...rest}
280
+ aria-disabled={off() ? "true" : rest["aria-disabled"]}
281
+ id={menu.triggerId}
282
+ role={menu.menubar ? "menuitem" : undefined}
283
+ popovertarget={off() ? undefined : menu.popupId}
284
+ aria-haspopup="menu"
285
+ style={withStyle(style, { "anchor-name": anchorName(menu.id) })}
286
+ >
287
+ {children}
288
+ </button>
289
+ );
290
+ }
291
+
292
+ function SubmenuTrigger({ children, disabled, label, style, ...rest }: MenuSubmenuTriggerProps) {
293
+ const menu = useMenu();
294
+ // A caller's `id` names it; the parts after it point at that (TASK-441 M39).
295
+ if (rest.id) menu.triggerId = rest.id;
296
+ register(menu.triggers);
297
+ return (
298
+ <button
299
+ type="button"
300
+ {...itemAttributes({ disabled, label })}
301
+ {...rest}
302
+ id={menu.triggerId}
303
+ role="menuitem"
304
+ popovertarget={menu.popupId}
305
+ aria-haspopup="menu"
306
+ style={withStyle(style, { "anchor-name": anchorName(menu.id) })}
307
+ >
308
+ {children}
309
+ </button>
310
+ );
311
+ }
312
+
313
+ function Popup({ children, side, align, sideOffset, alignOffset, style, ...rest }: MenuPopupProps) {
314
+ const menu = useMenu();
315
+ // A menu of a vertical Toolbar opens beside it, as one of a vertical Menubar (TASK-437 M14).
316
+ const toolbar = useContext(ToolbarContext);
317
+ const beside = () =>
318
+ menu.submenu || menu.menubar === "vertical" || toolbar?.orientation() === "vertical";
319
+ return (
320
+ <div
321
+ tabindex={-1}
322
+ aria-labelledby={menu.triggers() > 0 ? menu.triggerId : undefined}
323
+ {...rest}
324
+ id={menu.popupId}
325
+ role="menu"
326
+ popover="auto"
327
+ {...placement(
328
+ { side, align, sideOffset, alignOffset, style },
329
+ menu.id,
330
+ beside() ? "inline-end" : "bottom",
331
+ "start",
332
+ )}
333
+ >
334
+ <ToolbarContext value={OUTSIDE_TOOLBAR}>{children}</ToolbarContext>
335
+ </div>
336
+ );
337
+ }
338
+
339
+ function Item({ children, disabled, label, closeOnClick, ...rest }: MenuItemProps) {
340
+ useMenu();
341
+ return (
342
+ <button
343
+ type="button"
344
+ {...itemAttributes({ disabled, label, closeOnClick })}
345
+ {...rest}
346
+ role="menuitem"
347
+ >
348
+ {children}
349
+ </button>
350
+ );
351
+ }
352
+
353
+ function LinkItem({ children, label, closeOnClick, ...rest }: MenuLinkItemProps) {
354
+ useMenu();
355
+ return (
356
+ <a {...itemAttributes({ label, closeOnClick })} {...rest} role="menuitem">
357
+ {children}
358
+ </a>
359
+ );
360
+ }
361
+
362
+ function CheckboxItem({
363
+ children,
364
+ checked,
365
+ defaultChecked,
366
+ onCheckedChange,
367
+ disabled,
368
+ label,
369
+ closeOnClick,
370
+ ...rest
371
+ }: MenuCheckboxItemProps) {
372
+ useMenu();
373
+ return (
374
+ <button
375
+ type="button"
376
+ {...itemAttributes({ disabled, label, closeOnClick })}
377
+ {...rest}
378
+ role="menuitemcheckbox"
379
+ aria-checked={checked ?? defaultChecked ?? false}
380
+ on:checkedchange={(event: CustomEvent<MenuCheckedChangeDetail>) =>
381
+ onCheckedChange?.(event.detail.checked)
382
+ }
383
+ >
384
+ {children}
385
+ </button>
386
+ );
387
+ }
388
+
389
+ /** The `<div role="group">` of `Group` and `RadioGroup`, labelled by its `GroupLabel` if any. */
390
+ function GroupElement({
391
+ group,
392
+ children,
393
+ ...rest
394
+ }: JSX.IntrinsicElements["div"] & { group: GroupContextValue }) {
395
+ // The server writes the attributes before it renders the children: render them first to know
396
+ // whether a GroupLabel exists. The browser updates the reference when the label registers. The
397
+ // server renders once, so reading them once is the intent: `untrack` says so (no LUM2001).
398
+ const content = isServer ? untrack(() => children) : undefined;
399
+ return (
400
+ <div aria-labelledby={group.labels() > 0 ? group.labelId : undefined} {...rest} role="group">
401
+ {isServer ? content : children}
402
+ </div>
403
+ );
404
+ }
405
+
406
+ function Group(props: MenuGroupProps) {
407
+ useMenu();
408
+ const id = useId();
409
+ const group: GroupContextValue = { labelId: `${id}-label`, labels: signal(0) };
410
+ return (
411
+ <GroupContext value={group}>
412
+ <GroupElement {...props} group={group} />
413
+ </GroupContext>
414
+ );
415
+ }
416
+
417
+ function GroupLabel({ children, ...rest }: MenuGroupLabelProps) {
418
+ const group = useContext(GroupContext);
419
+ if (!group) throw new Error("Menu.GroupLabel must be inside <Menu.Group> or <Menu.RadioGroup>.");
420
+ register(group.labels);
421
+ return (
422
+ <div role="presentation" {...rest} id={group.labelId}>
423
+ {children}
424
+ </div>
425
+ );
426
+ }
427
+
428
+ function RadioGroup({
429
+ children,
430
+ value,
431
+ defaultValue,
432
+ onValueChange,
433
+ ...rest
434
+ }: MenuRadioGroupProps) {
435
+ useMenu();
436
+ const id = useId();
437
+ const group: GroupContextValue = { labelId: `${id}-label`, labels: signal(0) };
438
+ const radio = { value: () => (value === undefined ? defaultValue : value) };
439
+ return (
440
+ <GroupContext value={group}>
441
+ <RadioContext value={radio}>
442
+ <GroupElement
443
+ {...rest}
444
+ group={group}
445
+ on:checkedchange={(event: CustomEvent<MenuCheckedChangeDetail>) => {
446
+ const item = event.target as HTMLButtonElement;
447
+ if (item.getAttribute("role") === "menuitemradio" && event.detail.checked) {
448
+ onValueChange?.(item.value);
449
+ }
450
+ }}
451
+ >
452
+ {children}
453
+ </GroupElement>
454
+ </RadioContext>
455
+ </GroupContext>
456
+ );
457
+ }
458
+
459
+ function RadioItem({
460
+ children,
461
+ value,
462
+ disabled,
463
+ label,
464
+ closeOnClick,
465
+ ...rest
466
+ }: MenuRadioItemProps) {
467
+ useMenu();
468
+ const radio = useContext(RadioContext);
469
+ if (!radio) throw new Error("Menu.RadioItem must be inside <Menu.RadioGroup>.");
470
+ return (
471
+ <button
472
+ type="button"
473
+ {...itemAttributes({ disabled, label, closeOnClick })}
474
+ {...rest}
475
+ role="menuitemradio"
476
+ value={value}
477
+ aria-checked={radio.value() === value}
478
+ >
479
+ {children}
480
+ </button>
481
+ );
482
+ }
483
+
484
+ function Separator(props: MenuSeparatorProps) {
485
+ return <div {...props} role="separator" />;
486
+ }
487
+
488
+ /**
489
+ * A menu of commands, checkbox and radio items and submenus, opened by a button. No JavaScript is
490
+ * needed to open it: the trigger's `popovertarget` does, in server HTML too, and the client script
491
+ * adds the APG keyboard contract. Unstyled: style the parts through `class` and native or ARIA
492
+ * state (`:popover-open`, `[aria-expanded]`, `[aria-checked]`, `[aria-disabled]`,
493
+ * `[data-highlighted]`).
494
+ *
495
+ * @example
496
+ * <Menu.Root>
497
+ * <Menu.Trigger>Actions</Menu.Trigger>
498
+ * <Menu.Popup>
499
+ * <Menu.Item onClick={rename}>Rename</Menu.Item>
500
+ * <Menu.CheckboxItem bind:checked={pinned}>Pinned</Menu.CheckboxItem>
501
+ * <Menu.Separator />
502
+ * <Menu.Item disabled>Delete</Menu.Item>
503
+ * </Menu.Popup>
504
+ * </Menu.Root>
505
+ */
506
+ export const Menu = {
507
+ Root,
508
+ Trigger,
509
+ Popup,
510
+ Item,
511
+ LinkItem,
512
+ CheckboxItem,
513
+ Separator,
514
+ Group,
515
+ GroupLabel,
516
+ RadioGroup,
517
+ RadioItem,
518
+ SubmenuRoot,
519
+ SubmenuTrigger,
520
+ };
@@ -0,0 +1,16 @@
1
+ /*
2
+ * Menubar is Menus in a bar: `@lumiose/ui/menu.css` lays out their popups and items. `<lu-menubar>`,
3
+ * a custom element, is inline until styled: it is a row of its items (a column when vertical), and
4
+ * each menu's `<lu-menu>` adds no box.
5
+ */
6
+ @layer lu.core, lu.theme;
7
+ @import "../menu/menu.css";
8
+
9
+ @layer lu.core {
10
+ lu-menubar {
11
+ display: flex;
12
+ }
13
+ lu-menubar[aria-orientation="vertical"] {
14
+ flex-direction: column;
15
+ }
16
+ }