@cl-light-ds/ui 0.4.0 → 0.5.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 (91) hide show
  1. package/package.json +19 -17
  2. package/src/components/alert/Alert.astro +8 -2
  3. package/src/components/alert/CHANGELOG.md +9 -0
  4. package/src/components/button/Button.astro +78 -19
  5. package/src/components/button/CHANGELOG.md +22 -0
  6. package/src/components/button-group/ButtonGroup.astro +14 -3
  7. package/src/components/button-group/CHANGELOG.md +17 -0
  8. package/src/components/card/CHANGELOG.md +9 -0
  9. package/src/components/card/Card.astro +22 -0
  10. package/src/components/combobox/CHANGELOG.md +14 -0
  11. package/src/components/combobox/ComboboxTrigger.astro +31 -1
  12. package/src/components/command-search/CHANGELOG.md +27 -0
  13. package/src/components/command-search/CommandSearch.astro +515 -0
  14. package/src/components/command-search/CommandSearchGroup.astro +32 -0
  15. package/src/components/command-search/CommandSearchItem.astro +64 -0
  16. package/src/components/command-search/classes.ts +18 -0
  17. package/src/components/command-search/index.ts +12 -0
  18. package/src/components/command-search/nothing-matches.svg +42 -0
  19. package/src/components/dialog/CHANGELOG.md +13 -0
  20. package/src/components/dialog/DialogClose.astro +26 -12
  21. package/src/components/dropdown-menu/CHANGELOG.md +7 -0
  22. package/src/components/dropdown-menu/dropdown-menu-item-variants.ts +1 -1
  23. package/src/components/empty-state/CHANGELOG.md +18 -0
  24. package/src/components/empty-state/EmptyState.astro +157 -0
  25. package/src/components/empty-state/illustrations/AllCaughtUp.astro +52 -0
  26. package/src/components/empty-state/illustrations/EmptyWorkspaces.astro +56 -0
  27. package/src/components/empty-state/illustrations/LoadError.astro +61 -0
  28. package/src/components/empty-state/illustrations/NoResults.astro +60 -0
  29. package/src/components/empty-state/illustrations/NothingMatches.astro +66 -0
  30. package/src/components/empty-state/illustrations/index.ts +27 -0
  31. package/src/components/empty-state/index.ts +7 -0
  32. package/src/components/favorite-toggle/CHANGELOG.md +13 -0
  33. package/src/components/favorite-toggle/FavoriteToggle.astro +94 -0
  34. package/src/components/favorite-toggle/index.ts +1 -0
  35. package/src/components/input-group/CHANGELOG.md +7 -0
  36. package/src/components/input-group/InputGroup.astro +2 -0
  37. package/src/components/loading-overlay/CHANGELOG.md +25 -0
  38. package/src/components/loading-overlay/LoadingOverlay.astro +179 -0
  39. package/src/components/loading-overlay/index.ts +8 -0
  40. package/src/components/notifications-menu/CHANGELOG.md +22 -0
  41. package/src/components/notifications-menu/NotificationsMenu.astro +374 -0
  42. package/src/components/notifications-menu/NotificationsMenuItem.astro +129 -0
  43. package/src/components/notifications-menu/all-caught-up.svg +28 -0
  44. package/src/components/notifications-menu/index.ts +3 -0
  45. package/src/components/notifications-menu/types.ts +32 -0
  46. package/src/components/option-card/CHANGELOG.md +16 -0
  47. package/src/components/option-card/OptionCard.astro +135 -0
  48. package/src/components/option-card/index.ts +1 -0
  49. package/src/components/organisms/top-bar/CHANGELOG.md +21 -0
  50. package/src/components/organisms/top-bar/TopBar.astro +264 -0
  51. package/src/components/organisms/top-bar/index.ts +2 -0
  52. package/src/components/organisms/top-bar/types.ts +30 -0
  53. package/src/components/organisms/workspace/CHANGELOG.md +73 -0
  54. package/src/components/organisms/workspace/OrganisationOverview.astro +62 -0
  55. package/src/components/organisms/workspace/RecentWorkspaceCard.astro +235 -0
  56. package/src/components/organisms/workspace/RecentWorkspaces.astro +93 -0
  57. package/src/components/organisms/workspace/WorkspaceAvatar.astro +144 -0
  58. package/src/components/organisms/workspace/WorkspaceCard.astro +172 -0
  59. package/src/components/organisms/workspace/WorkspaceGlyph.astro +62 -0
  60. package/src/components/organisms/workspace/WorkspaceIcon.astro +42 -0
  61. package/src/components/organisms/workspace/WorkspaceIconPicker.astro +288 -0
  62. package/src/components/organisms/workspace/WorkspaceIconPickerGlyph.astro +44 -0
  63. package/src/components/organisms/workspace/icons.ts +98 -0
  64. package/src/components/organisms/workspace/index.ts +35 -0
  65. package/src/components/organisms/workspace/labels.ts +54 -0
  66. package/src/components/organisms/workspace/recent-card-rings-strong.png +0 -0
  67. package/src/components/organisms/workspace/types.ts +30 -0
  68. package/src/components/profile-menu/CHANGELOG.md +22 -0
  69. package/src/components/profile-menu/ProfileMenu.astro +236 -0
  70. package/src/components/profile-menu/index.ts +2 -0
  71. package/src/components/profile-menu/types.ts +27 -0
  72. package/src/components/search-field/CHANGELOG.md +14 -0
  73. package/src/components/search-field/SearchField.astro +163 -0
  74. package/src/components/search-field/index.ts +1 -0
  75. package/src/components/table/CHANGELOG.md +9 -0
  76. package/src/components/table/TableCell.astro +6 -0
  77. package/src/components/table/TableHead.astro +5 -1
  78. package/src/components/table/TableRow.astro +3 -1
  79. package/src/components/table/table-cell-variants.ts +2 -0
  80. package/src/components/toast/CHANGELOG.md +32 -0
  81. package/src/components/toast/Toast.astro +145 -0
  82. package/src/components/toast/Toaster.astro +322 -0
  83. package/src/components/toast/index.ts +9 -0
  84. package/src/components/toggle/CHANGELOG.md +7 -0
  85. package/src/lib/command-search.ts +73 -0
  86. package/src/lib/loading-overlay.ts +174 -0
  87. package/src/lib/toast.ts +127 -0
  88. package/src/lib/toggle-shared.ts +4 -1
  89. package/src/lib/workspace-palette.ts +119 -0
  90. package/src/styles/CHANGELOG.md +22 -0
  91. package/src/styles/globals.css +87 -0
@@ -0,0 +1,66 @@
1
+ ---
2
+ /**
3
+ * Illustration "nothing-matches" (184×124, with its own padding inside the viewBox).
4
+ * The designer's artwork; colours mapped to design tokens so it follows the theme.
5
+ * Decorative. Generated from the workspace selection handover assets (N15).
6
+ */
7
+ import type { HTMLAttributes } from "astro/types";
8
+
9
+ type Props = Omit<HTMLAttributes<"svg">, "width" | "height">;
10
+
11
+ const props = Astro.props as Props;
12
+ // Unique ids per instance, so several illustrations on one page don't share filters.
13
+ const uid = `es-nothing-matches-${Math.random().toString(36).slice(2, 8)}`;
14
+ ---
15
+
16
+ <svg
17
+ xmlns="http://www.w3.org/2000/svg"
18
+ width="184"
19
+ height="124"
20
+ viewBox="-12 -12 184 124"
21
+ fill="none"
22
+ aria-hidden="true"
23
+ focusable="false"
24
+ {...props}
25
+ >
26
+ <defs>
27
+ <filter id={`${uid}-sBack`} x="-30%" y="-30%" width="160%" height="160%" color-interpolation-filters="sRGB">
28
+ <feDropShadow dx="0" dy="1.5" stdDeviation="2" flood-color="#051729" flood-opacity="0.05"></feDropShadow>
29
+ </filter>
30
+ <filter id={`${uid}-sFront`} x="-40%" y="-30%" width="180%" height="170%" color-interpolation-filters="sRGB">
31
+ <feDropShadow dx="0" dy="5" stdDeviation="6" flood-color="#051729" flood-opacity="0.10"></feDropShadow>
32
+ </filter>
33
+ <filter id={`${uid}-sLens`} x="-60%" y="-60%" width="220%" height="220%" color-interpolation-filters="sRGB">
34
+ <feDropShadow dx="0" dy="4" stdDeviation="4" flood-opacity="0.28" style="flood-color:var(--primary)"></feDropShadow>
35
+ </filter>
36
+ </defs>
37
+ <circle cx="80" cy="50" r="44" stroke-width="12" style="stroke:color-mix(in oklab, var(--primary-50) 50%, var(--background))"></circle>
38
+ <circle cx="80" cy="50" r="24" stroke-width="10" style="stroke:var(--primary-50)"></circle>
39
+ <g transform="rotate(-12 44 52)">
40
+ <g filter={`url(#${uid}-sBack)`}><rect x="22" y="26" width="44" height="52" rx="8" style="fill:var(--background)"></rect></g>
41
+ <rect x="22.5" y="26.5" width="43" height="51" rx="7.5" stroke-opacity="0.06" style="stroke:var(--foreground)"></rect>
42
+ <rect x="28" y="32" width="14" height="14" rx="4" style="fill:var(--primary-100)"></rect>
43
+ <rect x="28" y="54" width="24" height="4" rx="2" style="fill:var(--border)"></rect>
44
+ <rect x="28" y="62" width="16" height="4" rx="2" style="fill:var(--muted)"></rect>
45
+ </g>
46
+ <g transform="rotate(12 116 52)">
47
+ <g filter={`url(#${uid}-sBack)`}><rect x="94" y="26" width="44" height="52" rx="8" style="fill:var(--background)"></rect></g>
48
+ <rect x="94.5" y="26.5" width="43" height="51" rx="7.5" stroke-opacity="0.06" style="stroke:var(--foreground)"></rect>
49
+ <rect x="100" y="34" width="28" height="4" rx="2" style="fill:var(--border)"></rect>
50
+ <rect x="100" y="42" width="32" height="4" rx="2" style="fill:var(--muted)"></rect>
51
+ <rect x="100" y="50" width="22" height="4" rx="2" style="fill:var(--muted)"></rect>
52
+ <rect x="100" y="58" width="28" height="4" rx="2" style="fill:var(--muted)"></rect>
53
+ </g>
54
+ <g filter={`url(#${uid}-sFront)`}><rect x="56" y="18" width="48" height="58" rx="9" style="fill:var(--background)"></rect></g>
55
+ <rect x="56.5" y="18.5" width="47" height="57" rx="8.5" stroke-opacity="0.06" style="stroke:var(--foreground)"></rect>
56
+ <rect x="62" y="24" width="36" height="22" rx="5" style="fill:var(--primary-50)"></rect>
57
+ <path d="M77 30.5 L84.5 35 L77 39.5 Z" stroke-width="1.5" stroke-linejoin="round" style="fill:var(--primary-300);stroke:var(--primary-300)"></path>
58
+ <rect x="62" y="53" width="28" height="4" rx="2" style="fill:var(--border)"></rect>
59
+ <rect x="62" y="61" width="18" height="4" rx="2" style="fill:var(--muted)"></rect>
60
+ <g filter={`url(#${uid}-sLens)`}>
61
+ <line x1="113.5" y1="79.5" x2="122" y2="88" stroke-width="6" stroke-linecap="round" style="stroke:var(--primary-700)"></line>
62
+ <circle cx="104" cy="70" r="13" stroke-width="4" style="fill:var(--background);stroke:var(--primary)"></circle>
63
+ </g>
64
+ <line x1="100" y1="66" x2="108" y2="74" stroke-width="2.2" stroke-linecap="round" style="stroke:var(--primary-300)"></line>
65
+ <line x1="108" y1="66" x2="100" y2="74" stroke-width="2.2" stroke-linecap="round" style="stroke:var(--primary-300)"></line>
66
+ </svg>
@@ -0,0 +1,27 @@
1
+ import AllCaughtUp from "./AllCaughtUp.astro";
2
+ import EmptyWorkspaces from "./EmptyWorkspaces.astro";
3
+ import LoadError from "./LoadError.astro";
4
+ import NoResults from "./NoResults.astro";
5
+ import NothingMatches from "./NothingMatches.astro";
6
+
7
+ /**
8
+ * Built-in empty state illustrations. Each has its own padding inside the
9
+ * viewBox; `pull` is the negative vertical margin that takes it back out, so
10
+ * the drawing sits at the message's normal spacing.
11
+ */
12
+ export const emptyStateIllustrations = {
13
+ /** Nothing created yet (280×200, page). */
14
+ empty: { component: EmptyWorkspaces, width: 280, height: 200, pull: "-my-6" },
15
+ /** A search found nothing (280×200, page). */
16
+ "no-results": { component: NoResults, width: 280, height: 200, pull: "-my-6" },
17
+ /** Something failed to load (280×200, page). */
18
+ error: { component: LoadError, width: 280, height: 200, pull: "-my-6" },
19
+ /** Nothing new, e.g. notifications (144×104, menu). */
20
+ "all-caught-up": { component: AllCaughtUp, width: 144, height: 104, pull: "-my-2" },
21
+ /** A search in a menu or command palette found nothing (184×124, menu). */
22
+ "nothing-matches": { component: NothingMatches, width: 184, height: 124, pull: "-my-3" },
23
+ } as const;
24
+
25
+ export type EmptyStateIllustrationName = keyof typeof emptyStateIllustrations;
26
+
27
+ export const EMPTY_STATE_ILLUSTRATIONS = Object.keys(emptyStateIllustrations) as EmptyStateIllustrationName[];
@@ -0,0 +1,7 @@
1
+ export { default as EmptyState } from "./EmptyState.astro";
2
+ export { emptyStateVariants } from "./EmptyState.astro";
3
+ export {
4
+ EMPTY_STATE_ILLUSTRATIONS,
5
+ emptyStateIllustrations,
6
+ type EmptyStateIllustrationName,
7
+ } from "./illustrations";
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `FavoriteToggle` component will be documented in this file.
4
+
5
+ ## Date 15th Oct 2026
6
+ ## Workspace selection handover (N8)
7
+
8
+ ### Added
9
+
10
+ - `FavoriteToggle`: a `Toggle` with `variant="ghost"` and a Lucide `Star`. Pressed fills the star with the `favorite` token (was `badge-warning` in the screen, gap G3); no pressed background, the normal hover background on hover (C12).
11
+ - Props: `label` (the accessible name, required), `defaultPressed`, `disabled`, `size` (Toggle sizes, default `sm`, 32px), `class`, and any button attribute. State is `aria-pressed`; changes fire the Toggle runtime's `toggle:change`. `data-favorite-toggle` marks it for scripts.
12
+ - "Only on a favourite, or on hover or focus" is left to the consumer: documented as a `group/*` + `opacity-0 group-hover/*:opacity-100 group-focus-within/*:opacity-100 aria-pressed:opacity-100` recipe, so the star stays in the Tab order.
13
+ - Storybook: "Favorite Toggle" under Actions.
@@ -0,0 +1,94 @@
1
+ ---
2
+ /**
3
+ * @component FavoriteToggle
4
+ * @title Favorite Toggle
5
+ * @description A star that adds something to favourites or takes it out. A ghost `Toggle`: pressed shows as a star filled with the `favorite` colour, with no pressed background.
6
+ *
7
+ * @preview
8
+ *
9
+ * <div class="flex items-center gap-2">
10
+ * <FavoriteToggle label="Favourite Acme Logistics" />
11
+ * <FavoriteToggle label="Favourite Northwind" defaultPressed />
12
+ * </div>
13
+ *
14
+ * @usage
15
+ *
16
+ * ```astro nocollapse
17
+ * ---
18
+ * import { FavoriteToggle } from '@cl-light-ds/ui/components/favorite-toggle';
19
+ * ---
20
+ *
21
+ * <FavoriteToggle label="Favourite Acme Logistics" defaultPressed={item.starred} />
22
+ * ```
23
+ *
24
+ * `label` names what is favourited ("Favourite Acme Logistics"); the state is
25
+ * announced through `aria-pressed`, so the label stays the same either way.
26
+ *
27
+ * ## Only when favourited, or on hover / focus
28
+ *
29
+ * Not built in. Put a `group/<name>` on the container and reveal the star with
30
+ * classes, so it is always there for keyboard and screen reader users:
31
+ *
32
+ * ```astro nocollapse
33
+ * <div class="group/card relative ...">
34
+ * <FavoriteToggle
35
+ * label="Favourite Acme Logistics"
36
+ * class="opacity-0 group-hover/card:opacity-100 group-focus-within/card:opacity-100 aria-pressed:opacity-100"
37
+ * />
38
+ * </div>
39
+ * ```
40
+ *
41
+ * @api
42
+ *
43
+ * ### Events
44
+ *
45
+ * | Event | Detail | Description |
46
+ * |-------|--------|-------------|
47
+ * | `toggle:change` | `{ pressed: boolean }` | Fired by the Toggle runtime when the state changes |
48
+ *
49
+ * Set it from outside with `toggle:set` (`{ detail: { value: true } }`), as for `Toggle`.
50
+ */
51
+
52
+ import type { HTMLAttributes } from "astro/types";
53
+ import { Star } from "@lucide/astro";
54
+ import { cn } from "@cl-light-ds/ui/lib/utils";
55
+ import type { ToggleSize } from "@cl-light-ds/ui/lib/toggle-shared";
56
+ import { Toggle } from "../toggle";
57
+
58
+ interface Props extends Omit<HTMLAttributes<"button">, "class"> {
59
+ /** Accessible name, e.g. "Favourite Acme Logistics". */
60
+ label: string;
61
+ /** Initially a favourite. */
62
+ defaultPressed?: boolean;
63
+ disabled?: boolean;
64
+ /** Toggle size; the star is 16px at every size. */
65
+ size?: ToggleSize;
66
+ class?: string;
67
+ }
68
+
69
+ const {
70
+ label,
71
+ defaultPressed = false,
72
+ disabled = false,
73
+ size = "sm",
74
+ class: className = "",
75
+ ...rest
76
+ } = Astro.props as Props;
77
+ ---
78
+
79
+ <Toggle
80
+ {...rest}
81
+ variant="ghost"
82
+ size={size}
83
+ defaultPressed={defaultPressed}
84
+ disabled={disabled}
85
+ aria-label={label}
86
+ data-favorite-toggle
87
+ class={cn("transition-[color,box-shadow,opacity]", className)}
88
+ >
89
+ <Star
90
+ data-slot="favorite-toggle-icon"
91
+ class="size-4 group-aria-pressed/toggle:fill-favorite group-aria-pressed/toggle:text-favorite"
92
+ aria-hidden="true"
93
+ />
94
+ </Toggle>
@@ -0,0 +1 @@
1
+ export { default as FavoriteToggle } from "./FavoriteToggle.astro";
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to the `InputGroup` component family (`InputGroup`, `InputGroupAddon`, `InputGroupButton`, `InputGroupInput`, `InputGroupText`, `InputGroupTextarea`) are documented in this file.
4
4
 
5
+ ## Date 15th Oct 2026
6
+ ## Workspace selection handover (C5)
7
+
8
+ - An input directly after an `InputGroupAddon align="inline-start"` holding `InputGroupText` has no left padding, so a prefix such as `lightning.com/` runs straight into what's typed. Icon prefixes are unchanged. Needs the addon before the input in the markup.
9
+ - Storybook: "Text prefix" preview.
10
+
11
+
5
12
  ## 2026-09-10 — Figma alignment (ClickLearn Lightning Design Library, node `2282-6923`)
6
13
 
7
14
  Figma documents a single "Default" composition with three example rows: a lead-icon search input with trailing supportive text, a plain input with a trailing icon, and a multi-line textarea with a toolbar (icon button + ghost text button + supportive text + separator + circular primary send button). No Sizes/States/Variants pages exist for this component in Figma (unlike `Input`) — scope is narrower accordingly.
@@ -99,6 +99,8 @@ const {
99
99
  data-slot="input-group"
100
100
  data-size={size}
101
101
  class={cn(
102
+ // A text prefix ("…lightning.com/"): the input starts right after it, with no left padding.
103
+ "[&>[data-align=inline-start]:has(>[data-slot=input-group-text])+[data-slot=input-group-control]]:pl-0!",
102
104
  "group/input-group border-input bg-card has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 data-[size=sm]:h-8 data-[size=default]:h-9 data-[size=lg]:h-10 rounded-lg border shadow-xs transition-[color,box-shadow] in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto! has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto! has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto!",
103
105
  className,
104
106
  )}
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `LoadingOverlay` component (and its controller in `@cl-light-ds/ui/lib/loading-overlay`) are documented in this file.
4
+
5
+ ## Date 15th Oct 2026
6
+ ## Workspace selection handover (N14)
7
+
8
+ New component, replacing the workspace-selection screen's hand-composed `OpeningOverlay.astro` and its `runOverlay` / `revealWorkspace` / `hideOverlay` code.
9
+
10
+ ### `LoadingOverlay`
11
+
12
+ - The whole screen dimmed and blurred (`bg-foreground/60`, `backdrop-blur-sm`), a 48px `Spinner`, a 30px title (`font-heading text-3xl font-semibold`) and a status line (`text-base`, `background/80`) (D117, D120).
13
+ - Props: `title`, `lines` (status lines), `interval` (ms between lines, default 900, D135), `loop` (cycle instead of stopping on the last line), `open` (render shown).
14
+ - `behind` slot: content shown under the dimming, faded in with `setLoadingOverlayBehind(true)` or `showLoadingOverlay({ behind: true | ms })` (D135, D191). Generalises the screen's workspace iframe.
15
+ - Reveal (D143): `revealLoadingOverlay()` fades the dimming (700ms) and the spinner and text (500ms) away, then hides; returns a Promise.
16
+ - A11y: `aria-busy="true"` while running; the title and status line are a polite live region (`role="status"`); the spinner is hidden from assistive tech (the text says it). On show, focus moves to the overlay (off whatever it covers) and returns on hide.
17
+ - `prefers-reduced-motion`: no fades on reveal or behind.
18
+
19
+ ### Controller (`@cl-light-ds/ui/lib/loading-overlay`)
20
+
21
+ - `showLoadingOverlay`, `setLoadingOverlayBehind`, `revealLoadingOverlay`, `hideLoadingOverlay`, and the matching `loading-overlay:*` events on the element; fires `loading-overlay:change` (`{ open }`).
22
+
23
+ ### Storybook
24
+
25
+ - "Loading Overlay" section: run it with changing lines and a reveal, with content behind, and pinned open in a frame.
@@ -0,0 +1,179 @@
1
+ ---
2
+ /**
3
+ * @component LoadingOverlay
4
+ * @title Loading Overlay
5
+ * @description The global loading pattern: the whole screen dimmed and blurred, a 48px spinner, a 30px title and status lines that change as the work goes on. It can show what comes next behind the dimming, then fade away to reveal it.
6
+ *
7
+ * @preview
8
+ *
9
+ * <LoadingOverlay
10
+ * id="demo-overlay"
11
+ * title="Opening workspace…"
12
+ * lines={["Loading your recordings…", "Fetching guides and assets…", "Getting your workspace ready…"]}
13
+ * />
14
+ * <Button data-demo-overlay>Show for 4 seconds</Button>
15
+ *
16
+ * @usage
17
+ *
18
+ * ```astro nocollapse
19
+ * ---
20
+ * import { LoadingOverlay } from '@cl-light-ds/ui/components/loading-overlay';
21
+ * ---
22
+ *
23
+ * <LoadingOverlay title="Creating workspace…" lines={["Creating scaffolding…", "Setting up roles and permissions…"]} />
24
+ *
25
+ * <script>
26
+ * import { showLoadingOverlay, revealLoadingOverlay } from '@cl-light-ds/ui/lib/loading-overlay';
27
+ *
28
+ * showLoadingOverlay();
29
+ * await createWorkspace();
30
+ * await revealLoadingOverlay();
31
+ * </script>
32
+ * ```
33
+ *
34
+ * @api
35
+ *
36
+ * ### JS (`@cl-light-ds/ui/lib/loading-overlay`)
37
+ *
38
+ * | Function | Description |
39
+ * |----------|-------------|
40
+ * | `showLoadingOverlay({ title, lines, interval, loop, behind, overlay })` | Show it (or restart with new text) |
41
+ * | `setLoadingOverlayBehind(show)` | Fade the `behind` slot in or out under the dimming |
42
+ * | `revealLoadingOverlay({ duration })` | Fade the dimming and text away, then hide; returns a Promise |
43
+ * | `hideLoadingOverlay()` | Hide at once |
44
+ *
45
+ * ### Events (on the overlay)
46
+ *
47
+ * Inbound: `loading-overlay:show` (detail: show options), `loading-overlay:hide`, `loading-overlay:reveal` (`{ duration }`), `loading-overlay:behind` (`{ show }`).
48
+ * Outbound: `loading-overlay:change` (`{ open }`).
49
+ *
50
+ * ### Data attributes
51
+ *
52
+ * `data-reveal` (`on` | `off`), `data-behind` (`on` | `off`), `aria-busy` while running.
53
+ */
54
+ import { cn } from "@cl-light-ds/ui/lib/utils";
55
+ import type { HTMLAttributes } from "astro/types";
56
+
57
+ import Spinner from "../spinner/Spinner.astro";
58
+
59
+ interface Props extends HTMLAttributes<"div"> {
60
+ /** The big title, e.g. "Opening workspace…". */
61
+ title?: string;
62
+ /** Status lines shown in turn under the title. */
63
+ lines?: string[];
64
+ /** ms between status lines (D135: 0.9s). */
65
+ interval?: number;
66
+ /** Start again after the last line instead of staying on it. */
67
+ loop?: boolean;
68
+ /** Render it shown (static screens, docs). Otherwise hidden until shown from JS. */
69
+ open?: boolean;
70
+ class?: string;
71
+ }
72
+
73
+ const {
74
+ title = "Loading…",
75
+ lines = [],
76
+ interval = 900,
77
+ loop = false,
78
+ open = false,
79
+ class: className = "",
80
+ ...rest
81
+ } = Astro.props as Props;
82
+
83
+ const hasBehind = Astro.slots.has("behind");
84
+ ---
85
+
86
+ <div
87
+ data-slot="loading-overlay"
88
+ data-lines={JSON.stringify(lines)}
89
+ data-interval={interval}
90
+ data-loop={loop ? "true" : "false"}
91
+ data-reveal="off"
92
+ data-behind="off"
93
+ aria-busy={open ? "true" : undefined}
94
+ tabindex="-1"
95
+ hidden={!open}
96
+ class={cn(
97
+ "group/overlay text-background fixed inset-0 z-50 flex flex-col items-center justify-center gap-5 outline-none",
98
+ className,
99
+ )}
100
+ {...rest}
101
+ >
102
+ {
103
+ /* D135, D191 — what comes next, behind the dimming; fades in on `data-behind="on"`. Not interactive. */
104
+ hasBehind && (
105
+ <div
106
+ data-slot="loading-overlay-behind"
107
+ aria-hidden="true"
108
+ inert
109
+ class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-700 group-data-[behind=on]/overlay:opacity-100 motion-reduce:transition-none"
110
+ >
111
+ <slot name="behind" />
112
+ </div>
113
+ )
114
+ }
115
+ {/* D117 — the dimming; D143 — fades away on reveal. */}
116
+ <div
117
+ data-slot="loading-overlay-backdrop"
118
+ aria-hidden="true"
119
+ class="bg-foreground/60 absolute inset-0 backdrop-blur-sm transition-opacity duration-700 group-data-[reveal=on]/overlay:opacity-0 motion-reduce:transition-none"
120
+ >
121
+ </div>
122
+ <Spinner
123
+ aria-hidden="true"
124
+ role="presentation"
125
+ class="relative size-12 transition-opacity duration-500 group-data-[reveal=on]/overlay:opacity-0 motion-reduce:transition-none"
126
+ />
127
+ <div
128
+ role="status"
129
+ aria-live="polite"
130
+ class="relative flex flex-col items-center gap-1 px-6 text-center transition-opacity duration-500 group-data-[reveal=on]/overlay:opacity-0 motion-reduce:transition-none"
131
+ >
132
+ <p data-slot="loading-overlay-title" class="font-heading text-3xl font-semibold tracking-tight">
133
+ {title}
134
+ </p>
135
+ <p
136
+ data-slot="loading-overlay-status"
137
+ class="text-background/80 text-base"
138
+ hidden={lines.length === 0}
139
+ >
140
+ {lines[0]}
141
+ </p>
142
+ </div>
143
+ </div>
144
+
145
+ <script>
146
+ import {
147
+ hideLoadingOverlay,
148
+ revealLoadingOverlay,
149
+ setLoadingOverlayBehind,
150
+ showLoadingOverlay,
151
+ type LoadingOverlayShowOptions,
152
+ } from "@cl-light-ds/ui/lib/loading-overlay";
153
+
154
+ document.querySelectorAll<HTMLElement>('[data-slot="loading-overlay"]').forEach((root) => {
155
+ if (root.dataset.wired === "true") return;
156
+ root.dataset.wired = "true";
157
+ root.addEventListener("loading-overlay:show", (event) =>
158
+ showLoadingOverlay({
159
+ ...((event as CustomEvent<LoadingOverlayShowOptions>).detail ?? {}),
160
+ overlay: root,
161
+ }),
162
+ );
163
+ root.addEventListener("loading-overlay:hide", () => hideLoadingOverlay(root));
164
+ root.addEventListener("loading-overlay:reveal", (event) =>
165
+ revealLoadingOverlay({
166
+ ...((event as CustomEvent<{ duration?: number }>).detail ?? {}),
167
+ overlay: root,
168
+ }),
169
+ );
170
+ root.addEventListener("loading-overlay:behind", (event) =>
171
+ setLoadingOverlayBehind(
172
+ (event as CustomEvent<{ show?: boolean }>).detail?.show ?? true,
173
+ root,
174
+ ),
175
+ );
176
+ // Rendered open: run its status lines.
177
+ if (!root.hidden) showLoadingOverlay({ overlay: root });
178
+ });
179
+ </script>
@@ -0,0 +1,8 @@
1
+ export { default as LoadingOverlay } from "./LoadingOverlay.astro";
2
+ export {
3
+ showLoadingOverlay,
4
+ hideLoadingOverlay,
5
+ revealLoadingOverlay,
6
+ setLoadingOverlayBehind,
7
+ } from "../../lib/loading-overlay";
8
+ export type { LoadingOverlayShowOptions } from "../../lib/loading-overlay";
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `NotificationsMenu` component family (`NotificationsMenu`, `NotificationsMenuItem`) are documented in this file.
4
+
5
+ ## Date 15th Oct 2026
6
+ ## Workspace selection handover (N10)
7
+
8
+ ### Added
9
+
10
+ - `NotificationsMenu`: a ghost `icon-sm` bell (`PopoverTrigger`) with a red `destructive` dot while any notification is unseen, opening a 360px (`w-90`) `PopoverContent` (`align="end"`, `sideOffset` 6): a header with the title and a link-style **Clear all**, a separator, a scrolling list (max 320px), and an empty state.
11
+ - `NotificationsMenuItem`: avatar (DS `Avatar size="sm"`, initials or image) or a 24px icon chip (`tone`: `default` / `success` / `warning` / `destructive`), title, text, a "context · time" line (`<time>` when `datetime` is given), a blue `primary` "new" dot centred on the right (D154, no tint, D162) with sr-only "(new)", and a ✕ (`Button` ghost `icon-xs`, C25) shown on hover / focus-within (always on coarse pointers). Default slot for extra content (e.g. a Download button); `media` slot to replace the avatar / icon.
12
+ - Items come from the `notifications` prop (JSON-serialisable data) and/or slotted `NotificationsMenuItem`s.
13
+ - Seen logic (D141, D165): an item counts as seen once `seenThreshold` (60%) of it has been in view in the open list, or at once when hovered or keyboard-focused. Hovered / focused items lose their dot immediately; scrolled-to ones when the popover closes. The bell's dot and accessible name ("Notifications — {count} new") stay in sync.
14
+ - Empty state (D203): the designer's `all-caught-up.svg` (144×104) with "You're all caught up". Copy, illustration (`emptyIllustration`, or `false`) and the whole block (`empty` slot) are overridable.
15
+ - Events on the root: `notifications:dismiss` `{ id }` and `notifications:clear` `{ ids }` (both cancelable), `notifications:seen` `{ ids }`, `notifications:change` `{ total, unseen }`. Inbound `notifications:add` `{ item, position }`; any other DOM change to the list is picked up by a `MutationObserver`.
16
+ - After a dismiss, focus moves to the next (or previous) item's ✕, else to the popover.
17
+
18
+ ### Generalised from the reference (cl-design `NotificationsPopover.astro`)
19
+
20
+ - "workspace · time" → `context` + `time`; all copy (title, labels, empty state) is props, with the reference's English as defaults except the empty description, which is now generic.
21
+ - Screen-specific export/duplicate templates are not part of the component: render a `NotificationsMenuItem` (e.g. in a `<template>`) and add it with `notifications:add`.
22
+ - The bell is the trigger itself (`PopoverTrigger` renders the `Button`), so `aria-expanded` / `aria-controls` land on it, not on a wrapper.