@cl-light-ds/ui 0.4.0 → 0.6.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 (127) hide show
  1. package/package.json +19 -17
  2. package/src/components/accordion/AccordionTrigger.astro +5 -1
  3. package/src/components/accordion/CHANGELOG.md +9 -0
  4. package/src/components/alert/Alert.astro +42 -3
  5. package/src/components/alert/CHANGELOG.md +19 -0
  6. package/src/components/button/Button.astro +78 -19
  7. package/src/components/button/CHANGELOG.md +22 -0
  8. package/src/components/button-group/ButtonGroup.astro +14 -3
  9. package/src/components/button-group/CHANGELOG.md +17 -0
  10. package/src/components/card/CHANGELOG.md +9 -0
  11. package/src/components/card/Card.astro +22 -0
  12. package/src/components/collapsible/CHANGELOG.md +9 -0
  13. package/src/components/collapsible/CollapsibleTrigger.astro +5 -1
  14. package/src/components/combobox/CHANGELOG.md +27 -0
  15. package/src/components/combobox/Combobox.astro +18 -1
  16. package/src/components/combobox/ComboboxTrigger.astro +32 -2
  17. package/src/components/command-search/CHANGELOG.md +57 -0
  18. package/src/components/command-search/CommandSearch.astro +594 -0
  19. package/src/components/command-search/CommandSearchGroup.astro +43 -0
  20. package/src/components/command-search/CommandSearchItem.astro +64 -0
  21. package/src/components/command-search/classes.ts +18 -0
  22. package/src/components/command-search/index.ts +12 -0
  23. package/src/components/command-search/nothing-matches.svg +42 -0
  24. package/src/components/dialog/CHANGELOG.md +50 -0
  25. package/src/components/dialog/Dialog.astro +14 -3
  26. package/src/components/dialog/DialogClose.astro +28 -12
  27. package/src/components/dialog/DialogContent.astro +19 -1
  28. package/src/components/dialog/DialogTrigger.astro +5 -1
  29. package/src/components/dropdown-menu/CHANGELOG.md +32 -0
  30. package/src/components/dropdown-menu/DropdownMenu.astro +13 -3
  31. package/src/components/dropdown-menu/DropdownMenuRadioGroup.astro +17 -2
  32. package/src/components/dropdown-menu/DropdownMenuTrigger.astro +7 -1
  33. package/src/components/dropdown-menu/dropdown-menu-item-variants.ts +1 -1
  34. package/src/components/empty-state/CHANGELOG.md +18 -0
  35. package/src/components/empty-state/EmptyState.astro +157 -0
  36. package/src/components/empty-state/illustrations/AllCaughtUp.astro +52 -0
  37. package/src/components/empty-state/illustrations/EmptyWorkspaces.astro +56 -0
  38. package/src/components/empty-state/illustrations/LoadError.astro +61 -0
  39. package/src/components/empty-state/illustrations/NoResults.astro +60 -0
  40. package/src/components/empty-state/illustrations/NothingMatches.astro +66 -0
  41. package/src/components/empty-state/illustrations/index.ts +27 -0
  42. package/src/components/empty-state/index.ts +7 -0
  43. package/src/components/favorite-toggle/CHANGELOG.md +13 -0
  44. package/src/components/favorite-toggle/FavoriteToggle.astro +94 -0
  45. package/src/components/favorite-toggle/index.ts +1 -0
  46. package/src/components/field/CHANGELOG.md +18 -0
  47. package/src/components/field/Field.astro +18 -0
  48. package/src/components/field/FieldError.astro +13 -1
  49. package/src/components/field/FieldGroup.astro +12 -3
  50. package/src/components/hover-card/CHANGELOG.md +9 -0
  51. package/src/components/hover-card/HoverCardTrigger.astro +5 -1
  52. package/src/components/input-group/CHANGELOG.md +7 -0
  53. package/src/components/input-group/InputGroup.astro +2 -0
  54. package/src/components/loading-overlay/CHANGELOG.md +25 -0
  55. package/src/components/loading-overlay/LoadingOverlay.astro +179 -0
  56. package/src/components/loading-overlay/index.ts +8 -0
  57. package/src/components/notifications-menu/CHANGELOG.md +29 -0
  58. package/src/components/notifications-menu/NotificationsMenu.astro +375 -0
  59. package/src/components/notifications-menu/NotificationsMenuItem.astro +130 -0
  60. package/src/components/notifications-menu/all-caught-up.svg +28 -0
  61. package/src/components/notifications-menu/index.ts +3 -0
  62. package/src/components/notifications-menu/types.ts +32 -0
  63. package/src/components/option-card/CHANGELOG.md +26 -0
  64. package/src/components/option-card/OptionCard.astro +143 -0
  65. package/src/components/option-card/index.ts +1 -0
  66. package/src/components/organisms/top-bar/CHANGELOG.md +27 -0
  67. package/src/components/organisms/top-bar/TopBar.astro +257 -0
  68. package/src/components/organisms/top-bar/index.ts +2 -0
  69. package/src/components/organisms/top-bar/types.ts +30 -0
  70. package/src/components/organisms/workspace/CHANGELOG.md +80 -0
  71. package/src/components/organisms/workspace/OrganisationOverview.astro +62 -0
  72. package/src/components/organisms/workspace/RecentWorkspaceCard.astro +235 -0
  73. package/src/components/organisms/workspace/RecentWorkspaces.astro +93 -0
  74. package/src/components/organisms/workspace/WorkspaceAvatar.astro +144 -0
  75. package/src/components/organisms/workspace/WorkspaceCard.astro +172 -0
  76. package/src/components/organisms/workspace/WorkspaceGlyph.astro +62 -0
  77. package/src/components/organisms/workspace/WorkspaceIcon.astro +42 -0
  78. package/src/components/organisms/workspace/WorkspaceIconPicker.astro +288 -0
  79. package/src/components/organisms/workspace/WorkspaceIconPickerGlyph.astro +44 -0
  80. package/src/components/organisms/workspace/icons.ts +98 -0
  81. package/src/components/organisms/workspace/index.ts +35 -0
  82. package/src/components/organisms/workspace/labels.ts +54 -0
  83. package/src/components/organisms/workspace/recent-card-rings-strong.png +0 -0
  84. package/src/components/organisms/workspace/types.ts +30 -0
  85. package/src/components/popover/CHANGELOG.md +14 -0
  86. package/src/components/popover/PopoverClose.astro +2 -1
  87. package/src/components/popover/PopoverTrigger.astro +7 -1
  88. package/src/components/profile-menu/CHANGELOG.md +22 -0
  89. package/src/components/profile-menu/ProfileMenu.astro +236 -0
  90. package/src/components/profile-menu/index.ts +2 -0
  91. package/src/components/profile-menu/types.ts +27 -0
  92. package/src/components/search-field/CHANGELOG.md +14 -0
  93. package/src/components/search-field/SearchField.astro +163 -0
  94. package/src/components/search-field/index.ts +1 -0
  95. package/src/components/select/CHANGELOG.md +8 -0
  96. package/src/components/select/SelectTrigger.astro +1 -1
  97. package/src/components/table/CHANGELOG.md +15 -0
  98. package/src/components/table/TableCell.astro +6 -0
  99. package/src/components/table/TableEditableCell.astro +2 -2
  100. package/src/components/table/TableHead.astro +5 -1
  101. package/src/components/table/TablePagination.astro +4 -4
  102. package/src/components/table/TableRow.astro +3 -1
  103. package/src/components/table/table-cell-variants.ts +2 -0
  104. package/src/components/tabs/CHANGELOG.md +16 -0
  105. package/src/components/tabs/Tabs.astro +26 -0
  106. package/src/components/tabs/TabsList.astro +30 -3
  107. package/src/components/toast/CHANGELOG.md +32 -0
  108. package/src/components/toast/Toast.astro +145 -0
  109. package/src/components/toast/Toaster.astro +322 -0
  110. package/src/components/toast/index.ts +9 -0
  111. package/src/components/toggle/CHANGELOG.md +7 -0
  112. package/src/components/toggle-group/CHANGELOG.md +11 -0
  113. package/src/components/toggle-group/ToggleGroup.astro +19 -2
  114. package/src/components/toggle-group/ToggleGroupItem.astro +10 -0
  115. package/src/components/tooltip/CHANGELOG.md +26 -0
  116. package/src/components/tooltip/Tooltip.astro +31 -3
  117. package/src/components/tooltip/TooltipTrigger.astro +5 -1
  118. package/src/lib/command-search.ts +78 -0
  119. package/src/lib/dialog-focus.ts +73 -0
  120. package/src/lib/dropdown-menu-radio-groups.ts +79 -0
  121. package/src/lib/loading-overlay.ts +174 -0
  122. package/src/lib/scroll-lock.ts +97 -0
  123. package/src/lib/toast.ts +127 -0
  124. package/src/lib/toggle-shared.ts +9 -1
  125. package/src/lib/workspace-palette.ts +119 -0
  126. package/src/styles/CHANGELOG.md +35 -0
  127. package/src/styles/globals.css +109 -0
@@ -98,6 +98,22 @@
98
98
  * </ToggleGroupItem>
99
99
  * </ToggleGroup>
100
100
  *
101
+ * ### Segmented Variant
102
+ *
103
+ * `variant="segmented"` is the outline look with the pressed item filled
104
+ * `muted` in `foreground` text, also on hover: a view switcher, where one item
105
+ * is always the current view. The fill follows the group's own pressed state
106
+ * (`aria-pressed`), so drive it with `defaultValue` or `toggle-group:set`.
107
+ *
108
+ * <ToggleGroup variant="segmented" defaultValue="card" aria-label="Collection view">
109
+ * <ToggleGroupItem value="card" aria-label="Card view">
110
+ * <LayoutGridIcon />
111
+ * </ToggleGroupItem>
112
+ * <ToggleGroupItem value="list" aria-label="List view">
113
+ * <ListIcon />
114
+ * </ToggleGroupItem>
115
+ * </ToggleGroup>
116
+ *
101
117
  * ### Sizes
102
118
  *
103
119
  * <div class="flex flex-col gap-4">
@@ -181,7 +197,8 @@ import type { HTMLAttributes } from "astro/types";
181
197
  import { cn } from "@cl-light-ds/ui/lib/utils";
182
198
 
183
199
  interface Props extends Omit<HTMLAttributes<"div">, "class"> {
184
- variant?: "default" | "outline";
200
+ /** `"segmented"`: outline, with the pressed item `muted` in `foreground` text (C26). */
201
+ variant?: "default" | "outline" | "segmented";
185
202
  size?: "xs" | "default" | "sm" | "lg";
186
203
  spacing?: number;
187
204
  defaultValue?: string | string[];
@@ -218,7 +235,7 @@ const {
218
235
  data-default-value={Array.isArray(defaultValue) ? defaultValue.join(",") : defaultValue}
219
236
  data-multiple={multiple ? "true" : undefined}
220
237
  class={cn(
221
- "rounded-lg data-[spacing=0]:data-[variant=outline]:shadow-xs",
238
+ "rounded-lg data-[spacing=0]:data-[variant=outline]:shadow-xs data-[spacing=0]:data-[variant=segmented]:shadow-xs",
222
239
  "group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-vertical:flex-col data-vertical:items-stretch",
223
240
  className
224
241
  )}
@@ -40,6 +40,16 @@ const {
40
40
  "group-data-vertical/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=outline]/toggle-group:border-t-0",
41
41
  "group-data-horizontal/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=outline]/toggle-group:first:border-s",
42
42
  "group-data-vertical/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=outline]/toggle-group:first:border-t",
43
+ // C26 — segmented: the outline's shared borders, and the pressed item
44
+ // `muted` in `foreground` text, also on hover. The pressed colours use a
45
+ // plain ancestor selector (Tailwind's group-data-* wraps the group in
46
+ // :where(), adding no weight) so they outrank the shared pressed and hover
47
+ // backgrounds.
48
+ "group-data-horizontal/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=segmented]/toggle-group:border-s-0",
49
+ "group-data-vertical/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=segmented]/toggle-group:border-t-0",
50
+ "group-data-horizontal/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=segmented]/toggle-group:first:border-s",
51
+ "group-data-vertical/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=segmented]/toggle-group:first:border-t",
52
+ "[[data-slot=toggle-group][data-variant=segmented]_&]:aria-pressed:bg-muted [[data-slot=toggle-group][data-variant=segmented]_&]:aria-pressed:text-foreground",
43
53
  className
44
54
  )}
45
55
  {...rest}
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to the `Tooltip` component family (`Tooltip`, `TooltipTrigger`, `TooltipContent`) are documented in this file.
4
4
 
5
+ ## Date 15th Oct 2026
6
+ ## Handover gaps: Tooltip on these pages (C22, gap G2)
7
+
8
+ ### Fixed
9
+
10
+ - **`TooltipTrigger asChild` binds your control, not the wrapper.** The handover recorded Tooltip as "doesn't work on these pages" (G2, workspace G10). That note dates from when the screens were static (no JavaScript), so no tooltip could work. Retested with 1.1.2 in the storybook and in a test page shaped like the screen (sticky top bar, `WorkspaceCard` with its stretched link, `ToggleGroup` view switcher, `FavoriteToggle`, a `Dialog`): hover shows the tooltip everywhere, on top of everything (portal, `z-50`), including in a dialog and over a sticky bar. What really failed, for every `asChild` trigger (the usual way to put a tooltip on a DS `Button`):
11
+ - **Keyboard:** the runtime listens for `focus` on whatever carries `data-slot="tooltip-trigger"`. With `asChild` that was the wrapper `<div>`, and `focus` doesn't bubble, so tabbing to the button never opened the tooltip.
12
+ - **Screen readers:** `aria-describedby` went on the wrapper `<div>`, not on the focused control.
13
+ - **Position:** the tooltip centred on the wrapper. In a grid cell, a flex column or a dialog body the wrapper stretches to the full width, so the tooltip sat off to one side (163px off in a 448px dialog).
14
+ - `Tooltip`'s script now hands the slot to the wrapper's first child while binding, then gives the child its own `data-slot` back (the runtime keeps the element). Keyboard focus opens the tooltip, `aria-describedby` is on the control, and the tooltip centres on it. Markup and props are unchanged.
15
+ - A **disabled** child (`:disabled` when the page loads) keeps the wrapper as trigger, as before: a disabled button fires no pointer events, so this keeps hovering it showing the tooltip.
16
+
17
+ ### Notes
18
+
19
+ - Like the other overlays, tooltips bind once, when the page loads. A tooltip added to the page later needs `create()` from `@data-slot/tooltip`, and is bound to its wrapper.
20
+ - The screens can now use `Tooltip` on icon-only controls, keeping their `aria-label`s.
21
+
22
+
23
+ ## Handover gaps: triggers don't submit forms (C29, gap G18)
24
+
25
+ ### Docs
26
+
27
+ - `TooltipTrigger` already renders `type="button"` unless you pass a `type`; no change.
28
+ - **`asChild`:** the wrapper is a `<div>`, not a button, so it can't set a type. Inside a `<form>`, give your child `type="button"` (now in the prop's JSDoc).
29
+
30
+
5
31
  ## 2026-09-18 — Figma alignment (ClickLearn Lightning Design Library, node `5275-137`)
6
32
 
7
33
  Figma documents Tooltip across four pages: Contents (6 side/align positions — already fully covered by the existing `side`/`align` props), Variants (Default, Outline), Sizes (Small, Large), and the top-level Component page.
@@ -351,7 +351,35 @@ const {
351
351
  <script>
352
352
  import { createTooltip } from "@data-slot/tooltip";
353
353
 
354
- document
355
- .querySelectorAll('[data-slot="tooltip"]')
356
- .forEach((el) => createTooltip(el));
354
+ /*
355
+ * `TooltipTrigger asChild` renders a wrapper <div data-slot="tooltip-trigger">
356
+ * around your control, and @data-slot/tooltip binds to whatever carries that
357
+ * slot. Bound to the wrapper, the tooltip never opened on keyboard focus
358
+ * (focus doesn't bubble to the div), put aria-describedby on the div instead
359
+ * of the control, and centred on the div, which stretches in grid/flex
360
+ * columns (C22). So bind the child: give it the slot while binding, then give
361
+ * its own data-slot back (the runtime keeps the element, not the selector).
362
+ * A disabled child fires no pointer events, so it keeps the wrapper as its
363
+ * trigger, as before: hovering a disabled control still shows its tooltip.
364
+ */
365
+ const bind = (root: Element) => {
366
+ const wrapper = root.querySelector<HTMLElement>('[data-slot="tooltip-trigger"][data-as-child]');
367
+ const child = wrapper?.firstElementChild as HTMLElement | null | undefined;
368
+ if (!wrapper || !child || wrapper.closest('[data-slot="tooltip"]') !== root || child.matches(":disabled")) {
369
+ createTooltip(root);
370
+ return;
371
+ }
372
+ const childSlot = child.getAttribute("data-slot");
373
+ wrapper.setAttribute("data-slot", "tooltip-trigger-wrapper");
374
+ child.setAttribute("data-slot", "tooltip-trigger");
375
+ try {
376
+ createTooltip(root);
377
+ } finally {
378
+ if (childSlot === null) child.removeAttribute("data-slot");
379
+ else child.setAttribute("data-slot", childSlot);
380
+ wrapper.setAttribute("data-slot", "tooltip-trigger");
381
+ }
382
+ };
383
+
384
+ document.querySelectorAll('[data-slot="tooltip"]').forEach(bind);
357
385
  </script>
@@ -4,7 +4,11 @@ import type { HTMLAttributes } from "astro/types";
4
4
 
5
5
  interface Props extends HTMLAttributes<"button"> {
6
6
  class?: string;
7
- /** Radix-like: render wrapper so the child can be the interactive element */
7
+ /**
8
+ * Radix-like: render wrapper so the child can be the interactive element.
9
+ * The wrapper is not a button, so the child sets its own type: give a
10
+ * `<button>`/`<Button>` child `type="button"` inside a `<form>` or it submits it.
11
+ */
8
12
  asChild?: boolean;
9
13
  }
10
14
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Helpers for the `CommandSearch` component
3
+ * (`@cl-light-ds/ui/components/command-search`).
4
+ *
5
+ * ```ts
6
+ * import { setCommandSearchResults } from "@cl-light-ds/ui/lib/command-search";
7
+ *
8
+ * // mode="remote": search on your side, then hand the results back.
9
+ * palette.addEventListener("command-search:search", async (event) => {
10
+ * const { query } = event.detail;
11
+ * setCommandSearchResults(palette, await api.search(query), { query });
12
+ * });
13
+ * ```
14
+ */
15
+
16
+ export interface CommandSearchResultItem {
17
+ /** Unique value, sent with `command-search:select`. */
18
+ value: string;
19
+ /** The row's text. */
20
+ label: string;
21
+ /** Muted text on the right, e.g. the workspace it is in. */
22
+ description?: string;
23
+ /** Navigate here when chosen. */
24
+ href?: string;
25
+ disabled?: boolean;
26
+ }
27
+
28
+ export interface CommandSearchResultGroup {
29
+ heading: string;
30
+ items: CommandSearchResultItem[];
31
+ /** Total matches, when more exist than `items` holds. Defaults to `items.length`. */
32
+ count?: number;
33
+ /**
34
+ * Show at most this many of `items`; the count stays `count ?? items.length`.
35
+ * Defaults to `CommandSearch`'s `limit`; 0 for no limit.
36
+ */
37
+ limit?: number;
38
+ }
39
+
40
+ type Target = string | HTMLElement | undefined;
41
+
42
+ const findRoot = (target: Target): HTMLElement | null => {
43
+ if (typeof document === "undefined") return null;
44
+ if (target instanceof HTMLElement) return target;
45
+ if (typeof target === "string") return document.getElementById(target);
46
+ return document.querySelector<HTMLElement>("[data-command-search]");
47
+ };
48
+
49
+ /** Open the palette (empty, focused). Defaults to the first on the page. */
50
+ export function openCommandSearch(target?: Target) {
51
+ findRoot(target)?.dispatchEvent(
52
+ new CustomEvent("dialog:set", { detail: { open: true } }),
53
+ );
54
+ }
55
+
56
+ /** Close the palette. */
57
+ export function closeCommandSearch(target?: Target) {
58
+ findRoot(target)?.dispatchEvent(
59
+ new CustomEvent("dialog:set", { detail: { open: false } }),
60
+ );
61
+ }
62
+
63
+ /**
64
+ * End a search in `mode="remote"`: render these groups as the results (or,
65
+ * with no groups, keep the rows you have put in the list yourself). `query`
66
+ * guards against stale answers: results for an older query are ignored.
67
+ */
68
+ export function setCommandSearchResults(
69
+ target: Target,
70
+ groups?: CommandSearchResultGroup[],
71
+ options: { query?: string } = {},
72
+ ) {
73
+ findRoot(target)?.dispatchEvent(
74
+ new CustomEvent("command-search:results", {
75
+ detail: { groups, query: options.query },
76
+ }),
77
+ );
78
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Focus inside popovers that open in a dialog.
3
+ *
4
+ * The popover runtime moves an open popover's content to <body> (a positioner),
5
+ * so it is outside the dialog: the dialog's focus trap sees focus there as
6
+ * "escaped" and pulls it back, and Tab from the popover's trigger skips the
7
+ * popover. A popover opened from a dialog acts as a nested modal instead:
8
+ *
9
+ * - Tab from its trigger goes into the popover (Shift+Tab to its last control);
10
+ * - Tab and Shift+Tab go round the popover's own controls;
11
+ * - Escape (the popover's own) closes it and returns to the trigger.
12
+ *
13
+ * The popover is tied to its dialog through its trigger (`aria-controls`).
14
+ * Installed once by Dialog; runs before the dialog's trap (window, capture).
15
+ */
16
+
17
+ const TABBABLE =
18
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
19
+
20
+ let installed = false;
21
+
22
+ /** The open popover whose trigger is in an open dialog and which holds or triggers `el`. */
23
+ function popoverFor(el: Element): { content: HTMLElement; fromTrigger: boolean } | null {
24
+ const content = el.closest<HTMLElement>('[data-slot="popover-content"]');
25
+ if (content) return !content.hidden && inOpenDialog(triggerOf(content)) ? { content, fromTrigger: false } : null;
26
+
27
+ const trigger = el.closest<HTMLElement>('[aria-controls][aria-expanded="true"]');
28
+ const target = trigger && document.getElementById(trigger.getAttribute("aria-controls")!);
29
+ if (target?.dataset.slot === "popover-content" && !target.hidden && inOpenDialog(trigger)) {
30
+ return { content: target, fromTrigger: true };
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function triggerOf(content: HTMLElement) {
36
+ return content.id ? document.querySelector(`[aria-controls="${CSS.escape(content.id)}"]`) : null;
37
+ }
38
+
39
+ function inOpenDialog(el: Element | null) {
40
+ return !!el?.closest('[data-slot="dialog-content"][data-open]');
41
+ }
42
+
43
+ export function trapTabInPopoversInDialogs() {
44
+ if (installed) return;
45
+ installed = true;
46
+
47
+ window.addEventListener(
48
+ "keydown",
49
+ (event) => {
50
+ if (event.key !== "Tab" || event.defaultPrevented) return;
51
+ const active = document.activeElement;
52
+ const found = active && popoverFor(active);
53
+ if (!found) return;
54
+
55
+ const tabbables = Array.from(found.content.querySelectorAll<HTMLElement>(TABBABLE)).filter(
56
+ (el) => !el.closest("[hidden]") && el.getClientRects().length > 0,
57
+ );
58
+ if (tabbables.length === 0) return;
59
+
60
+ event.preventDefault();
61
+ event.stopPropagation();
62
+ const at = found.fromTrigger ? -1 : tabbables.indexOf(active as HTMLElement);
63
+ const next =
64
+ at === -1
65
+ ? event.shiftKey
66
+ ? tabbables.length - 1
67
+ : 0
68
+ : (at + (event.shiftKey ? -1 : 1) + tabbables.length) % tabbables.length;
69
+ tabbables[next].focus();
70
+ },
71
+ true,
72
+ );
73
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Several radio groups in one DropdownMenu.
3
+ *
4
+ * The @data-slot/dropdown-menu runtime holds a single radio value per menu, so a
5
+ * second group (Sort by + Order) would untick the first. A *named*
6
+ * DropdownMenuRadioGroup is run on the runtime's checkbox values instead: its
7
+ * items are registered as checkbox items, exactly one is kept checked, and they
8
+ * are still announced as radios (`menuitemradio`).
9
+ *
10
+ * The group's value is mirrored on the group (`data-value`) and announced with
11
+ * `dropdown-menu:group-value-change` ({ name, value, previousValue }) on the root.
12
+ */
13
+
14
+ type Menu = { values: string[]; set(detail: { values?: string[] }): void };
15
+
16
+ const GROUP = '[data-slot="dropdown-menu-radio-group"][data-name]';
17
+ const ITEM = '[data-slot="dropdown-menu-radio-item"]';
18
+ const GROUPED_ITEM = "[data-radio-group-item]";
19
+
20
+ /** Before the runtime starts: turn named groups' items into checkbox items. */
21
+ export function prepareRadioGroups(root: HTMLElement) {
22
+ const defaults: string[] = [];
23
+ for (const group of root.querySelectorAll<HTMLElement>(GROUP)) {
24
+ const items = Array.from(group.querySelectorAll<HTMLElement>(ITEM));
25
+ const initial =
26
+ group.dataset.defaultValue ?? items.find((item) => item.hasAttribute("data-default-checked"))?.dataset.value;
27
+ for (const item of items) {
28
+ item.dataset.slot = "dropdown-menu-checkbox-item";
29
+ item.setAttribute("data-radio-group-item", group.dataset.name!);
30
+ item.toggleAttribute("data-default-checked", item.dataset.value === initial);
31
+ }
32
+ if (initial) {
33
+ defaults.push(initial);
34
+ group.dataset.value = initial;
35
+ }
36
+ }
37
+ // Root defaults win over item defaults in the runtime, so merge ours in.
38
+ if (defaults.length && root.dataset.defaultValues) {
39
+ const values = JSON.parse(root.dataset.defaultValues) as string[];
40
+ root.dataset.defaultValues = JSON.stringify([...values, ...defaults.filter((v) => !values.includes(v))]);
41
+ }
42
+ }
43
+
44
+ /** After the runtime starts: keep one item per group checked. */
45
+ export function bindRadioGroups(root: HTMLElement, menu: Menu) {
46
+ const items = Array.from(root.querySelectorAll<HTMLElement>(GROUPED_ITEM));
47
+ if (items.length === 0) return;
48
+ // The runtime re-sets checkbox items' role when the menu opens; keep them radios.
49
+ const asRadio = () => {
50
+ for (const item of items) if (item.getAttribute("role") !== "menuitemradio") item.setAttribute("role", "menuitemradio");
51
+ };
52
+ asRadio();
53
+ const roles = new MutationObserver(asRadio);
54
+ for (const item of items) roles.observe(item, { attributeFilter: ["role"] });
55
+
56
+ // Picking the checked item again would untick it: a radio can't be unset.
57
+ root.addEventListener("dropdown-menu:select", (event) => {
58
+ const item = (event as CustomEvent<{ item: HTMLElement | null }>).detail.item;
59
+ if (item?.hasAttribute("data-radio-group-item") && item.hasAttribute("data-checked")) event.preventDefault();
60
+ });
61
+
62
+ root.addEventListener("dropdown-menu:values-change", (event) => {
63
+ const { item, checked } = (event as CustomEvent<{ item: HTMLElement | null; checked: boolean }>).detail;
64
+ const name = item?.getAttribute("data-radio-group-item");
65
+ if (!item || !name || !checked) return;
66
+ const siblings = items.filter((other) => other !== item && other.getAttribute("data-radio-group-item") === name);
67
+ const drop = new Set(siblings.map((other) => other.dataset.value));
68
+ menu.set({ values: menu.values.filter((value) => !drop.has(value)) });
69
+
70
+ const group = item.closest<HTMLElement>(GROUP);
71
+ const previousValue = group?.dataset.value ?? null;
72
+ if (group) group.dataset.value = item.dataset.value;
73
+ root.dispatchEvent(
74
+ new CustomEvent("dropdown-menu:group-value-change", {
75
+ detail: { name, value: item.dataset.value, previousValue },
76
+ }),
77
+ );
78
+ });
79
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Controller for the `LoadingOverlay` component
3
+ * (`@cl-light-ds/ui/components/loading-overlay`). Works on the overlay's markup
4
+ * directly, so it can be called from any script, before or after the
5
+ * component's own script has run.
6
+ *
7
+ * ```ts
8
+ * import { showLoadingOverlay, revealLoadingOverlay, hideLoadingOverlay } from "@cl-light-ds/ui/lib/loading-overlay";
9
+ *
10
+ * showLoadingOverlay({ title: "Opening workspace…", lines: ["Loading your recordings…", "Fetching guides and assets…"] });
11
+ * await work();
12
+ * await revealLoadingOverlay(); // fade the dimming away to what is behind it
13
+ * ```
14
+ *
15
+ * The same is available as events on the overlay element:
16
+ * `loading-overlay:show` (detail: the show options), `loading-overlay:hide`,
17
+ * `loading-overlay:reveal` (detail: `{ duration }`), `loading-overlay:behind`
18
+ * (detail: `{ show }`). It fires `loading-overlay:change` (`{ open }`).
19
+ */
20
+
21
+ export interface LoadingOverlayShowOptions {
22
+ /** Big title, e.g. "Opening workspace…". Defaults to the component's `title`. */
23
+ title?: string;
24
+ /** Status lines shown in turn under the title. Defaults to the component's `lines`. */
25
+ lines?: string[];
26
+ /** ms between status lines. Defaults to the component's `interval` (900). */
27
+ interval?: number;
28
+ /** Start again after the last line instead of staying on it. */
29
+ loop?: boolean;
30
+ /**
31
+ * Fade in the `behind` slot's content under the dimming: `true` at once, a
32
+ * number of ms to wait first, `false` not at all (default).
33
+ */
34
+ behind?: boolean | number;
35
+ /** Which overlay: its id or element. Defaults to the first on the page. */
36
+ overlay?: string | HTMLElement;
37
+ }
38
+
39
+ type Target = string | HTMLElement | undefined;
40
+
41
+ interface RunState {
42
+ lineTimer?: number;
43
+ behindTimer?: number;
44
+ hideTimer?: number;
45
+ returnFocus?: HTMLElement | null;
46
+ }
47
+
48
+ const ROOT = '[data-slot="loading-overlay"]';
49
+ const runs = new WeakMap<HTMLElement, RunState>();
50
+
51
+ const findOverlay = (overlay: Target): HTMLElement | null => {
52
+ if (typeof document === "undefined") return null;
53
+ if (overlay instanceof HTMLElement) return overlay;
54
+ if (typeof overlay === "string") return document.getElementById(overlay);
55
+ return document.querySelector<HTMLElement>(ROOT);
56
+ };
57
+
58
+ const part = (root: HTMLElement, name: string) =>
59
+ root.querySelector<HTMLElement>(`[data-slot="loading-overlay-${name}"]`);
60
+
61
+ const readLines = (root: HTMLElement): string[] => {
62
+ try {
63
+ const lines = JSON.parse(root.dataset.lines ?? "[]");
64
+ return Array.isArray(lines) ? lines.map(String) : [];
65
+ } catch {
66
+ return [];
67
+ }
68
+ };
69
+
70
+ const stop = (root: HTMLElement) => {
71
+ const run = runs.get(root);
72
+ if (!run) return run;
73
+ window.clearInterval(run.lineTimer);
74
+ window.clearTimeout(run.behindTimer);
75
+ window.clearTimeout(run.hideTimer);
76
+ return run;
77
+ };
78
+
79
+ const emit = (root: HTMLElement, open: boolean) =>
80
+ root.dispatchEvent(new CustomEvent("loading-overlay:change", { detail: { open } }));
81
+
82
+ /** Show the overlay (or restart it with new text). */
83
+ export function showLoadingOverlay(options: LoadingOverlayShowOptions = {}) {
84
+ const root = findOverlay(options.overlay);
85
+ if (!root) return;
86
+ const wasOpen = !root.hidden;
87
+ const previous = stop(root);
88
+ const run: RunState = {
89
+ returnFocus: wasOpen
90
+ ? previous?.returnFocus
91
+ : (document.activeElement as HTMLElement | null),
92
+ };
93
+ runs.set(root, run);
94
+
95
+ const title = part(root, "title");
96
+ const status = part(root, "status");
97
+ if (title && options.title !== undefined) title.textContent = options.title;
98
+ const lines = options.lines ?? readLines(root);
99
+ const interval = options.interval ?? (Number(root.dataset.interval) || 900);
100
+ const loop = options.loop ?? root.dataset.loop === "true";
101
+ let at = 0;
102
+ if (status) {
103
+ status.textContent = lines[0] ?? "";
104
+ status.hidden = lines.length === 0;
105
+ }
106
+ if (lines.length > 1) {
107
+ run.lineTimer = window.setInterval(() => {
108
+ at = loop ? (at + 1) % lines.length : Math.min(at + 1, lines.length - 1);
109
+ if (status) status.textContent = lines[at];
110
+ }, interval);
111
+ }
112
+
113
+ root.dataset.reveal = "off";
114
+ root.dataset.behind = "off";
115
+ const behind = options.behind ?? false;
116
+ if (behind !== false) {
117
+ run.behindTimer = window.setTimeout(
118
+ () => setLoadingOverlayBehind(true, root),
119
+ behind === true ? 0 : behind,
120
+ );
121
+ }
122
+
123
+ root.hidden = false;
124
+ root.setAttribute("aria-busy", "true");
125
+ // Keyboard focus moves off whatever is now covered.
126
+ root.focus({ preventScroll: true });
127
+ if (!wasOpen) emit(root, true);
128
+ }
129
+
130
+ /** Fade the `behind` slot's content in (or out) under the dimming. */
131
+ export function setLoadingOverlayBehind(show: boolean, overlay?: Target) {
132
+ const root = findOverlay(overlay);
133
+ if (root && !root.hidden) root.dataset.behind = show ? "on" : "off";
134
+ }
135
+
136
+ /** Hide the overlay at once. */
137
+ export function hideLoadingOverlay(overlay?: Target) {
138
+ const root = findOverlay(overlay);
139
+ if (!root || root.hidden) return;
140
+ const run = stop(root);
141
+ runs.delete(root);
142
+ root.hidden = true;
143
+ root.removeAttribute("aria-busy");
144
+ root.dataset.reveal = "off";
145
+ root.dataset.behind = "off";
146
+ if (root.contains(document.activeElement) && run?.returnFocus?.isConnected)
147
+ run.returnFocus.focus({ preventScroll: true });
148
+ emit(root, false);
149
+ }
150
+
151
+ /**
152
+ * Fade the dimming, spinner and text away, revealing what is behind (the
153
+ * page, or the `behind` slot), then hide. Resolves once hidden.
154
+ */
155
+ export function revealLoadingOverlay(
156
+ options: { duration?: number; overlay?: Target } = {},
157
+ ): Promise<void> {
158
+ const root = findOverlay(options.overlay);
159
+ if (!root || root.hidden) return Promise.resolve();
160
+ const run = runs.get(root) ?? {};
161
+ window.clearInterval(run.lineTimer);
162
+ window.clearTimeout(run.hideTimer);
163
+ runs.set(root, run);
164
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
165
+ const duration = reduced ? 0 : (options.duration ?? 700);
166
+ root.dataset.reveal = "on";
167
+ root.removeAttribute("aria-busy");
168
+ return new Promise((resolve) => {
169
+ run.hideTimer = window.setTimeout(() => {
170
+ hideLoadingOverlay(root);
171
+ resolve();
172
+ }, duration);
173
+ });
174
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Page scroll lock for dialogs and menus that leaves the scrollbar where it is.
3
+ *
4
+ * The @data-slot runtimes lock scroll by setting `overflow: hidden` and
5
+ * `scrollbar-gutter: stable` on <html>. The reserved gutter isn't covered by a
6
+ * dialog's overlay (a white band down the right edge), and on a page with no
7
+ * scrollbar the gutter appears from nowhere and the page jumps sideways. So the
8
+ * components pass `lockScroll: false` to the runtime and lock here instead:
9
+ * nothing about the layout changes; the wheel, touch and the page-scrolling keys
10
+ * just don't scroll the page while a lock is held. Anything under the pointer
11
+ * that can scroll by itself (a long list in the dialog) still does, and a
12
+ * dragged scrollbar is put back.
13
+ */
14
+
15
+ let holds = 0;
16
+ let savedY = 0;
17
+ let installed = false;
18
+
19
+ /** Whether something between `target` and the page can scroll by `dy` itself. */
20
+ function scrollsItself(target: EventTarget | null, dy: number) {
21
+ for (
22
+ let el = target instanceof Element ? target : null;
23
+ el && el !== document.body && el !== document.documentElement;
24
+ el = el.parentElement
25
+ ) {
26
+ const { overflowY } = getComputedStyle(el);
27
+ if (!/(auto|scroll)/.test(overflowY)) continue;
28
+ if (el.scrollHeight <= el.clientHeight) continue;
29
+ if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight) return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ function install() {
35
+ if (installed) return;
36
+ installed = true;
37
+
38
+ window.addEventListener(
39
+ "wheel",
40
+ (event) => {
41
+ if (holds > 0 && !scrollsItself(event.target, event.deltaY)) event.preventDefault();
42
+ },
43
+ { passive: false },
44
+ );
45
+
46
+ let touchY = 0;
47
+ window.addEventListener("touchstart", (event) => (touchY = event.touches[0]?.clientY ?? 0), {
48
+ passive: true,
49
+ });
50
+ window.addEventListener(
51
+ "touchmove",
52
+ (event) => {
53
+ const dy = touchY - (event.touches[0]?.clientY ?? touchY);
54
+ if (holds > 0 && !scrollsItself(event.target, dy)) event.preventDefault();
55
+ },
56
+ { passive: false },
57
+ );
58
+
59
+ window.addEventListener("keydown", (event) => {
60
+ if (holds === 0 || !["PageUp", "PageDown", "Home", "End", " "].includes(event.key)) return;
61
+ const target = event.target as HTMLElement;
62
+ if (target.closest("input, textarea, select, button, [contenteditable]")) return;
63
+ const dy = event.key === "PageUp" || event.key === "Home" ? -1 : 1;
64
+ if (!scrollsItself(target, dy)) event.preventDefault();
65
+ });
66
+
67
+ window.addEventListener("scroll", () => {
68
+ if (holds > 0 && window.scrollY !== savedY) window.scrollTo(window.scrollX, savedY);
69
+ });
70
+ }
71
+
72
+ /** Take a lock. Locks nest: the page scrolls again once every lock is released. */
73
+ export function holdScrollLock() {
74
+ install();
75
+ if (holds === 0) savedY = window.scrollY;
76
+ holds += 1;
77
+ }
78
+
79
+ export function releaseScrollLock() {
80
+ holds = Math.max(0, holds - 1);
81
+ }
82
+
83
+ /**
84
+ * Hold the lock while `root` is open. `event` is the runtime's open-change event
85
+ * (its detail has `open`); `isOpen` reads the state for roots that start open.
86
+ */
87
+ export function lockScrollWhileOpen(root: HTMLElement, event: string, isOpen: () => boolean) {
88
+ let open = false;
89
+ const set = (next: boolean) => {
90
+ if (next === open) return;
91
+ open = next;
92
+ if (next) holdScrollLock();
93
+ else releaseScrollLock();
94
+ };
95
+ root.addEventListener(event, (e) => set(Boolean((e as CustomEvent<{ open?: boolean }>).detail?.open)));
96
+ set(isOpen());
97
+ }