@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
@@ -0,0 +1,288 @@
1
+ ---
2
+ /**
3
+ * @component WorkspaceIconPicker
4
+ * @title Workspace Icon Picker
5
+ * @description Organism. Choose a workspace's colour and icon: a 64px avatar button opens a popover with the 12 palette colours, then the name's initial and the 27 placeholder icons. The avatar follows the choice as you pick.
6
+ *
7
+ * @preview
8
+ *
9
+ * <WorkspaceIconPicker name="Acme Logistics" color="blue" icon="initial" />
10
+ *
11
+ * @usage
12
+ *
13
+ * ```astro nocollapse
14
+ * ---
15
+ * import { WorkspaceIconPicker } from '@cl-light-ds/ui/components/organisms/workspace';
16
+ * ---
17
+ *
18
+ * <form>
19
+ * <WorkspaceIconPicker name="" color="blue" icon="initial" colorName="color" iconName="icon" />
20
+ * <input name="name" data-workspace-name />
21
+ * </form>
22
+ *
23
+ * <script>
24
+ * const picker = document.querySelector('[data-slot="workspace-icon-picker"]');
25
+ * picker.addEventListener("workspace-icon-picker:change", (e) => console.log(e.detail)); // { color, icon }
26
+ * // Keep the initial in step with the name as it is typed:
27
+ * document.querySelector("[data-workspace-name]").addEventListener("input", (e) =>
28
+ * picker.dispatchEvent(new CustomEvent("workspace-icon-picker:set", { detail: { name: e.target.value } })),
29
+ * );
30
+ * </script>
31
+ * ```
32
+ *
33
+ * Colours are round swatches (the tint with a dot of the colour), ringed when
34
+ * chosen; icons are tiles, on the chosen colour's tint with a ring when
35
+ * chosen. Every swatch and tile is a button with `aria-pressed`. The popover
36
+ * stays open while you pick; Escape or a click outside closes it and returns
37
+ * focus to the avatar (popover runtime).
38
+ *
39
+ * With `colorName` / `iconName`, hidden inputs carry the choice in a form.
40
+ *
41
+ * @api
42
+ *
43
+ * ### Events (on the `[data-slot="workspace-icon-picker"]` root)
44
+ *
45
+ * | Event | Detail | Description |
46
+ * |-------|--------|-------------|
47
+ * | `workspace-icon-picker:change` | `{ color, icon }` | Fired (bubbling) when a colour or icon is picked |
48
+ * | `workspace-icon-picker:set` | `{ color?, icon?, name? }` | Dispatch to set the colour, icon, or the name the initial is taken from. Doesn't fire `change` |
49
+ */
50
+
51
+ import type { HTMLAttributes } from "astro/types";
52
+ import { cn } from "@cl-light-ds/ui/lib/utils";
53
+ import {
54
+ DEFAULT_WORKSPACE_COLOR,
55
+ WORKSPACE_COLORS,
56
+ workspacePalette,
57
+ type WorkspaceColor,
58
+ } from "@cl-light-ds/ui/lib/workspace-palette";
59
+ import { Popover, PopoverContent, PopoverTrigger } from "../../popover";
60
+ import { Separator } from "../../separator";
61
+ import { WORKSPACE_GLYPHS, workspaceGlyphLabel, type WorkspaceGlyphName } from "./icons";
62
+ import WorkspaceAvatar from "./WorkspaceAvatar.astro";
63
+ import WorkspaceIconPickerGlyph from "./WorkspaceIconPickerGlyph.astro";
64
+
65
+ interface Props extends Omit<HTMLAttributes<"div">, "class"> {
66
+ /** The workspace name, for the initial. Can change later with `workspace-icon-picker:set`. */
67
+ name?: string;
68
+ /** The chosen colour. Defaults to blue. */
69
+ color?: WorkspaceColor;
70
+ /** The chosen glyph: an icon name, or `"initial"` (default). */
71
+ icon?: WorkspaceGlyphName;
72
+ /** Name of a hidden input carrying the colour, for forms. */
73
+ colorName?: string;
74
+ /** Name of a hidden input carrying the icon, for forms. */
75
+ iconName?: string;
76
+ /** Accessible name of the avatar button. */
77
+ label?: string;
78
+ /** Caps heading and group name over the colours. */
79
+ colorLabel?: string;
80
+ /** Caps heading and group name over the icons. */
81
+ iconLabel?: string;
82
+ /** Popover alignment against the avatar. */
83
+ align?: "start" | "center" | "end";
84
+ class?: string;
85
+ }
86
+
87
+ const {
88
+ name = "",
89
+ color = DEFAULT_WORKSPACE_COLOR,
90
+ icon = "initial",
91
+ colorName,
92
+ iconName,
93
+ label = "Choose an icon and colour",
94
+ colorLabel = "Colour",
95
+ iconLabel = "Icon",
96
+ align = "start",
97
+ class: className = "",
98
+ ...rest
99
+ } = Astro.props as Props;
100
+
101
+ const swatch = workspacePalette[color] ?? workspacePalette[DEFAULT_WORKSPACE_COLOR];
102
+ const colorTitle = (c: string) => c.charAt(0).toUpperCase() + c.slice(1);
103
+ const tile =
104
+ "flex size-9 cursor-pointer items-center justify-center outline-none transition-[color,background-color,box-shadow] focus-visible:ring-3 focus-visible:ring-chart-1";
105
+ ---
106
+
107
+ <div
108
+ {...rest}
109
+ data-slot="workspace-icon-picker"
110
+ data-color={color}
111
+ data-icon={icon}
112
+ data-name={name}
113
+ class={cn("inline-flex", className)}
114
+ >
115
+ <Popover class="flex">
116
+ <PopoverTrigger
117
+ variant="ghost"
118
+ size="icon"
119
+ aria-label={label}
120
+ class="size-16 rounded-xl p-0 hover:bg-transparent hover:opacity-90 [&_svg]:size-full"
121
+ data-workspace-icon-picker-trigger
122
+ >
123
+ <WorkspaceAvatar color={color} size="xl" shape="square">
124
+ <WorkspaceIconPickerGlyph icon={icon} name={name} class="size-7 text-2xl" />
125
+ </WorkspaceAvatar>
126
+ </PopoverTrigger>
127
+
128
+ <PopoverContent align={align} class="w-80 max-w-[calc(100vw-1rem)] gap-3" data-workspace-icon-picker-content>
129
+ <p class="text-xs font-semibold tracking-wide text-muted-foreground uppercase" aria-hidden="true">
130
+ {colorLabel}
131
+ </p>
132
+ <div role="group" aria-label={colorLabel} class="grid grid-cols-6 gap-1">
133
+ {
134
+ WORKSPACE_COLORS.map((c) => (
135
+ <button
136
+ type="button"
137
+ class={cn(tile, "rounded-full hover:bg-background-hover aria-pressed:ring-2 aria-pressed:ring-ring")}
138
+ aria-label={colorTitle(c)}
139
+ aria-pressed={c === color ? "true" : "false"}
140
+ data-color-option={c}
141
+ >
142
+ <span class={cn("flex size-7 items-center justify-center rounded-full", workspacePalette[c].tint)}>
143
+ <span class={cn("size-3 rounded-full", workspacePalette[c].solid)} />
144
+ </span>
145
+ </button>
146
+ ))
147
+ }
148
+ </div>
149
+
150
+ <Separator />
151
+
152
+ <p class="text-xs font-semibold tracking-wide text-muted-foreground uppercase" aria-hidden="true">
153
+ {iconLabel}
154
+ </p>
155
+ <div role="group" aria-label={iconLabel} class="grid grid-cols-7 gap-1">
156
+ {
157
+ WORKSPACE_GLYPHS.map((g) => (
158
+ <button
159
+ type="button"
160
+ class={cn(
161
+ tile,
162
+ "rounded-md not-aria-pressed:text-foreground not-aria-pressed:hover:bg-background-hover aria-pressed:ring-1 aria-pressed:ring-ring",
163
+ g === icon && [swatch.tint, swatch.text],
164
+ )}
165
+ aria-label={workspaceGlyphLabel(g)}
166
+ aria-pressed={g === icon ? "true" : "false"}
167
+ data-icon-option={g}
168
+ >
169
+ <WorkspaceIconPickerGlyph icon={g} name={name} class="size-5 text-base" />
170
+ </button>
171
+ ))
172
+ }
173
+ </div>
174
+ </PopoverContent>
175
+ </Popover>
176
+
177
+ {colorName && <input type="hidden" name={colorName} value={color} data-workspace-icon-picker-color />}
178
+ {iconName && <input type="hidden" name={iconName} value={icon} data-workspace-icon-picker-icon />}
179
+ </div>
180
+
181
+ <script>
182
+ import { workspacePalette, type WorkspaceColor } from "@cl-light-ds/ui/lib/workspace-palette";
183
+
184
+ const wired = new WeakSet<Element>();
185
+ const initialOf = (name: string) => ([...name.trim()][0] ?? "").toLocaleUpperCase();
186
+ const classesOf = (color: string) => {
187
+ const s = workspacePalette[color as WorkspaceColor];
188
+ return s ? [s.tint, s.text] : [];
189
+ };
190
+
191
+ function wire(root: HTMLElement) {
192
+ if (wired.has(root)) return;
193
+ wired.add(root);
194
+
195
+ // Hold references now: the popover portals its content to <body> while open.
196
+ const content = root.querySelector<HTMLElement>("[data-workspace-icon-picker-content]");
197
+ const avatar = root.querySelector<HTMLElement>('[data-slot="workspace-avatar"]');
198
+ if (!content || !avatar) return;
199
+ const colorButtons = Array.from(content.querySelectorAll<HTMLElement>("[data-color-option]"));
200
+ const iconButtons = Array.from(content.querySelectorAll<HTMLElement>("[data-icon-option]"));
201
+ const colorInput = root.querySelector<HTMLInputElement>("[data-workspace-icon-picker-color]");
202
+ const iconInput = root.querySelector<HTMLInputElement>("[data-workspace-icon-picker-icon]");
203
+
204
+ let color = root.dataset.color ?? "blue";
205
+ let icon = root.dataset.icon ?? "initial";
206
+
207
+ const pressedTile = () => iconButtons.find((b) => b.dataset.iconOption === icon);
208
+
209
+ const setColor = (next: string) => {
210
+ if (!workspacePalette[next as WorkspaceColor]) return;
211
+ const from = classesOf(color);
212
+ const to = classesOf(next);
213
+ for (const el of [avatar, pressedTile()]) {
214
+ if (!el) continue;
215
+ el.classList.remove(...from);
216
+ el.classList.add(...to);
217
+ }
218
+ avatar.dataset.color = next;
219
+ color = next;
220
+ root.dataset.color = next;
221
+ for (const b of colorButtons) b.setAttribute("aria-pressed", String(b.dataset.colorOption === next));
222
+ if (colorInput) colorInput.value = next;
223
+ };
224
+
225
+ const setIcon = (next: string) => {
226
+ const button = iconButtons.find((b) => b.dataset.iconOption === next);
227
+ if (!button) return;
228
+ pressedTile()?.classList.remove(...classesOf(color));
229
+ icon = next;
230
+ root.dataset.icon = next;
231
+ button.classList.add(...classesOf(color));
232
+ for (const b of iconButtons) b.setAttribute("aria-pressed", String(b === button));
233
+ // Copy the tile's glyph into the avatar, keeping the avatar's glyph size.
234
+ const source = button.querySelector<HTMLElement>('[data-slot="workspace-icon-picker-glyph"]');
235
+ const target = avatar.querySelector<HTMLElement>('[data-slot="workspace-icon-picker-glyph"]');
236
+ if (source && target) {
237
+ const copy = source.cloneNode(true) as HTMLElement;
238
+ copy.className = target.className;
239
+ target.replaceWith(copy);
240
+ }
241
+ if (iconInput) iconInput.value = next;
242
+ };
243
+
244
+ /** The initial follows the name; with no name, the fallback icon shows. */
245
+ const setName = (name: string) => {
246
+ const letter = initialOf(name);
247
+ root.dataset.name = name;
248
+ for (const scope of [avatar, content]) {
249
+ for (const el of scope.querySelectorAll<HTMLElement>('[data-slot="workspace-icon-picker-initial"]')) {
250
+ el.textContent = letter;
251
+ el.hidden = !letter;
252
+ }
253
+ for (const el of scope.querySelectorAll<HTMLElement>('[data-slot="workspace-icon-picker-fallback"]')) {
254
+ el.hidden = Boolean(letter);
255
+ }
256
+ }
257
+ };
258
+
259
+ const emitChange = () =>
260
+ root.dispatchEvent(
261
+ new CustomEvent("workspace-icon-picker:change", { detail: { color, icon }, bubbles: true }),
262
+ );
263
+
264
+ for (const b of colorButtons) {
265
+ b.addEventListener("click", () => {
266
+ if (b.dataset.colorOption === color) return;
267
+ setColor(b.dataset.colorOption!);
268
+ emitChange();
269
+ });
270
+ }
271
+ for (const b of iconButtons) {
272
+ b.addEventListener("click", () => {
273
+ if (b.dataset.iconOption === icon) return;
274
+ setIcon(b.dataset.iconOption!);
275
+ emitChange();
276
+ });
277
+ }
278
+
279
+ root.addEventListener("workspace-icon-picker:set", (event) => {
280
+ const detail = (event as CustomEvent<{ color?: string; icon?: string; name?: string }>).detail ?? {};
281
+ if (detail.name !== undefined) setName(detail.name);
282
+ if (detail.color && detail.color !== color) setColor(detail.color);
283
+ if (detail.icon && detail.icon !== icon) setIcon(detail.icon);
284
+ });
285
+ }
286
+
287
+ document.querySelectorAll<HTMLElement>('[data-slot="workspace-icon-picker"]').forEach(wire);
288
+ </script>
@@ -0,0 +1,44 @@
1
+ ---
2
+ /**
3
+ * Internal to `WorkspaceIconPicker`: the glyph on a tile and on the avatar.
4
+ * Unlike `WorkspaceGlyph`, the initial always renders both the letter and the
5
+ * fallback icon (one of them `hidden`), so the picker can follow a name that
6
+ * is typed in after the page loads. `class` sizes the box (`size-*`) and the
7
+ * letter (`text-*`); the icon fills the box.
8
+ */
9
+
10
+ import { cn } from "@cl-light-ds/ui/lib/utils";
11
+ import { DEFAULT_WORKSPACE_ICON, workspaceInitial } from "./icons";
12
+ import WorkspaceIcon from "./WorkspaceIcon.astro";
13
+
14
+ interface Props {
15
+ icon: string;
16
+ name?: string;
17
+ class?: string;
18
+ }
19
+
20
+ const { icon, name = "", class: className = "" } = Astro.props as Props;
21
+ const letter = workspaceInitial(name);
22
+ ---
23
+
24
+ <span
25
+ data-slot="workspace-icon-picker-glyph"
26
+ data-glyph={icon}
27
+ class={cn("flex shrink-0 items-center justify-center", className)}
28
+ aria-hidden="true"
29
+ >
30
+ {
31
+ icon === "initial" ? (
32
+ <>
33
+ <span data-slot="workspace-icon-picker-initial" class="leading-none font-semibold" hidden={!letter}>
34
+ {letter}
35
+ </span>
36
+ <span data-slot="workspace-icon-picker-fallback" class="flex size-full" hidden={Boolean(letter)}>
37
+ <WorkspaceIcon name={DEFAULT_WORKSPACE_ICON} class="size-full" />
38
+ </span>
39
+ </>
40
+ ) : (
41
+ <WorkspaceIcon name={icon} class="size-full" />
42
+ )
43
+ }
44
+ </span>
@@ -0,0 +1,98 @@
1
+ import {
2
+ BookOpen,
3
+ Briefcase,
4
+ Building2,
5
+ Factory,
6
+ FlaskConical,
7
+ Globe,
8
+ GraduationCap,
9
+ HardHat,
10
+ Headset,
11
+ HeartPulse,
12
+ Landmark,
13
+ Leaf,
14
+ Lightbulb,
15
+ Mountain,
16
+ Package,
17
+ Plane,
18
+ Rocket,
19
+ Scale,
20
+ Server,
21
+ ShieldCheck,
22
+ ShoppingCart,
23
+ Store,
24
+ TrendingUp,
25
+ Truck,
26
+ Users,
27
+ Wallet,
28
+ Wrench,
29
+ type AstroComponent,
30
+ } from "@lucide/astro";
31
+
32
+ /**
33
+ * The workspace placeholder icon set: 27 Lucide icons a workspace can be given
34
+ * until it has a logo. String names, so backend data can store them. The order
35
+ * is the icon picker's order.
36
+ */
37
+ export const workspaceIcons = {
38
+ "building-2": Building2,
39
+ briefcase: Briefcase,
40
+ users: Users,
41
+ "graduation-cap": GraduationCap,
42
+ "book-open": BookOpen,
43
+ landmark: Landmark,
44
+ wallet: Wallet,
45
+ "trending-up": TrendingUp,
46
+ "shopping-cart": ShoppingCart,
47
+ store: Store,
48
+ truck: Truck,
49
+ package: Package,
50
+ factory: Factory,
51
+ "hard-hat": HardHat,
52
+ wrench: Wrench,
53
+ server: Server,
54
+ headset: Headset,
55
+ "shield-check": ShieldCheck,
56
+ scale: Scale,
57
+ "heart-pulse": HeartPulse,
58
+ "flask-conical": FlaskConical,
59
+ globe: Globe,
60
+ plane: Plane,
61
+ leaf: Leaf,
62
+ rocket: Rocket,
63
+ lightbulb: Lightbulb,
64
+ mountain: Mountain,
65
+ } satisfies Record<string, AstroComponent>;
66
+
67
+ export type WorkspaceIconName = keyof typeof workspaceIcons;
68
+
69
+ /** The 27 icon names, in picker order. */
70
+ export const WORKSPACE_ICONS = Object.keys(workspaceIcons) as WorkspaceIconName[];
71
+
72
+ /** What a workspace avatar can show: one of the icons, or the first letter of the name. */
73
+ export type WorkspaceGlyphName = WorkspaceIconName | "initial";
74
+
75
+ /** Every glyph an icon picker offers: the initial first, then the 27 icons. */
76
+ export const WORKSPACE_GLYPHS: WorkspaceGlyphName[] = ["initial", ...WORKSPACE_ICONS];
77
+
78
+ /** The icon for unknown names, and for an initial with no name to take it from. */
79
+ export const DEFAULT_WORKSPACE_ICON: WorkspaceIconName = "building-2";
80
+
81
+ /** The Lucide component for an icon name; unknown names fall back to the building. */
82
+ export const resolveWorkspaceIcon = (name: string | undefined): AstroComponent =>
83
+ workspaceIcons[name as WorkspaceIconName] ?? workspaceIcons[DEFAULT_WORKSPACE_ICON];
84
+
85
+ /** The first letter of a workspace name, upper-cased ("" when the name is blank). */
86
+ export const workspaceInitial = (name: string | undefined): string =>
87
+ ([...(name ?? "").trim()][0] ?? "").toLocaleUpperCase();
88
+
89
+ /**
90
+ * A readable label for a glyph, e.g. an icon picker's `aria-label`:
91
+ * "shield-check" → "Shield check", "building-2" → "Building",
92
+ * "initial" → "First letter of the name".
93
+ */
94
+ export const workspaceGlyphLabel = (name: WorkspaceGlyphName): string => {
95
+ if (name === "initial") return "First letter of the name";
96
+ const words = name.replace(/-\d+$/, "").replace(/-/g, " ");
97
+ return words.charAt(0).toUpperCase() + words.slice(1);
98
+ };
@@ -0,0 +1,35 @@
1
+ export { default as OrganisationOverview } from "./OrganisationOverview.astro";
2
+ export { default as WorkspaceCard } from "./WorkspaceCard.astro";
3
+ export { default as RecentWorkspaceCard } from "./RecentWorkspaceCard.astro";
4
+ export { default as RecentWorkspaces } from "./RecentWorkspaces.astro";
5
+ export { default as WorkspaceIconPicker } from "./WorkspaceIconPicker.astro";
6
+ export { default as WorkspaceAvatar } from "./WorkspaceAvatar.astro";
7
+ export { workspaceAvatarVariants } from "./WorkspaceAvatar.astro";
8
+ export { default as WorkspaceGlyph } from "./WorkspaceGlyph.astro";
9
+ export { default as WorkspaceIcon } from "./WorkspaceIcon.astro";
10
+ export {
11
+ DEFAULT_WORKSPACE_ICON,
12
+ WORKSPACE_GLYPHS,
13
+ WORKSPACE_ICONS,
14
+ resolveWorkspaceIcon,
15
+ workspaceGlyphLabel,
16
+ workspaceIcons,
17
+ workspaceInitial,
18
+ type WorkspaceGlyphName,
19
+ type WorkspaceIconName,
20
+ } from "./icons";
21
+ export {
22
+ DEFAULT_WORKSPACE_COLOR,
23
+ WORKSPACE_COLORS,
24
+ brandGradient,
25
+ workspacePalette,
26
+ type WorkspaceColor,
27
+ type WorkspaceSwatch,
28
+ } from "@cl-light-ds/ui/lib/workspace-palette";
29
+ export * from "./types";
30
+ export {
31
+ defaultWorkspaceCardLabels,
32
+ fillLabel,
33
+ workspaceCardTexts,
34
+ type WorkspaceCardLabels,
35
+ } from "./labels";
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The copy on workspace cards (`WorkspaceCard`, `RecentWorkspaceCard`,
3
+ * `RecentWorkspaces`), as templates so it can be translated. `{time}`,
4
+ * `{name}` and `{count}` are filled in.
5
+ */
6
+ export interface WorkspaceCardLabels {
7
+ /** "Opened {time}", e.g. "Opened 2 hours ago". */
8
+ opened: string;
9
+ /** "Last edited by {name}". */
10
+ lastEditedBy: string;
11
+ /** "{count} recordings". */
12
+ recordings: string;
13
+ /** "{count} recording", for exactly one. */
14
+ recording: string;
15
+ /** The star's accessible name: "Favourite {name}". */
16
+ favorite: string;
17
+ /** The large recent card's caps line: "Jump back in". */
18
+ jumpBackIn: string;
19
+ }
20
+
21
+ export const defaultWorkspaceCardLabels: WorkspaceCardLabels = {
22
+ opened: "Opened {time}",
23
+ lastEditedBy: "Last edited by {name}",
24
+ recordings: "{count} recordings",
25
+ recording: "{count} recording",
26
+ favorite: "Favourite {name}",
27
+ jumpBackIn: "Jump back in",
28
+ };
29
+
30
+ /** Fills `{key}` placeholders in a template. */
31
+ export const fillLabel = (template: string, values: Record<string, string | number>): string =>
32
+ template.replace(/\{(\w+)\}/g, (match, key: string) => (key in values ? String(values[key]) : match));
33
+
34
+ /** The card texts for one workspace; parts whose data is missing are `""`. */
35
+ export function workspaceCardTexts(
36
+ data: { name: string; opened?: string; lastEditedBy?: string; recordings?: number | string },
37
+ labels: Partial<WorkspaceCardLabels> = {},
38
+ ) {
39
+ const l = { ...defaultWorkspaceCardLabels, ...labels };
40
+ const { name, opened, lastEditedBy, recordings } = data;
41
+ const recordingsText =
42
+ recordings === undefined || recordings === ""
43
+ ? ""
44
+ : typeof recordings === "number"
45
+ ? fillLabel(recordings === 1 ? l.recording : l.recordings, { count: recordings })
46
+ : recordings;
47
+ return {
48
+ opened: opened ? fillLabel(l.opened, { time: opened }) : "",
49
+ lastEditedBy: lastEditedBy ? fillLabel(l.lastEditedBy, { name: lastEditedBy }) : "",
50
+ recordings: recordingsText,
51
+ favorite: fillLabel(l.favorite, { name }),
52
+ jumpBackIn: l.jumpBackIn,
53
+ };
54
+ }
@@ -0,0 +1,30 @@
1
+ import type { WorkspaceColor } from "@cl-light-ds/ui/lib/workspace-palette";
2
+
3
+ /** `WorkspaceAvatar` colour treatments. */
4
+ export type WorkspaceAvatarTone = "soft" | "solid" | "inverse" | "brand";
5
+
6
+ /** `WorkspaceAvatar` sizes: 32 / 40 / 56 / 64px. */
7
+ export type WorkspaceAvatarSize = "sm" | "default" | "lg" | "xl";
8
+
9
+ /** `WorkspaceAvatar` shapes: round, or a rounded square. */
10
+ export type WorkspaceAvatarShape = "circle" | "square";
11
+
12
+ /** `RecentWorkspaceCard` sizes: large (gradient), medium (tall), small (two stacked), extra small (one row). */
13
+ export type RecentWorkspaceCardSize = "lg" | "md" | "sm" | "xs";
14
+
15
+ /** The large recent card's background: the workspace's own gradient, or the brand gradient. */
16
+ export type RecentWorkspaceTone = "workspace" | "brand";
17
+
18
+ /** One workspace for `RecentWorkspaces`. Other keys (e.g. `data-*`) go on its card's link. */
19
+ export interface RecentWorkspace {
20
+ name: string;
21
+ href: string;
22
+ color?: WorkspaceColor;
23
+ /** A placeholder icon name, or `"initial"`. */
24
+ icon?: string;
25
+ /** When it was last opened, e.g. "2 hours ago" ("Opened 2 hours ago"). */
26
+ opened?: string;
27
+ lastEditedBy?: string;
28
+ recordings?: number | string;
29
+ [attribute: `data-${string}`]: string | undefined;
30
+ }
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to the `Popover` component family (`Popover`, `PopoverTrigger`, `PopoverContent`, `PopoverHeader`, `PopoverTitle`, `PopoverDescription`, `PopoverClose`) are documented in this file.
4
4
 
5
+ ## Date 15th Oct 2026
6
+ ## Handover gaps: triggers don't submit forms (C29, gap G18)
7
+
8
+ ### Fixed
9
+
10
+ - **`PopoverTrigger` renders `type="button"`** unless you pass a `type`. It renders a `Button`, which has no type, so inside a `<form>` opening the popover submitted the form (the WorkspaceIconPicker in the New workspace form created the workspace). Plain `Button` is unchanged.
11
+ - **`PopoverClose` renders `type="button"`** unless you pass a `type`.
12
+ - **`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).
13
+
14
+ ### Storybook
15
+
16
+ - "Inside a form" preview: a Popover trigger next to a submit button; the line under it counts submits.
17
+
18
+
5
19
  ## 2026-09-18 — Figma alignment (ClickLearn Lightning Design Library, node `5548-11795`)
6
20
 
7
21
  Figma documents Popover across two pages: Items (the trigger button's Default/Hover/Pressed/Focus/Disabled states) and the expanded popover (12 Side × Alignment combinations, all sharing the same panel styling).
@@ -6,11 +6,12 @@ interface Props extends HTMLAttributes<"button"> {
6
6
  class?: string;
7
7
  }
8
8
 
9
- const { class: className = "", ...rest } = Astro.props as Props;
9
+ const { class: className = "", type = "button", ...rest } = Astro.props as Props;
10
10
  ---
11
11
 
12
12
  <button
13
13
  {...rest}
14
+ type={type}
14
15
  data-slot="popover-close"
15
16
  class={cn("", className)}
16
17
  >
@@ -5,7 +5,11 @@ import type { Props as ButtonProps } from "../button/Button.astro";
5
5
 
6
6
  type Props = ButtonProps & {
7
7
  class?: string;
8
- /** Radix-like: render wrapper so the child can be the interactive element */
8
+ /**
9
+ * Radix-like: render wrapper so the child can be the interactive element.
10
+ * The wrapper is not a button, so the child sets its own type: give a
11
+ * `<button>`/`<Button>` child `type="button"` inside a `<form>` or it submits it.
12
+ */
9
13
  asChild?: boolean;
10
14
  };
11
15
 
@@ -13,6 +17,7 @@ const {
13
17
  class: className = "",
14
18
  asChild = false,
15
19
  variant = "outline",
20
+ type = "button",
16
21
  ...rest
17
22
  } = Astro.props as Props;
18
23
  ---
@@ -28,6 +33,7 @@ const {
28
33
  variant={variant}
29
34
  class={cn("", "inline-flex items-center", className)}
30
35
  {...rest as any}
36
+ type={(rest as any).as === "a" ? undefined : type}
31
37
  >
32
38
  <slot />
33
39
  </Button>
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `ProfileMenu` component are documented in this file.
4
+
5
+ ## Date 15th Oct 2026
6
+ ## Workspace selection handover (N13)
7
+
8
+ ### Added
9
+
10
+ - `ProfileMenu`, built on `DropdownMenu`:
11
+ - **Trigger (C15, D103):** the user's `Avatar` (32px) is the trigger: a chrome-less round `<button data-slot="dropdown-menu-trigger">` around it, with a `ring-2 ring-ring` focus ring and no hover ring. Because the button *is* the runtime's trigger (not an `asChild` wrapper), `aria-haspopup`, `aria-expanded` and Esc focus return land on it, as D103 asked frontend to do.
12
+ - **Panel:** `w-64`, `align="end"`, opens **6px** below the avatar (`sideOffset` 6, C28, D206) instead of the menus' default 4px. Scroll lock is off by default (`lockScroll`), as in the reference.
13
+ - **Header (D180):** avatar, name (semibold, truncates) and email (muted). No role line. `header` slot to replace it.
14
+ - **Upgrade:** a full-width `sm` primary button whose label comes from `plan` looked up in `upgradeLabels` — defaults `trial` → "Upgrade to Pro", `pro` → "Upgrade to Enterprise", `enterprise` → none. `upgradeLabel` overrides (`false` hides), `upgradeHref` navigates, `upgrade` slot replaces it. It is a menu item (value `upgrade`), so arrow keys reach it.
15
+ - **Items:** `items` prop (`id`, `label`, `icon`, `href`, `variant`, `separatorBefore`), defaulting to Manage profile, Get desktop app, separator, Log out. The default slot replaces them with your own `DropdownMenuItem`s.
16
+ - `profile-menu:select` event (`{ id }`, bubbles, cancelable), fired for items and the upgrade button. Items with an `href` navigate unless it's cancelled (same contract as `sidebar-nav:workspace-change`).
17
+ - `defaultProfileMenuUpgradeLabels` and the `ProfileMenuItemData` / `ProfileMenuUpgradeLabels` types are exported.
18
+
19
+ ### Generalised from the reference (cl-design `ProfileMenu.astro`)
20
+
21
+ - User, plan and the plan → label mapping come from props instead of the screen's mock data and `upgradeLabel()` helper.
22
+ - Items are data or slots instead of fixed markup; copy is props with the reference's labels as defaults.