@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,374 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* @component NotificationsMenu
|
|
4
|
+
* @title Notifications Menu
|
|
5
|
+
* @description A bell button with a red dot while there are unseen notifications, opening a 360px popover: a header with Clear all, the notifications (dismiss one with ✕), and an empty state.
|
|
6
|
+
*
|
|
7
|
+
* @preview
|
|
8
|
+
*
|
|
9
|
+
* <NotificationsMenu
|
|
10
|
+
* notifications={[
|
|
11
|
+
* { id: "1", title: "Anna Lee commented", text: "“Step 4 needs a new screenshot.”", context: "Northwind Group", time: "5 min ago", initials: "AL", new: true },
|
|
12
|
+
* { id: "2", title: "Your recording got 120 views", text: "“Create a sales order” this week.", context: "Contoso", time: "Yesterday", icon: Eye },
|
|
13
|
+
* ]}
|
|
14
|
+
* />
|
|
15
|
+
*
|
|
16
|
+
* @usage
|
|
17
|
+
*
|
|
18
|
+
* ```astro nocollapse
|
|
19
|
+
* ---
|
|
20
|
+
* import { NotificationsMenu, NotificationsMenuItem } from '@cl-light-ds/ui/components/notifications-menu';
|
|
21
|
+
* import { CircleCheck, Eye } from '@lucide/astro';
|
|
22
|
+
* ---
|
|
23
|
+
*
|
|
24
|
+
* <!-- From data -->
|
|
25
|
+
* <NotificationsMenu
|
|
26
|
+
* notifications={[
|
|
27
|
+
* { id: "1", title: "Anna Lee commented", text: "Step 4 needs a new screenshot.", context: "Northwind Group", time: "5 min ago", initials: "AL", new: true },
|
|
28
|
+
* { id: "2", title: "120 new views", context: "Contoso", time: "Yesterday", icon: Eye },
|
|
29
|
+
* ]}
|
|
30
|
+
* />
|
|
31
|
+
*
|
|
32
|
+
* <!-- Or composed, e.g. for an item with an action -->
|
|
33
|
+
* <NotificationsMenu>
|
|
34
|
+
* <NotificationsMenuItem id="export-1" title="Your export is ready" icon={CircleCheck} tone="success" time="Just now" new>
|
|
35
|
+
* <Button as="a" href="/export.zip" download variant="outline" size="sm">Download</Button>
|
|
36
|
+
* </NotificationsMenuItem>
|
|
37
|
+
* </NotificationsMenu>
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @api
|
|
41
|
+
*
|
|
42
|
+
* ### Seen / new
|
|
43
|
+
*
|
|
44
|
+
* A notification with `new` shows a blue dot and keeps the bell's red dot on. It counts as **seen** once
|
|
45
|
+
* at least `seenThreshold` (60%) of it has been in view in the open list, or as soon as it is hovered or
|
|
46
|
+
* keyboard-focused. Hovered / focused ones lose their dot at once; ones seen by scrolling lose it when the
|
|
47
|
+
* popover closes. The bell's red dot and accessible name follow the unseen count live.
|
|
48
|
+
*
|
|
49
|
+
* ### Events (on the `[data-slot="notifications-menu"]` root, bubbling)
|
|
50
|
+
*
|
|
51
|
+
* | Event | Detail | Description |
|
|
52
|
+
* |-------|--------|-------------|
|
|
53
|
+
* | `notifications:dismiss` | `{ id }` | ✕ pressed. Cancelable: `preventDefault()` keeps the item. |
|
|
54
|
+
* | `notifications:clear` | `{ ids }` | Clear all pressed. Cancelable. |
|
|
55
|
+
* | `notifications:seen` | `{ ids }` | These notifications were just seen (in view, hovered or focused). |
|
|
56
|
+
* | `notifications:change` | `{ total, unseen }` | The count or unseen count changed. |
|
|
57
|
+
*
|
|
58
|
+
* Inbound: dispatch `notifications:add` with `{ item: HTMLElement, position?: "start" | "end" }` on the root
|
|
59
|
+
* to add a rendered `NotificationsMenuItem` (e.g. cloned from a `<template>`); it defaults to the top. Items
|
|
60
|
+
* added or removed in the list any other way are picked up too.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import type { HTMLAttributes } from "astro/types";
|
|
64
|
+
import { Bell } from "@lucide/astro";
|
|
65
|
+
import { cn } from "@cl-light-ds/ui/lib/utils";
|
|
66
|
+
import { Button } from "../button";
|
|
67
|
+
import {
|
|
68
|
+
Popover,
|
|
69
|
+
PopoverContent,
|
|
70
|
+
PopoverHeader,
|
|
71
|
+
PopoverTitle,
|
|
72
|
+
PopoverTrigger,
|
|
73
|
+
} from "../popover";
|
|
74
|
+
import { Separator } from "../separator";
|
|
75
|
+
import NotificationsMenuItem from "./NotificationsMenuItem.astro";
|
|
76
|
+
import type { NotificationsMenuItemData } from "./types";
|
|
77
|
+
import allCaughtUp from "./all-caught-up.svg?url";
|
|
78
|
+
|
|
79
|
+
interface Props extends HTMLAttributes<"div"> {
|
|
80
|
+
/** Notifications rendered before any slotted `NotificationsMenuItem`s. */
|
|
81
|
+
notifications?: NotificationsMenuItemData[];
|
|
82
|
+
/** Popover heading. */
|
|
83
|
+
title?: string;
|
|
84
|
+
/** The bell's accessible name with nothing unseen. */
|
|
85
|
+
label?: string;
|
|
86
|
+
/** The bell's accessible name with unseen ones; `{count}` is replaced. */
|
|
87
|
+
unseenLabel?: string;
|
|
88
|
+
clearLabel?: string;
|
|
89
|
+
/** Accessible name of each ✕; `{title}` is replaced. */
|
|
90
|
+
dismissLabel?: string;
|
|
91
|
+
/** Screen-reader text after a new notification. */
|
|
92
|
+
newLabel?: string;
|
|
93
|
+
emptyTitle?: string;
|
|
94
|
+
emptyDescription?: string;
|
|
95
|
+
/** Image URL for the empty state; `false` for none. Default: the "all caught up" illustration. */
|
|
96
|
+
emptyIllustration?: string | false;
|
|
97
|
+
/** Share of an item that must be in view to count as seen (0–1). */
|
|
98
|
+
seenThreshold?: number;
|
|
99
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
100
|
+
align?: "start" | "center" | "end";
|
|
101
|
+
sideOffset?: number;
|
|
102
|
+
class?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const {
|
|
106
|
+
notifications = [],
|
|
107
|
+
title = "Notifications",
|
|
108
|
+
label = "Notifications",
|
|
109
|
+
unseenLabel = "Notifications — {count} new",
|
|
110
|
+
clearLabel = "Clear all",
|
|
111
|
+
dismissLabel = "Dismiss: {title}",
|
|
112
|
+
newLabel = "(new)",
|
|
113
|
+
emptyTitle = "You're all caught up",
|
|
114
|
+
emptyDescription = "New notifications will show up here.",
|
|
115
|
+
emptyIllustration = allCaughtUp,
|
|
116
|
+
seenThreshold = 0.6,
|
|
117
|
+
side = "bottom",
|
|
118
|
+
align = "end",
|
|
119
|
+
sideOffset = 6,
|
|
120
|
+
class: className = "",
|
|
121
|
+
...rest
|
|
122
|
+
} = Astro.props as Props;
|
|
123
|
+
|
|
124
|
+
const hasSlotItems = Astro.slots.has("default");
|
|
125
|
+
const hasEmptySlot = Astro.slots.has("empty");
|
|
126
|
+
const empty = notifications.length === 0 && !hasSlotItems;
|
|
127
|
+
// Server-side guess from the data; the script recounts slotted items on load.
|
|
128
|
+
const unseen = notifications.filter((n) => n.new).length;
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
<div
|
|
132
|
+
{...rest}
|
|
133
|
+
data-slot="notifications-menu"
|
|
134
|
+
data-label={label}
|
|
135
|
+
data-unseen-label={unseenLabel}
|
|
136
|
+
data-seen-threshold={seenThreshold}
|
|
137
|
+
class={cn("inline-flex", className)}
|
|
138
|
+
>
|
|
139
|
+
<Popover class="flex">
|
|
140
|
+
<PopoverTrigger
|
|
141
|
+
variant="ghost"
|
|
142
|
+
size="icon-sm"
|
|
143
|
+
class="group/bell relative"
|
|
144
|
+
aria-label={unseen ? unseenLabel.replace("{count}", String(unseen)) : label}
|
|
145
|
+
data-unseen={unseen ? "true" : "false"}
|
|
146
|
+
data-notifications-bell
|
|
147
|
+
>
|
|
148
|
+
<Bell />
|
|
149
|
+
{/* The red dot, while any notification is unseen (D141). */}
|
|
150
|
+
<span
|
|
151
|
+
data-slot="notifications-menu-badge"
|
|
152
|
+
class="bg-destructive ring-background absolute top-1 right-1 hidden size-2 rounded-full ring-2 group-data-[unseen=true]/bell:block"
|
|
153
|
+
aria-hidden="true"
|
|
154
|
+
></span>
|
|
155
|
+
</PopoverTrigger>
|
|
156
|
+
|
|
157
|
+
<PopoverContent
|
|
158
|
+
side={side}
|
|
159
|
+
align={align}
|
|
160
|
+
sideOffset={sideOffset}
|
|
161
|
+
class="group/notifications w-90 max-w-[calc(100vw-1rem)] gap-0 p-0"
|
|
162
|
+
data-empty={empty ? "true" : "false"}
|
|
163
|
+
data-notifications-content
|
|
164
|
+
>
|
|
165
|
+
<PopoverHeader class="flex-row items-center justify-between gap-2 p-3">
|
|
166
|
+
<PopoverTitle>{title}</PopoverTitle>
|
|
167
|
+
<Button
|
|
168
|
+
variant="link"
|
|
169
|
+
size="sm"
|
|
170
|
+
class="group-data-[empty=true]/notifications:hidden"
|
|
171
|
+
data-notifications-clear
|
|
172
|
+
>
|
|
173
|
+
{clearLabel}
|
|
174
|
+
</Button>
|
|
175
|
+
</PopoverHeader>
|
|
176
|
+
|
|
177
|
+
<Separator />
|
|
178
|
+
|
|
179
|
+
<ul
|
|
180
|
+
data-slot="notifications-menu-list"
|
|
181
|
+
aria-label={title}
|
|
182
|
+
class="flex max-h-80 flex-col overflow-y-auto group-data-[empty=true]/notifications:hidden"
|
|
183
|
+
>
|
|
184
|
+
{
|
|
185
|
+
notifications.map((n) => (
|
|
186
|
+
<NotificationsMenuItem {...n} dismissLabel={dismissLabel} newLabel={newLabel} />
|
|
187
|
+
))
|
|
188
|
+
}
|
|
189
|
+
<slot />
|
|
190
|
+
</ul>
|
|
191
|
+
|
|
192
|
+
<div
|
|
193
|
+
data-slot="notifications-menu-empty"
|
|
194
|
+
class="hidden flex-col items-center gap-2 px-6 py-10 text-center group-data-[empty=true]/notifications:flex"
|
|
195
|
+
>
|
|
196
|
+
{
|
|
197
|
+
hasEmptySlot ? (
|
|
198
|
+
<slot name="empty" />
|
|
199
|
+
) : (
|
|
200
|
+
<>
|
|
201
|
+
{emptyIllustration && (
|
|
202
|
+
<img src={emptyIllustration} alt="" width="144" height="104" class="-my-2" />
|
|
203
|
+
)}
|
|
204
|
+
<p class="text-sm font-medium">{emptyTitle}</p>
|
|
205
|
+
{emptyDescription && <p class="text-muted-foreground text-xs">{emptyDescription}</p>}
|
|
206
|
+
</>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
</div>
|
|
210
|
+
</PopoverContent>
|
|
211
|
+
</Popover>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<script>
|
|
215
|
+
const wired = new WeakSet<Element>();
|
|
216
|
+
|
|
217
|
+
function wire(root: HTMLElement) {
|
|
218
|
+
if (wired.has(root)) return;
|
|
219
|
+
wired.add(root);
|
|
220
|
+
|
|
221
|
+
// Hold references now: the popover portals its content to <body> while open.
|
|
222
|
+
const popover = root.querySelector<HTMLElement>('[data-slot="popover"]');
|
|
223
|
+
const bell = root.querySelector<HTMLElement>("[data-notifications-bell]");
|
|
224
|
+
const content = root.querySelector<HTMLElement>("[data-notifications-content]");
|
|
225
|
+
const list = root.querySelector<HTMLElement>('[data-slot="notifications-menu-list"]');
|
|
226
|
+
const clear = root.querySelector<HTMLElement>("[data-notifications-clear]");
|
|
227
|
+
if (!popover || !bell || !content || !list) return;
|
|
228
|
+
|
|
229
|
+
const label = root.dataset.label ?? "Notifications";
|
|
230
|
+
const unseenLabel = root.dataset.unseenLabel ?? "{count}";
|
|
231
|
+
const threshold = Number(root.dataset.seenThreshold ?? 0.6);
|
|
232
|
+
let open = false;
|
|
233
|
+
let last = "";
|
|
234
|
+
|
|
235
|
+
const items = () =>
|
|
236
|
+
Array.from(list.querySelectorAll<HTMLElement>('[data-slot="notifications-menu-item"]'));
|
|
237
|
+
const emit = (name: string, detail: unknown, cancelable = false) =>
|
|
238
|
+
root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, cancelable }));
|
|
239
|
+
|
|
240
|
+
const update = () => {
|
|
241
|
+
const all = items();
|
|
242
|
+
const unseen = all.filter(
|
|
243
|
+
(n) => n.dataset.new === "true" && !n.hasAttribute("data-seen"),
|
|
244
|
+
).length;
|
|
245
|
+
bell.dataset.unseen = String(unseen > 0);
|
|
246
|
+
bell.setAttribute(
|
|
247
|
+
"aria-label",
|
|
248
|
+
unseen ? unseenLabel.replace("{count}", String(unseen)) : label,
|
|
249
|
+
);
|
|
250
|
+
content.dataset.empty = String(all.length === 0);
|
|
251
|
+
const key = `${all.length}:${unseen}`;
|
|
252
|
+
if (key !== last) {
|
|
253
|
+
last = key;
|
|
254
|
+
emit("notifications:change", { total: all.length, unseen });
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** Mark as seen. `now` also drops the blue dot (hover / focus); otherwise it goes on close. */
|
|
259
|
+
const see = (notes: HTMLElement[], now: boolean) => {
|
|
260
|
+
const fresh = notes.filter(
|
|
261
|
+
(n) => n.dataset.new === "true" && !n.hasAttribute("data-seen"),
|
|
262
|
+
);
|
|
263
|
+
for (const n of notes) {
|
|
264
|
+
if (n.dataset.new !== "true") continue;
|
|
265
|
+
if (now) {
|
|
266
|
+
n.dataset.new = "false";
|
|
267
|
+
n.removeAttribute("data-seen");
|
|
268
|
+
} else n.setAttribute("data-seen", "");
|
|
269
|
+
}
|
|
270
|
+
if (fresh.length) emit("notifications:seen", { ids: fresh.map((n) => n.dataset.notification) });
|
|
271
|
+
update();
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const observer = new IntersectionObserver(
|
|
275
|
+
(entries) => {
|
|
276
|
+
if (!open) return;
|
|
277
|
+
see(
|
|
278
|
+
entries.filter((e) => e.isIntersecting).map((e) => e.target as HTMLElement),
|
|
279
|
+
false,
|
|
280
|
+
);
|
|
281
|
+
},
|
|
282
|
+
{ root: list, threshold },
|
|
283
|
+
);
|
|
284
|
+
const watch = () => {
|
|
285
|
+
observer.disconnect();
|
|
286
|
+
for (const n of items()) observer.observe(n);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
popover.addEventListener("popover:change", (event) => {
|
|
290
|
+
open = (event as CustomEvent<{ open: boolean }>).detail.open;
|
|
291
|
+
if (open) {
|
|
292
|
+
// Let it lay out before anything counts as in view.
|
|
293
|
+
requestAnimationFrame(watch);
|
|
294
|
+
} else {
|
|
295
|
+
observer.disconnect();
|
|
296
|
+
for (const n of items()) {
|
|
297
|
+
if (n.hasAttribute("data-seen")) {
|
|
298
|
+
n.dataset.new = "false";
|
|
299
|
+
n.removeAttribute("data-seen");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
update();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Hovering or keyboard-focusing a notification is seeing it (D165).
|
|
307
|
+
const noteOf = (event: Event) =>
|
|
308
|
+
(event.target as HTMLElement).closest<HTMLElement>('[data-slot="notifications-menu-item"]');
|
|
309
|
+
list.addEventListener("mouseover", (event) => {
|
|
310
|
+
const note = noteOf(event);
|
|
311
|
+
if (note) see([note], true);
|
|
312
|
+
});
|
|
313
|
+
list.addEventListener("focusin", (event) => {
|
|
314
|
+
// Only keyboard focus: the popover may focus the first item on open.
|
|
315
|
+
const note = noteOf(event);
|
|
316
|
+
if (note && (event.target as HTMLElement).matches(":focus-visible")) see([note], true);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const focusAfterRemoval = (next: HTMLElement | null) => {
|
|
320
|
+
const target = next?.querySelector<HTMLElement>("[data-notifications-dismiss]");
|
|
321
|
+
if (target) target.focus();
|
|
322
|
+
else {
|
|
323
|
+
if (!content.hasAttribute("tabindex")) content.tabIndex = -1;
|
|
324
|
+
content.focus();
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
list.addEventListener("click", (event) => {
|
|
329
|
+
const dismiss = (event.target as HTMLElement).closest("[data-notifications-dismiss]");
|
|
330
|
+
const note = dismiss?.closest<HTMLElement>('[data-slot="notifications-menu-item"]');
|
|
331
|
+
if (!note) return;
|
|
332
|
+
if (!emit("notifications:dismiss", { id: note.dataset.notification }, true)) return;
|
|
333
|
+
const next =
|
|
334
|
+
(note.nextElementSibling as HTMLElement | null) ??
|
|
335
|
+
(note.previousElementSibling as HTMLElement | null);
|
|
336
|
+
note.remove();
|
|
337
|
+
update();
|
|
338
|
+
focusAfterRemoval(next?.matches('[data-slot="notifications-menu-item"]') ? next : null);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
clear?.addEventListener("click", () => {
|
|
342
|
+
const all = items();
|
|
343
|
+
if (!emit("notifications:clear", { ids: all.map((n) => n.dataset.notification) }, true)) return;
|
|
344
|
+
for (const n of all) n.remove();
|
|
345
|
+
update();
|
|
346
|
+
focusAfterRemoval(null);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
root.addEventListener("notifications:add", (event) => {
|
|
350
|
+
const { item, position = "start" } =
|
|
351
|
+
(event as CustomEvent<{ item?: HTMLElement; position?: "start" | "end" }>).detail ?? {};
|
|
352
|
+
if (!item) return;
|
|
353
|
+
if (position === "end") list.append(item);
|
|
354
|
+
else list.prepend(item);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Items added / removed / re-flagged by app code stay in sync.
|
|
358
|
+
new MutationObserver((records) => {
|
|
359
|
+
if (open) {
|
|
360
|
+
for (const r of records)
|
|
361
|
+
for (const node of r.addedNodes)
|
|
362
|
+
if (node instanceof HTMLElement && node.matches('[data-slot="notifications-menu-item"]'))
|
|
363
|
+
observer.observe(node);
|
|
364
|
+
}
|
|
365
|
+
update();
|
|
366
|
+
}).observe(list, { childList: true, subtree: true, attributeFilter: ["data-new"] });
|
|
367
|
+
|
|
368
|
+
update();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
document
|
|
372
|
+
.querySelectorAll<HTMLElement>('[data-slot="notifications-menu"]')
|
|
373
|
+
.forEach(wire);
|
|
374
|
+
</script>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* @component NotificationsMenuItem
|
|
4
|
+
* @title Notifications Menu Item
|
|
5
|
+
* @description One row of a NotificationsMenu: avatar or icon, title, text, a "context · time" line, a blue "new" dot and a ✕ to dismiss it.
|
|
6
|
+
*
|
|
7
|
+
* @usage
|
|
8
|
+
*
|
|
9
|
+
* ```astro nocollapse
|
|
10
|
+
* <NotificationsMenuItem id="n1" title="Anna commented" text="Looks good to me" context="Northwind" time="2 min ago" initials="AL" new />
|
|
11
|
+
*
|
|
12
|
+
* <NotificationsMenuItem id="n2" title="Your export is ready" icon={CircleCheck} tone="success" time="Just now" new>
|
|
13
|
+
* <Button as="a" href="/export.zip" download variant="outline" size="sm">Download</Button>
|
|
14
|
+
* </NotificationsMenuItem>
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Use it inside `NotificationsMenu`'s default slot, or let `NotificationsMenu` render it from its `notifications` prop.
|
|
18
|
+
* The default slot holds extra content under the text (e.g. an action button); the `media` slot replaces the avatar / icon.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { HTMLAttributes } from "astro/types";
|
|
22
|
+
import { X } from "@lucide/astro";
|
|
23
|
+
import { cva } from "class-variance-authority";
|
|
24
|
+
import { cn } from "@cl-light-ds/ui/lib/utils";
|
|
25
|
+
import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
|
|
26
|
+
import { Button } from "../button";
|
|
27
|
+
import type { NotificationsMenuItemData } from "./types";
|
|
28
|
+
|
|
29
|
+
type Props = Omit<HTMLAttributes<"li">, "id" | "title"> &
|
|
30
|
+
NotificationsMenuItemData & {
|
|
31
|
+
/** Accessible name of the ✕. `{title}` is replaced by the notification's title. */
|
|
32
|
+
dismissLabel?: string;
|
|
33
|
+
/** Screen-reader text after a new notification. */
|
|
34
|
+
newLabel?: string;
|
|
35
|
+
class?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
id,
|
|
40
|
+
title,
|
|
41
|
+
text,
|
|
42
|
+
context,
|
|
43
|
+
time,
|
|
44
|
+
datetime,
|
|
45
|
+
initials,
|
|
46
|
+
avatarSrc,
|
|
47
|
+
icon: Icon,
|
|
48
|
+
tone = "default",
|
|
49
|
+
new: isNew = false,
|
|
50
|
+
dismissLabel = "Dismiss: {title}",
|
|
51
|
+
newLabel = "(new)",
|
|
52
|
+
class: className = "",
|
|
53
|
+
...rest
|
|
54
|
+
} = Astro.props as Props;
|
|
55
|
+
|
|
56
|
+
const hasMedia = Astro.slots.has("media");
|
|
57
|
+
const hasExtra = Astro.slots.has("default");
|
|
58
|
+
|
|
59
|
+
const iconChipVariants = cva(
|
|
60
|
+
"bg-muted flex size-6 shrink-0 items-center justify-center rounded-full [&_svg]:size-3.5",
|
|
61
|
+
{
|
|
62
|
+
variants: {
|
|
63
|
+
tone: {
|
|
64
|
+
default: "text-muted-foreground",
|
|
65
|
+
success: "text-success",
|
|
66
|
+
warning: "text-warning",
|
|
67
|
+
destructive: "text-destructive",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
defaultVariants: { tone: "default" },
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
<li
|
|
76
|
+
{...rest}
|
|
77
|
+
data-slot="notifications-menu-item"
|
|
78
|
+
data-notification={id}
|
|
79
|
+
data-new={isNew ? "true" : "false"}
|
|
80
|
+
class={cn(
|
|
81
|
+
"group/note relative flex items-start gap-3 border-b border-border p-3 pe-9 transition-colors last:border-b-0",
|
|
82
|
+
className,
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{
|
|
86
|
+
hasMedia ? (
|
|
87
|
+
<slot name="media" />
|
|
88
|
+
) : initials || avatarSrc ? (
|
|
89
|
+
<Avatar size="sm" variant={avatarSrc ? "image" : "initials"}>
|
|
90
|
+
{avatarSrc && <AvatarImage src={avatarSrc} alt="" />}
|
|
91
|
+
<AvatarFallback>{initials}</AvatarFallback>
|
|
92
|
+
</Avatar>
|
|
93
|
+
) : Icon ? (
|
|
94
|
+
<span class={iconChipVariants({ tone })} aria-hidden="true">
|
|
95
|
+
<Icon />
|
|
96
|
+
</span>
|
|
97
|
+
) : null
|
|
98
|
+
}
|
|
99
|
+
<span class={cn("flex min-w-0 flex-1 flex-col", hasExtra ? "gap-1" : "gap-0.5")}>
|
|
100
|
+
<span data-slot="notifications-menu-item-title" class="text-foreground text-sm font-medium">{title}</span>
|
|
101
|
+
{text && <span class="text-muted-foreground text-xs">{text}</span>}
|
|
102
|
+
{hasExtra && <span data-slot="notifications-menu-item-extra"><slot /></span>}
|
|
103
|
+
{
|
|
104
|
+
(context || time) && (
|
|
105
|
+
<span class="text-muted-foreground/70 text-xs">
|
|
106
|
+
{context}
|
|
107
|
+
{context && time && " · "}
|
|
108
|
+
{time && (datetime ? <time datetime={datetime}>{time}</time> : time)}
|
|
109
|
+
</span>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
</span>
|
|
113
|
+
{/* The "new" dot: centred vertically, clear of the ✕ (D154). Never alone: sr-only text too. */}
|
|
114
|
+
<span
|
|
115
|
+
data-slot="notifications-menu-item-dot"
|
|
116
|
+
class="bg-primary absolute top-1/2 right-3.5 hidden size-2 -translate-y-1/2 rounded-full group-focus-within/note:hidden group-hover/note:hidden group-data-[new=true]/note:block"
|
|
117
|
+
aria-hidden="true"
|
|
118
|
+
></span>
|
|
119
|
+
<span class="sr-only group-data-[new=false]/note:hidden">{newLabel}</span>
|
|
120
|
+
<Button
|
|
121
|
+
variant="ghost"
|
|
122
|
+
size="icon-xs"
|
|
123
|
+
class="absolute top-2 right-2 opacity-0 group-focus-within/note:opacity-100 group-hover/note:opacity-100 pointer-coarse:opacity-100"
|
|
124
|
+
aria-label={dismissLabel.replace("{title}", title)}
|
|
125
|
+
data-notifications-dismiss
|
|
126
|
+
>
|
|
127
|
+
<X />
|
|
128
|
+
</Button>
|
|
129
|
+
</li>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="144" height="104" viewBox="-12 -12 144 104" fill="none" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo1ZTFjODE4MC05NTYxLTQ3YWYtOGYwMy0zNWJmODU4YjViN2EAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaC+NPxB7N/p9adnc2qsfPxgAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpjYjNhMTNlZS1kN2NlLTQyNGYtODBiYi1mM2E3MjEwNjRhZmZscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNotRHSjZb1m13eqMxg3CrdWwAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggaByci08iEbo8a7fyckB98mnsuZspV6glutU6CRYHq7ikZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaCE4XFoYfQdK2oaIn4KqoaMAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCBwyJmeK42jGvzuOwLqXoDrEDyRkMXdC7jb6T0G7aNtl2RuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBimZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjVlMWM4MTgwLTk1NjEtNDdhZi04ZjAzLTM1YmY4NThiNWI3YS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjQwNzRmMDY1LWM3ZGEtNDE5My04MmJkLTE5MTI2NjA1M2NkMnJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCBoHJyLTyIRujxrt/JyQH3yaey5mylXqCW61ToJFgeruKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggDoC/w970Rz1Igqx1EVS7RMxcJhZaAy+qJS5QZcSbvbqiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggNp2qc1mvgSvJEClX2ZNnwW8h6YEISoyFzEQlrAgN7hl0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQOsPa7KlN7A+bxSZMbiwEHyIQO5N1z1/LriHBGTnTLVR2U8DysV/pK+fSfYixl2JUMqvt8cAN/cU5VG46RwGmuI=</c2pa:manifest></metadata>
|
|
2
|
+
<defs>
|
|
3
|
+
<filter id="sBack" x="-30%" y="-40%" width="160%" height="190%" color-interpolation-filters="sRGB">
|
|
4
|
+
<feDropShadow dx="0" dy="1.5" stdDeviation="2" flood-color="#051729" flood-opacity="0.05"></feDropShadow>
|
|
5
|
+
</filter>
|
|
6
|
+
<filter id="sFront" x="-40%" y="-40%" width="180%" height="190%" color-interpolation-filters="sRGB">
|
|
7
|
+
<feDropShadow dx="0" dy="5" stdDeviation="6" flood-color="#051729" flood-opacity="0.10"></feDropShadow>
|
|
8
|
+
</filter>
|
|
9
|
+
<filter id="sCheck" x="-60%" y="-60%" width="220%" height="220%" color-interpolation-filters="sRGB">
|
|
10
|
+
<feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#3785E0" flood-opacity="0.32"></feDropShadow>
|
|
11
|
+
</filter>
|
|
12
|
+
</defs>
|
|
13
|
+
<circle cx="60" cy="40" r="35" stroke="#F5F9FE" stroke-width="10"></circle>
|
|
14
|
+
<circle cx="60" cy="40" r="19" stroke="#EBF3FC" stroke-width="8"></circle>
|
|
15
|
+
<g filter="url(#sBack)"><rect x="22" y="12" width="76" height="18" rx="6" fill="#FFFFFF"></rect></g>
|
|
16
|
+
<rect x="22.5" y="12.5" width="75" height="17" rx="5.5" stroke="#09090B" stroke-opacity="0.06"></rect>
|
|
17
|
+
<circle cx="31" cy="21" r="4" fill="#F4F4F5"></circle>
|
|
18
|
+
<rect x="39" y="19" width="30" height="4" rx="2" fill="#F4F4F5"></rect>
|
|
19
|
+
<g filter="url(#sFront)"><rect x="16" y="34" width="88" height="24" rx="7" fill="#FFFFFF"></rect></g>
|
|
20
|
+
<rect x="16.5" y="34.5" width="87" height="23" rx="6.5" stroke="#09090B" stroke-opacity="0.06"></rect>
|
|
21
|
+
<circle cx="28" cy="46" r="6" fill="#EBF3FC"></circle>
|
|
22
|
+
<path d="M25.5 45.5 a2.5 2.5 0 0 1 5 0 c0 2.2 1 3 1 3 h-7 s1 -0.8 1 -3 Z M27.2 50.2 a1 1 0 0 0 1.6 0" stroke="#3785E0" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"></path>
|
|
23
|
+
<rect x="39" y="41" width="40" height="4" rx="2" fill="#E4E4E7"></rect>
|
|
24
|
+
<rect x="39" y="48.5" width="26" height="3.5" rx="1.75" fill="#F4F4F5"></rect>
|
|
25
|
+
<g filter="url(#sCheck)"><circle cx="100" cy="58" r="11" fill="#3785E0"></circle></g>
|
|
26
|
+
<circle cx="100" cy="58" r="11" stroke="#FFFFFF" stroke-width="2.5"></circle>
|
|
27
|
+
<path d="M95.5 58.2 l3 3 l6 -6" stroke="#FFFFFF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path>
|
|
28
|
+
</svg>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AstroComponent } from "@lucide/astro";
|
|
2
|
+
|
|
3
|
+
/** Colour of a notification's icon chip. */
|
|
4
|
+
export type NotificationTone = "default" | "success" | "warning" | "destructive";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One notification. Everything except `icon` is JSON-serialisable, so a page
|
|
8
|
+
* can pass an API response straight into `<NotificationsMenu notifications={…} />`.
|
|
9
|
+
*/
|
|
10
|
+
export interface NotificationsMenuItemData {
|
|
11
|
+
/** Stable id. Sent back in `notifications:dismiss` / `notifications:seen`. */
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
/** Second line, muted. */
|
|
15
|
+
text?: string;
|
|
16
|
+
/** Where it happened (a workspace, project, team…). Shown as "context · time". */
|
|
17
|
+
context?: string;
|
|
18
|
+
/** Display time, e.g. "2 min ago". */
|
|
19
|
+
time?: string;
|
|
20
|
+
/** Machine-readable time for the `<time>` element (ISO 8601). */
|
|
21
|
+
datetime?: string;
|
|
22
|
+
/** Initials for an Avatar. Takes precedence over `icon`. */
|
|
23
|
+
initials?: string;
|
|
24
|
+
/** Avatar image (with `initials` as its fallback). */
|
|
25
|
+
avatarSrc?: string;
|
|
26
|
+
/** A Lucide icon, shown in a round chip when there is no avatar. */
|
|
27
|
+
icon?: AstroComponent;
|
|
28
|
+
/** Colour of the icon chip. Default `"default"` (muted). */
|
|
29
|
+
tone?: NotificationTone;
|
|
30
|
+
/** Unseen: shows the blue dot and counts towards the bell's red dot. */
|
|
31
|
+
new?: boolean;
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `OptionCard` component will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## Date 15th Oct 2026
|
|
6
|
+
## Workspace selection handover (N18)
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `OptionCard`: a radio choice as a card, for use inside `RadioGroup` (which owns the value, roving focus, arrow keys, `role="radio"`/`aria-checked` and the hidden form input).
|
|
11
|
+
- Radio, bold title, optional `badge` slot beside it, muted description. `title`/`description` props, or `title`/`description` slots for rich text.
|
|
12
|
+
- Selected (`data-checked` on its radio): `border-primary` on `badge-accent` at 50%. No JavaScript of its own; the state is read with `has-[]` from the runtime's attributes.
|
|
13
|
+
- Optional nested content (default slot), shown only while the card is chosen, outside the `<label>` so it can hold its own controls. `content="inset"` (notes under the description after a divider, as Duplicate's link details), `"panel"` (white bordered panel, as Export's checklist) or `"plain"`.
|
|
14
|
+
- `disabled` dims the card and disables its radio; `id` goes on the radio item.
|
|
15
|
+
- Accent badges on a chosen card blend into its tint; give them `class="border-chart-1"` (C19). Not applied automatically, since `Badge` exposes no variant hook.
|
|
16
|
+
- Storybook: "Option Card" under Forms.
|