@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,236 @@
1
+ ---
2
+ /**
3
+ * @component ProfileMenu
4
+ * @title Profile Menu
5
+ * @description The signed-in user's menu: the avatar itself is the trigger; the menu shows the avatar, name and email, an upgrade button that follows the plan, and account actions.
6
+ *
7
+ * @preview
8
+ *
9
+ * <ProfileMenu name="Wiley Fox" email="wiley.fox@northwind.com" plan="trial" />
10
+ *
11
+ * @usage
12
+ *
13
+ * ```astro nocollapse
14
+ * ---
15
+ * import { ProfileMenu } from '@cl-light-ds/ui/components/profile-menu';
16
+ * ---
17
+ *
18
+ * <ProfileMenu
19
+ * name="Wiley Fox"
20
+ * email="wiley.fox@northwind.com"
21
+ * avatarSrc="/avatars/wiley.png"
22
+ * plan="pro"
23
+ * upgradeHref="/billing"
24
+ * />
25
+ * ```
26
+ *
27
+ * @api
28
+ *
29
+ * ### Trigger
30
+ *
31
+ * The avatar is the trigger: a chrome-less, round `<button>` around the DS `Avatar`, with a focus ring and no
32
+ * hover ring (C15). `aria-haspopup` / `aria-expanded` and focus return on Esc land on it.
33
+ *
34
+ * ### Upgrade
35
+ *
36
+ * `plan` is looked up in `upgradeLabels` (default: `trial` → "Upgrade to Pro", `pro` → "Upgrade to Enterprise",
37
+ * `enterprise` → none). `upgradeLabel` overrides the lookup (`false` hides it); the `upgrade` slot replaces the button.
38
+ * The button is a menu item (value `"upgrade"`), so arrow keys reach it.
39
+ *
40
+ * ### Events (on the `[data-slot="profile-menu"]` root)
41
+ *
42
+ * | Event | Detail | Description |
43
+ * |-------|--------|-------------|
44
+ * | `profile-menu:select` | `{ id }` | Bubbles, cancelable. An item or the upgrade button (`id: "upgrade"`) was chosen. If it has an `href` and the event isn't cancelled, the browser navigates there. |
45
+ *
46
+ * The underlying `dropdown-menu:*` events are available too.
47
+ */
48
+
49
+ import type { HTMLAttributes } from "astro/types";
50
+ import { LogOut, Monitor, User } from "@lucide/astro";
51
+ import { cn } from "@cl-light-ds/ui/lib/utils";
52
+ import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
53
+ import { Button } from "../button";
54
+ import {
55
+ DropdownMenu,
56
+ DropdownMenuContent,
57
+ DropdownMenuItem,
58
+ DropdownMenuSeparator,
59
+ } from "../dropdown-menu";
60
+ import {
61
+ defaultProfileMenuUpgradeLabels,
62
+ type ProfileMenuItemData,
63
+ type ProfileMenuUpgradeLabels,
64
+ } from "./types";
65
+
66
+ interface Props extends HTMLAttributes<"div"> {
67
+ name: string;
68
+ email?: string;
69
+ avatarSrc?: string;
70
+ /** Avatar fallback. Default: the first letters of the first two words of `name`. */
71
+ initials?: string;
72
+ /** The account's plan, looked up in `upgradeLabels`. */
73
+ plan?: string;
74
+ upgradeLabels?: ProfileMenuUpgradeLabels;
75
+ /** Overrides the plan lookup; `false` hides the button. */
76
+ upgradeLabel?: string | false;
77
+ /** Where the upgrade button goes. Without it, listen for `profile-menu:select` (`id: "upgrade"`). */
78
+ upgradeHref?: string;
79
+ /** Menu rows. Default: Manage profile, Get desktop app, Log out. The default slot replaces them. */
80
+ items?: ProfileMenuItemData[];
81
+ /** Trigger's accessible name; `{name}` is replaced. */
82
+ triggerLabel?: string;
83
+ side?: "top" | "bottom" | "left" | "right";
84
+ align?: "start" | "center" | "end";
85
+ /** Gap below the avatar. 6px, like a Popover, so it clears a top bar's border (C28). */
86
+ sideOffset?: number;
87
+ /** Lock page scroll while open. Off by default: locking shifts pages that don't scroll. */
88
+ lockScroll?: boolean;
89
+ /** Classes for the menu panel. */
90
+ contentClass?: string;
91
+ class?: string;
92
+ }
93
+
94
+ const defaultItems: ProfileMenuItemData[] = [
95
+ { id: "manage-profile", label: "Manage profile", icon: User },
96
+ { id: "desktop-app", label: "Get desktop app", icon: Monitor },
97
+ { id: "log-out", label: "Log out", icon: LogOut, separatorBefore: true },
98
+ ];
99
+
100
+ const {
101
+ name,
102
+ email,
103
+ avatarSrc,
104
+ initials = name
105
+ .split(/\s+/)
106
+ .filter(Boolean)
107
+ .slice(0, 2)
108
+ .map((w) => w[0]!.toUpperCase())
109
+ .join(""),
110
+ plan,
111
+ upgradeLabels = defaultProfileMenuUpgradeLabels,
112
+ upgradeLabel,
113
+ upgradeHref,
114
+ items = defaultItems,
115
+ triggerLabel = "{name} — account menu",
116
+ side = "bottom",
117
+ align = "end",
118
+ sideOffset = 6,
119
+ lockScroll = false,
120
+ contentClass = "",
121
+ class: className = "",
122
+ ...rest
123
+ } = Astro.props as Props;
124
+
125
+ const lookup = (p?: string) => {
126
+ if (!p) return null;
127
+ const key = Object.keys(upgradeLabels).find((k) => k.toLowerCase() === p.toLowerCase());
128
+ return key ? upgradeLabels[key] : null;
129
+ };
130
+ const upgrade = upgradeLabel === false ? null : (upgradeLabel ?? lookup(plan));
131
+ const hasUpgradeSlot = Astro.slots.has("upgrade");
132
+ const hasHeaderSlot = Astro.slots.has("header");
133
+ const hasItemsSlot = Astro.slots.has("default");
134
+ ---
135
+
136
+ <div {...rest} data-slot="profile-menu" data-plan={plan} class={cn("flex", className)}>
137
+ {/* `flex`, not inline-block: no extra line-height under the avatar. */}
138
+ <DropdownMenu lockScroll={lockScroll} class="flex">
139
+ {/*
140
+ The avatar is the trigger itself (C15, D103): a reset round button, so the
141
+ runtime's aria-haspopup / aria-expanded and focus return land on it.
142
+ Focus ring only; no hover ring.
143
+ */}
144
+ <button
145
+ type="button"
146
+ data-slot="dropdown-menu-trigger"
147
+ aria-label={triggerLabel.replace("{name}", name)}
148
+ class="focus-visible:ring-ring cursor-pointer rounded-full outline-none focus-visible:ring-2"
149
+ >
150
+ <Avatar>
151
+ {avatarSrc && <AvatarImage src={avatarSrc} alt="" />}
152
+ <AvatarFallback>{initials}</AvatarFallback>
153
+ </Avatar>
154
+ </button>
155
+
156
+ <DropdownMenuContent side={side} align={align} sideOffset={sideOffset} class={cn("w-64", contentClass)}>
157
+ <div data-slot="profile-menu-header" class="flex flex-col gap-3 p-2">
158
+ {
159
+ hasHeaderSlot ? (
160
+ <slot name="header" />
161
+ ) : (
162
+ <div class="flex items-center gap-3">
163
+ <Avatar>
164
+ {avatarSrc && <AvatarImage src={avatarSrc} alt="" />}
165
+ <AvatarFallback>{initials}</AvatarFallback>
166
+ </Avatar>
167
+ <div class="flex min-w-0 flex-col">
168
+ <span class="truncate text-sm font-semibold">{name}</span>
169
+ {email && <span class="text-muted-foreground truncate text-xs">{email}</span>}
170
+ </div>
171
+ </div>
172
+ )
173
+ }
174
+ {
175
+ hasUpgradeSlot ? (
176
+ <slot name="upgrade" />
177
+ ) : (
178
+ upgrade && (
179
+ <Button
180
+ type="button"
181
+ size="sm"
182
+ class="w-full"
183
+ role="menuitem"
184
+ data-slot="dropdown-menu-item"
185
+ data-value="upgrade"
186
+ data-href={upgradeHref}
187
+ data-profile-menu-upgrade
188
+ >
189
+ {upgrade}
190
+ </Button>
191
+ )
192
+ )
193
+ }
194
+ </div>
195
+ <DropdownMenuSeparator />
196
+ {
197
+ hasItemsSlot ? (
198
+ <slot />
199
+ ) : (
200
+ items.map((item) => {
201
+ const Icon = item.icon;
202
+ return (
203
+ <>
204
+ {item.separatorBefore && <DropdownMenuSeparator />}
205
+ <DropdownMenuItem value={item.id} variant={item.variant} data-href={item.href}>
206
+ {Icon && <Icon slot="leading-icon" />}
207
+ {item.label}
208
+ </DropdownMenuItem>
209
+ </>
210
+ );
211
+ })
212
+ )
213
+ }
214
+ </DropdownMenuContent>
215
+ </DropdownMenu>
216
+ </div>
217
+
218
+ <script>
219
+ document.querySelectorAll<HTMLElement>('[data-slot="profile-menu"]').forEach((root) => {
220
+ if (root.dataset.profileMenuWired) return;
221
+ root.dataset.profileMenuWired = "";
222
+ root.addEventListener("dropdown-menu:select", (event) => {
223
+ const { value, item } = (event as CustomEvent<{ value: string; item: HTMLElement | null }>)
224
+ .detail;
225
+ const proceed = root.dispatchEvent(
226
+ new CustomEvent("profile-menu:select", {
227
+ bubbles: true,
228
+ cancelable: true,
229
+ detail: { id: value },
230
+ }),
231
+ );
232
+ const href = item?.dataset.href;
233
+ if (proceed && href) window.location.assign(href);
234
+ });
235
+ });
236
+ </script>
@@ -0,0 +1,2 @@
1
+ export { default as ProfileMenu } from "./ProfileMenu.astro";
2
+ export * from "./types";
@@ -0,0 +1,27 @@
1
+ import type { AstroComponent } from "@lucide/astro";
2
+
3
+ /** One row of the profile menu. */
4
+ export interface ProfileMenuItemData {
5
+ /** Sent as `value` in `dropdown-menu:select` / `profile-menu:select`. */
6
+ id: string;
7
+ label: string;
8
+ icon?: AstroComponent;
9
+ /** Navigate here when chosen (unless `profile-menu:select` is cancelled). */
10
+ href?: string;
11
+ variant?: "default" | "destructive";
12
+ /** Draw a separator above this item. */
13
+ separatorBefore?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Upgrade-button label per plan. A plan mapped to `null` (or missing) gets no
18
+ * button. Keys are matched case-insensitively against the `plan` prop.
19
+ */
20
+ export type ProfileMenuUpgradeLabels = Record<string, string | null>;
21
+
22
+ /** Documented defaults: Trial → Pro, Pro → Enterprise, Enterprise → none. */
23
+ export const defaultProfileMenuUpgradeLabels: ProfileMenuUpgradeLabels = {
24
+ trial: "Upgrade to Pro",
25
+ pro: "Upgrade to Enterprise",
26
+ enterprise: null,
27
+ };
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `SearchField` component will be documented in this file.
4
+
5
+ ## Date 15th Oct 2026
6
+ ## Workspace selection handover (N20)
7
+
8
+ ### Added
9
+
10
+ - `SearchField`: an `InputGroup` (search icon, `type="search"` input) joined to an outline `Button` in a `ButtonGroup`. The single focus ring round both while typing, and the button's own ring when tabbed to, come from `ButtonGroup` (C4); the outline button keeps its plain border on hover (C3).
11
+ - Props: `label` (the field's `aria-label`, required), `placeholder` (defaults to `label`), `buttonLabel` (default "Search"), `name`, `value`, `inputId`, `size` (`sm` / `default` / `lg`, applied to field and button), `disabled`, `class`.
12
+ - Submitting: Enter in the field or the button fires `search-field:submit` (`{ value }`, bubbles, cancelable) on the root. Without `action` the root is a `<div role="search">` and nothing else happens. With `action` (and `method`, default `get`) it is a `<form role="search">` whose submit button is the Search button; cancelling the event stops the submission.
13
+ - Behaviour is a bundled script, bound once per field.
14
+ - Storybook: "Search Field" under Forms.
@@ -0,0 +1,163 @@
1
+ ---
2
+ /**
3
+ * @component SearchField
4
+ * @title Search Field
5
+ * @description A search input joined to a Search button: an `InputGroup` and an outline `Button` in a `ButtonGroup`, with one focus ring round both while typing and the button's own ring when tabbed to. Submits on Enter or on the button.
6
+ *
7
+ * @preview
8
+ *
9
+ * <SearchField label="Search workspaces" placeholder="Search workspaces" />
10
+ *
11
+ * @usage
12
+ *
13
+ * ```astro nocollapse
14
+ * ---
15
+ * import { SearchField } from '@cl-light-ds/ui/components/search-field';
16
+ * ---
17
+ *
18
+ * <!-- In the page: listen for the event -->
19
+ * <SearchField label="Search workspaces" placeholder="Search workspaces" id="workspace-search" />
20
+ * <script>
21
+ * document.getElementById("workspace-search")?.addEventListener("search-field:submit", (e) => {
22
+ * runSearch(e.detail.value);
23
+ * });
24
+ * </script>
25
+ *
26
+ * <!-- Or as a form: a GET to /search?q=… -->
27
+ * <SearchField label="Search" name="q" action="/search" />
28
+ * ```
29
+ *
30
+ * @api
31
+ *
32
+ * ### Events
33
+ *
34
+ * | Event | Detail | Description |
35
+ * |-------|--------|-------------|
36
+ * | `search-field:submit` | `{ value: string }` | On Enter in the field or a click on the button. Bubbles; cancelable: with `action`, cancelling it stops the form from submitting. |
37
+ *
38
+ * Without `action` the root is a `<div role="search">` and nothing is
39
+ * submitted: Enter and the button only fire the event. With `action` it is a
40
+ * `<form role="search">` and the button is its submit button.
41
+ */
42
+
43
+ import type { HTMLAttributes } from "astro/types";
44
+ import { Search } from "@lucide/astro";
45
+ import { cn } from "@cl-light-ds/ui/lib/utils";
46
+ import { Button } from "../button";
47
+ import { ButtonGroup } from "../button-group";
48
+ import { InputGroup, InputGroupAddon, InputGroupInput } from "../input-group";
49
+
50
+ interface Props extends Omit<HTMLAttributes<"div">, "class"> {
51
+ /** Accessible name of the field (`aria-label`), e.g. "Search workspaces". */
52
+ label: string;
53
+ /** Placeholder; defaults to `label`. */
54
+ placeholder?: string;
55
+ /** Button text. */
56
+ buttonLabel?: string;
57
+ /** Field name (form submission). */
58
+ name?: string;
59
+ /** Initial value. */
60
+ value?: string;
61
+ /** id for the input. */
62
+ inputId?: string;
63
+ /** Renders a `<form role="search">` that submits here. */
64
+ action?: string;
65
+ method?: "get" | "post";
66
+ size?: "sm" | "default" | "lg";
67
+ disabled?: boolean;
68
+ class?: string;
69
+ }
70
+
71
+ const {
72
+ label,
73
+ placeholder = label,
74
+ buttonLabel = "Search",
75
+ name,
76
+ value,
77
+ inputId,
78
+ action,
79
+ method = "get",
80
+ size = "default",
81
+ disabled = false,
82
+ class: className = "",
83
+ ...rest
84
+ } = Astro.props as Props;
85
+
86
+ const isForm = action !== undefined;
87
+ const Tag = isForm ? "form" : "div";
88
+ ---
89
+
90
+ <Tag
91
+ {...rest}
92
+ {...(isForm ? { action, method } : {})}
93
+ role="search"
94
+ data-slot="search-field"
95
+ class={cn("flex w-full min-w-0", className)}
96
+ >
97
+ <ButtonGroup class="w-full">
98
+ <InputGroup size={size} class="min-w-0 flex-1">
99
+ <InputGroupAddon align="inline-start">
100
+ <Search />
101
+ </InputGroupAddon>
102
+ <InputGroupInput
103
+ id={inputId}
104
+ type="search"
105
+ name={name}
106
+ value={value}
107
+ placeholder={placeholder}
108
+ aria-label={label}
109
+ enterkeyhint="search"
110
+ autocomplete="off"
111
+ disabled={disabled}
112
+ data-search-field-input
113
+ />
114
+ </InputGroup>
115
+ <Button
116
+ type={isForm ? "submit" : "button"}
117
+ variant="outline"
118
+ size={size}
119
+ disabled={disabled}
120
+ data-search-field-submit
121
+ >
122
+ {buttonLabel}
123
+ </Button>
124
+ </ButtonGroup>
125
+ </Tag>
126
+
127
+ <script>
128
+ const bound = new WeakSet<HTMLElement>();
129
+
130
+ function bind(root: HTMLElement) {
131
+ if (bound.has(root)) return;
132
+ bound.add(root);
133
+
134
+ const input = root.querySelector<HTMLInputElement>("[data-search-field-input]");
135
+ const button = root.querySelector<HTMLButtonElement>("[data-search-field-submit]");
136
+
137
+ // Returns false when a listener cancelled it.
138
+ const emit = () =>
139
+ root.dispatchEvent(
140
+ new CustomEvent("search-field:submit", {
141
+ detail: { value: input?.value ?? "" },
142
+ bubbles: true,
143
+ cancelable: true,
144
+ }),
145
+ );
146
+
147
+ if (root instanceof HTMLFormElement) {
148
+ root.addEventListener("submit", (event) => {
149
+ if (!emit()) event.preventDefault();
150
+ });
151
+ return;
152
+ }
153
+
154
+ input?.addEventListener("keydown", (event) => {
155
+ if (event.key !== "Enter" || event.isComposing) return;
156
+ event.preventDefault();
157
+ emit();
158
+ });
159
+ button?.addEventListener("click", () => emit());
160
+ }
161
+
162
+ document.querySelectorAll<HTMLElement>('[data-slot="search-field"]').forEach(bind);
163
+ </script>
@@ -0,0 +1 @@
1
+ export { default as SearchField } from "./SearchField.astro";
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to the `Select` component will be 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
+ - **`SelectTrigger`: a `type` you pass now wins.** It always rendered `type="button"`, but after your attributes, so it overrode yours. The default is unchanged.
11
+
12
+
5
13
  ## Date 14th Sep 2026
6
14
  ## Figma alignment (node 2329-7946) + props audit
7
15
 
@@ -15,8 +15,8 @@ const {
15
15
  ---
16
16
 
17
17
  <button
18
- {...rest}
19
18
  type="button"
19
+ {...rest}
20
20
  data-slot="select-trigger"
21
21
  data-size={size}
22
22
  class={cn(
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to the `Table` component family (`Table`, `TableHeader`, `TableBody`, `TableFooter`, `TableRow`, `TableHead`, `TableCell`, `TableCaption`) are documented in this file.
4
4
 
5
+ ## Date 15th Oct 2026
6
+ ## Workspace selection handover (C29 follow-up)
7
+
8
+ - `TablePagination`'s page buttons and `TableEditableCell`'s Save/Cancel render `type="button"`, so they don't submit a surrounding `<form>` (same bug as C29, in buttons that aren't triggers).
9
+
10
+
11
+ ## Date 15th Oct 2026
12
+ ## Workspace selection handover (C13)
13
+
14
+ - **`pinned="end"`** on `TableHead` and `TableCell`: the column sticks to the right edge while the table scrolls (an actions column). Works without `interactive`. The cells take the row's background (`bg-inherit`), so they follow the row's hover and its colour transition. The existing `pinnedColumns` pins from the start only.
15
+ - **Row focus:** put `data-row-focus` on the element that leads a row (e.g. the name link) and its keyboard focus ring (`inset-ring-3 ring/50`) is drawn round the whole row. `TableRow` is now `position: relative`, and the cell holding a `data-row-focus` element is `static`.
16
+ - Empty and error rows (`TableEmpty`) were already white; no change.
17
+ - Storybook: "Pinned actions column and row focus" preview.
18
+
19
+
5
20
  ## 2026-09-18 — Figma alignment (ClickLearn Lightning Design Library, node `5548-9089`)
6
21
 
7
22
  Note: Figma's own canvas for this component is named "Table (⚠️)" — the designer's own marker that this page isn't fully finalized. Aligned against what's clearly evidenced (the overall container treatment) and left ambiguous details (see Notes) rather than guess.
@@ -12,6 +12,8 @@ interface Props extends HTMLAttributes<"td"> {
12
12
  numeric?: boolean;
13
13
  /** Nesting level (1–3). Indents the cell content 20px per level. */
14
14
  level?: 1 | 2 | 3;
15
+ /** "end" sticks the cell to the right edge while the table scrolls (pair with the same on its `TableHead`). */
16
+ pinned?: "end";
15
17
  }
16
18
 
17
19
  const {
@@ -20,6 +22,7 @@ const {
20
22
  numeric = false,
21
23
  align = numeric ? "right" : "left",
22
24
  level,
25
+ pinned,
23
26
  style,
24
27
  ...rest
25
28
  } = Astro.props as Props;
@@ -30,6 +33,7 @@ const indent = levelIndentStyle(level);
30
33
  <td
31
34
  data-slot="table-cell"
32
35
  data-column={column}
36
+ data-pinned-end={pinned === "end" ? "" : undefined}
33
37
  style={indent ? [indent, typeof style === "string" ? style : ""].filter(Boolean).join("; ") : style}
34
38
  class={cn(
35
39
  cellBase,
@@ -37,6 +41,8 @@ const indent = levelIndentStyle(level);
37
41
  "h-12 py-2 font-medium",
38
42
  numeric && "font-semibold tabular-nums",
39
43
  "[&:has([type=checkbox])]:w-15 [&:has([type=checkbox])]:text-center",
44
+ // A row-focus element's ring is drawn by the row, so its cell mustn't be the positioning box.
45
+ "has-[[data-row-focus]]:static",
40
46
  className,
41
47
  )}
42
48
  {...rest}
@@ -177,11 +177,11 @@ const affordanceIcon =
177
177
  maxlength={maxLength}
178
178
  class={cn("min-w-24 flex-1", align === "right" && "text-right")}
179
179
  />
180
- <Button data-table-action="confirm" variant="ghost" size="icon-sm" aria-label="Save" class="text-primary">
180
+ <Button type="button" data-table-action="confirm" variant="ghost" size="icon-sm" aria-label="Save" class="text-primary">
181
181
  <Check class="group-data-[state=saving]/cell:hidden" aria-hidden="true" />
182
182
  <Spinner class="hidden group-data-[state=saving]/cell:block" aria-label="Saving" />
183
183
  </Button>
184
- <Button data-table-action="cancel" variant="ghost" size="icon-sm" aria-label="Cancel">
184
+ <Button type="button" data-table-action="cancel" variant="ghost" size="icon-sm" aria-label="Cancel">
185
185
  <X aria-hidden="true" />
186
186
  </Button>
187
187
  </div>
@@ -19,6 +19,8 @@ interface Props extends HTMLAttributes<"th"> {
19
19
  resizable?: boolean;
20
20
  /** Initial column width, e.g. "240px". */
21
21
  width?: string;
22
+ /** "end" sticks the column to the right edge while the table scrolls (an actions column). */
23
+ pinned?: "end";
22
24
  }
23
25
 
24
26
  const {
@@ -29,6 +31,7 @@ const {
29
31
  align = "left",
30
32
  resizable = false,
31
33
  width,
34
+ pinned,
32
35
  ...rest
33
36
  } = Astro.props as Props;
34
37
 
@@ -42,6 +45,7 @@ const ariaSort =
42
45
  data-sortable={sortable ? "" : undefined}
43
46
  data-sort={sortable ? sort : undefined}
44
47
  data-align={align}
48
+ data-pinned-end={pinned === "end" ? "" : undefined}
45
49
  aria-sort={ariaSort}
46
50
  scope="col"
47
51
  style={width ? `width: ${width}; min-width: ${width}` : undefined}
@@ -49,7 +53,7 @@ const ariaSort =
49
53
  cellBase,
50
54
  cellAlign({ align }),
51
55
  "group/th h-11 bg-muted text-xs font-semibold text-foreground uppercase",
52
- "group-data-[sticky-header]/table:sticky group-data-[sticky-header]/table:top-0 group-data-[sticky-header]/table:z-10 data-[pinned]:z-20 data-[pinned]:bg-muted",
56
+ "group-data-[sticky-header]/table:sticky group-data-[sticky-header]/table:top-0 group-data-[sticky-header]/table:z-10 data-[pinned]:z-20 data-[pinned]:bg-muted data-[pinned-end]:z-20 data-[pinned-end]:bg-muted",
53
57
  "[&:has([type=checkbox])]:w-15 [&:has([type=checkbox])]:text-center",
54
58
  className,
55
59
  )}
@@ -48,10 +48,10 @@ const to = Math.min(page * pageSize, totalCount);
48
48
  </label>
49
49
  <span data-slot="table-pagination-status" class="text-foreground">Page {page} of {pageCount}</span>
50
50
  <ButtonGroup>
51
- <Button variant="outline" size="icon-sm" data-table-page="first" aria-label="First page" disabled={page <= 1}><ChevronsLeft /></Button>
52
- <Button variant="outline" size="icon-sm" data-table-page="prev" aria-label="Previous page" disabled={page <= 1}><ChevronLeft /></Button>
53
- <Button variant="outline" size="icon-sm" data-table-page="next" aria-label="Next page" disabled={page >= pageCount}><ChevronRight /></Button>
54
- <Button variant="outline" size="icon-sm" data-table-page="last" aria-label="Last page" disabled={page >= pageCount}><ChevronsRight /></Button>
51
+ <Button type="button" variant="outline" size="icon-sm" data-table-page="first" aria-label="First page" disabled={page <= 1}><ChevronsLeft /></Button>
52
+ <Button type="button" variant="outline" size="icon-sm" data-table-page="prev" aria-label="Previous page" disabled={page <= 1}><ChevronLeft /></Button>
53
+ <Button type="button" variant="outline" size="icon-sm" data-table-page="next" aria-label="Next page" disabled={page >= pageCount}><ChevronRight /></Button>
54
+ <Button type="button" variant="outline" size="icon-sm" data-table-page="last" aria-label="Last page" disabled={page >= pageCount}><ChevronsRight /></Button>
55
55
  </ButtonGroup>
56
56
  </div>
57
57
  </nav>
@@ -42,7 +42,9 @@ const {
42
42
  aria-expanded={folder ? String(expanded) : undefined}
43
43
  aria-selected={selected ? "true" : undefined}
44
44
  class={cn(
45
- "group/row bg-background transition-colors",
45
+ "group/row relative bg-background transition-colors",
46
+ // Row focus: an element marked `data-row-focus` (e.g. the row's name link) draws its focus ring round the whole row.
47
+ "[&_[data-row-focus]]:outline-none [&_[data-row-focus]]:after:pointer-events-none [&_[data-row-focus]]:after:absolute [&_[data-row-focus]]:after:inset-0 [&_[data-row-focus]]:after:z-[3] [&_[data-row-focus]:focus-visible]:after:inset-ring-3 [&_[data-row-focus]:focus-visible]:after:inset-ring-ring/50",
46
48
  "[tbody_&]:hover:bg-table-row-hover data-[state=selected]:bg-table-row-selected [tbody_&]:data-[state=selected]:hover:bg-table-row-selected",
47
49
  className,
48
50
  )}
@@ -5,6 +5,8 @@ export const cellBase = [
5
5
  "relative border-b border-border px-4 align-middle whitespace-nowrap first:pl-6 last:pr-6",
6
6
  "outline-none focus-visible:z-[2] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
7
7
  "data-[pinned]:sticky data-[pinned]:z-[1] data-[pinned]:bg-inherit data-[pinned-last]:shadow-[inset_-1px_0_0_var(--border)]",
8
+ // pinned="end": stuck to the right edge (an actions column), on the row's own background so it follows hover.
9
+ "data-[pinned-end]:sticky data-[pinned-end]:right-0 data-[pinned-end]:z-[1] data-[pinned-end]:bg-inherit",
8
10
  ];
9
11
 
10
12
  export const cellAlign = cva("", {
@@ -2,4 +2,20 @@
2
2
 
3
3
  All notable changes to the `Tabs` component will be documented in this file.
4
4
 
5
+ ## Date 15th Oct 2026
6
+ ## Indicator on a bordered list
7
+
8
+ - The `@data-slot/tabs` runtime subtracts the list's border width twice, so on a `TabsList` with a real border (e.g. `class="border"`) the indicator sat 1px off. `Tabs` now records each list's border widths (`--tabs-list-border-l`, `--tabs-list-border-t`) and both indicators add them back along the list (left when horizontal, top when vertical; the runtime measures the other axis correctly); with no border they are 0, so nothing else changes. A workaround for a runtime bug (bejamas/data-slot).
9
+
10
+
11
+ ## Date 15th Oct 2026
12
+ ## Workspace selection handover (C11)
13
+
14
+ ### Added
15
+
16
+ - `bordered` prop on `TabsList` (default `false`): a 1px `border`-token outline round the list, sets `data-bordered`. For a tabs list on a light grey page beside bordered controls (search field, buttons), where the muted fill alone blends in (D104). The list keeps its height.
17
+ - Drawn as an inset ring (`ring-1 ring-inset ring-border`) with 1px more padding, not a CSS `border`: same geometry, but the tabs runtime positions the indicator from `offsetLeft` and then subtracts the list's `clientLeft` (its border width) again, so with a real border (as the screen's `class="border"`) the indicator sat 1px left of its tab. Worth fixing upstream in `@data-slot/tabs`.
18
+ - Opt-in: existing tabs lists look the same. Replaces the workspace-selection screen's `class="border"`.
19
+ - Storybook: a "Bordered" preview.
20
+
5
21
  ## Unreleased