@cl-light-ds/ui 0.3.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.
- package/package.json +20 -18
- package/src/components/alert/Alert.astro +8 -2
- package/src/components/alert/CHANGELOG.md +9 -0
- package/src/components/button/Button.astro +78 -19
- package/src/components/button/CHANGELOG.md +22 -0
- package/src/components/button-group/ButtonGroup.astro +14 -3
- package/src/components/button-group/CHANGELOG.md +17 -0
- package/src/components/callout/CHANGELOG.md +32 -0
- package/src/components/callout/Callout.astro +147 -0
- package/src/components/callout/CalloutDescription.astro +16 -0
- package/src/components/callout/CalloutTitle.astro +16 -0
- package/src/components/callout/index.ts +3 -0
- package/src/components/card/CHANGELOG.md +9 -0
- package/src/components/card/Card.astro +22 -0
- package/src/components/combobox/CHANGELOG.md +14 -0
- package/src/components/combobox/ComboboxTrigger.astro +31 -1
- package/src/components/command-search/CHANGELOG.md +27 -0
- package/src/components/command-search/CommandSearch.astro +515 -0
- package/src/components/command-search/CommandSearchGroup.astro +32 -0
- package/src/components/command-search/CommandSearchItem.astro +64 -0
- package/src/components/command-search/classes.ts +18 -0
- package/src/components/command-search/index.ts +12 -0
- package/src/components/command-search/nothing-matches.svg +42 -0
- package/src/components/dialog/CHANGELOG.md +13 -0
- package/src/components/dialog/DialogClose.astro +26 -12
- package/src/components/dropdown-menu/CHANGELOG.md +7 -0
- package/src/components/dropdown-menu/dropdown-menu-item-variants.ts +1 -1
- package/src/components/empty-state/CHANGELOG.md +18 -0
- package/src/components/empty-state/EmptyState.astro +157 -0
- package/src/components/empty-state/illustrations/AllCaughtUp.astro +52 -0
- package/src/components/empty-state/illustrations/EmptyWorkspaces.astro +56 -0
- package/src/components/empty-state/illustrations/LoadError.astro +61 -0
- package/src/components/empty-state/illustrations/NoResults.astro +60 -0
- package/src/components/empty-state/illustrations/NothingMatches.astro +66 -0
- package/src/components/empty-state/illustrations/index.ts +27 -0
- package/src/components/empty-state/index.ts +7 -0
- package/src/components/favorite-toggle/CHANGELOG.md +13 -0
- package/src/components/favorite-toggle/FavoriteToggle.astro +94 -0
- package/src/components/favorite-toggle/index.ts +1 -0
- package/src/components/input/CHANGELOG.md +15 -0
- package/src/components/input/InputOTP.astro +211 -0
- package/src/components/input/index.ts +1 -1
- package/src/components/input-group/CHANGELOG.md +7 -0
- package/src/components/input-group/InputGroup.astro +2 -0
- package/src/components/loading-overlay/CHANGELOG.md +25 -0
- package/src/components/loading-overlay/LoadingOverlay.astro +179 -0
- package/src/components/loading-overlay/index.ts +8 -0
- package/src/components/logo/CHANGELOG.md +17 -0
- package/src/components/logo/Logo.astro +90 -0
- package/src/components/logo/index.ts +3 -0
- package/src/components/logo/logo-paths.ts +57 -0
- package/src/components/notifications-menu/CHANGELOG.md +22 -0
- package/src/components/notifications-menu/NotificationsMenu.astro +374 -0
- package/src/components/notifications-menu/NotificationsMenuItem.astro +129 -0
- package/src/components/notifications-menu/all-caught-up.svg +28 -0
- package/src/components/notifications-menu/index.ts +3 -0
- package/src/components/notifications-menu/types.ts +32 -0
- package/src/components/option-card/CHANGELOG.md +16 -0
- package/src/components/option-card/OptionCard.astro +135 -0
- package/src/components/option-card/index.ts +1 -0
- package/src/components/organisms/top-bar/CHANGELOG.md +21 -0
- package/src/components/organisms/top-bar/TopBar.astro +264 -0
- package/src/components/organisms/top-bar/index.ts +2 -0
- package/src/components/organisms/top-bar/types.ts +30 -0
- package/src/components/organisms/workspace/CHANGELOG.md +73 -0
- package/src/components/organisms/workspace/OrganisationOverview.astro +62 -0
- package/src/components/organisms/workspace/RecentWorkspaceCard.astro +235 -0
- package/src/components/organisms/workspace/RecentWorkspaces.astro +93 -0
- package/src/components/organisms/workspace/WorkspaceAvatar.astro +144 -0
- package/src/components/organisms/workspace/WorkspaceCard.astro +172 -0
- package/src/components/organisms/workspace/WorkspaceGlyph.astro +62 -0
- package/src/components/organisms/workspace/WorkspaceIcon.astro +42 -0
- package/src/components/organisms/workspace/WorkspaceIconPicker.astro +288 -0
- package/src/components/organisms/workspace/WorkspaceIconPickerGlyph.astro +44 -0
- package/src/components/organisms/workspace/icons.ts +98 -0
- package/src/components/organisms/workspace/index.ts +35 -0
- package/src/components/organisms/workspace/labels.ts +54 -0
- package/src/components/organisms/workspace/recent-card-rings-strong.png +0 -0
- package/src/components/organisms/workspace/types.ts +30 -0
- package/src/components/profile-menu/CHANGELOG.md +22 -0
- package/src/components/profile-menu/ProfileMenu.astro +236 -0
- package/src/components/profile-menu/index.ts +2 -0
- package/src/components/profile-menu/types.ts +27 -0
- package/src/components/search-field/CHANGELOG.md +14 -0
- package/src/components/search-field/SearchField.astro +163 -0
- package/src/components/search-field/index.ts +1 -0
- package/src/components/table/CHANGELOG.md +9 -0
- package/src/components/table/TableCell.astro +6 -0
- package/src/components/table/TableHead.astro +5 -1
- package/src/components/table/TableRow.astro +3 -1
- package/src/components/table/table-cell-variants.ts +2 -0
- package/src/components/toast/CHANGELOG.md +32 -0
- package/src/components/toast/Toast.astro +145 -0
- package/src/components/toast/Toaster.astro +322 -0
- package/src/components/toast/index.ts +9 -0
- package/src/components/toggle/CHANGELOG.md +7 -0
- package/src/lib/command-search.ts +73 -0
- package/src/lib/loading-overlay.ts +174 -0
- package/src/lib/toast.ts +127 -0
- package/src/lib/toggle-shared.ts +4 -1
- package/src/lib/workspace-palette.ts +119 -0
- package/src/styles/CHANGELOG.md +22 -0
- package/src/styles/globals.css +100 -1
|
@@ -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,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,15 @@
|
|
|
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 (C13)
|
|
7
|
+
|
|
8
|
+
- **`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.
|
|
9
|
+
- **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`.
|
|
10
|
+
- Empty and error rows (`TableEmpty`) were already white; no change.
|
|
11
|
+
- Storybook: "Pinned actions column and row focus" preview.
|
|
12
|
+
|
|
13
|
+
|
|
5
14
|
## 2026-09-18 — Figma alignment (ClickLearn Lightning Design Library, node `5548-9089`)
|
|
6
15
|
|
|
7
16
|
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}
|
|
@@ -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
|
)}
|
|
@@ -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("", {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `Toast` component family (`Toaster`, `Toast`, and the `toast()` API in `@cl-light-ds/ui/lib/toast`) are documented in this file.
|
|
4
|
+
|
|
5
|
+
## Date 15th Oct 2026
|
|
6
|
+
## Workspace selection handover (N9)
|
|
7
|
+
|
|
8
|
+
New component, replacing the workspace-selection screen's hand-composed `Toast.astro`, the toast stack in `TopBar.astro` and `restackToasts` (gap G14).
|
|
9
|
+
|
|
10
|
+
### `Toaster`
|
|
11
|
+
|
|
12
|
+
- Built on `@data-slot/toast` (`createToast`): the runtime renders toasts from a `<template>`, keeps their order, sets `role`/`aria-live` per kind (error → `alert`, assertive; others → `status`, polite), pauses timers on hover and focus, unfurls on hover or focus within (`data-expanded`), supports swipe to dismiss and measures heights (`--toast-*` tokens).
|
|
13
|
+
- Placement (D160): fixed under the top bar's right end. Props `top` (where the bar ends, default 41px), `offset` (gap, default 0.5rem), `right` (inset, default 1rem, 1.5rem from md). The right edge adds `var(--data-slot-scrollbar-width, 0px)` so the stack stays put while a dialog hides the page scrollbar.
|
|
14
|
+
- Stacking (D161, D164, D167): the newest in front, two more peeking out 10px below, 5% smaller each and blank; a fourth and beyond hidden while stacked. Unfurled: a list 12px apart, each at its own height, with a softer shadow (`shadow-xs`). All moves take 300ms.
|
|
15
|
+
- Clear all (D170–D172, C23): with two or more toasts, unfurling shows a link button above the list, flush under the bar (the gap closes), no hover background, underline only. Label via `clearAllLabel`.
|
|
16
|
+
- Toasts stay until closed by default (`duration={0}`, D166); `limit` (default 5) caps how many are kept visible.
|
|
17
|
+
- Entrance (D160): slides in 2rem from the right and fades; slides back out on close.
|
|
18
|
+
- Attention (D164, D175): once in (300ms), a ring ripples out three times (info blue, success green) or the card shakes 12px, seven swings (error). Only the newest toast. Skipped with `prefers-reduced-motion`, which also drops the slide (fade only).
|
|
19
|
+
|
|
20
|
+
### `Toast`
|
|
21
|
+
|
|
22
|
+
- Kinds `info` (blue `Info` icon), `success` (green `CircleCheck`), `error` (`callout-background-error` tint, `destructive/40` border, red `CircleX` icon and title, foreground text). Icon and ✕ at the top, outline action button centred on the right (D168, D169).
|
|
23
|
+
- Usable on its own as a static toast (docs, previews).
|
|
24
|
+
|
|
25
|
+
### `toast()` (`@cl-light-ds/ui/lib/toast`)
|
|
26
|
+
|
|
27
|
+
- `toast({ kind, title, description, action: { label, onClick, href, download }, duration, id })` returns the id; `toast.update`, `toast.dismiss`, `toast.clear`. Wraps the runtime's inbound `toast:*` events; calls made before the Toaster is bound are queued.
|
|
28
|
+
- `action.href` (+ `download`) opens or downloads a file: added on top of the runtime, whose action is a plain button.
|
|
29
|
+
|
|
30
|
+
### Storybook
|
|
31
|
+
|
|
32
|
+
- "Toast" section: static kinds, live buttons per kind, a stack of three, and Clear all.
|