@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.
- package/package.json +19 -17
- package/src/components/accordion/AccordionTrigger.astro +5 -1
- package/src/components/accordion/CHANGELOG.md +9 -0
- package/src/components/alert/Alert.astro +42 -3
- package/src/components/alert/CHANGELOG.md +19 -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/card/CHANGELOG.md +9 -0
- package/src/components/card/Card.astro +22 -0
- package/src/components/collapsible/CHANGELOG.md +9 -0
- package/src/components/collapsible/CollapsibleTrigger.astro +5 -1
- package/src/components/combobox/CHANGELOG.md +27 -0
- package/src/components/combobox/Combobox.astro +18 -1
- package/src/components/combobox/ComboboxTrigger.astro +32 -2
- package/src/components/command-search/CHANGELOG.md +57 -0
- package/src/components/command-search/CommandSearch.astro +594 -0
- package/src/components/command-search/CommandSearchGroup.astro +43 -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 +50 -0
- package/src/components/dialog/Dialog.astro +14 -3
- package/src/components/dialog/DialogClose.astro +28 -12
- package/src/components/dialog/DialogContent.astro +19 -1
- package/src/components/dialog/DialogTrigger.astro +5 -1
- package/src/components/dropdown-menu/CHANGELOG.md +32 -0
- package/src/components/dropdown-menu/DropdownMenu.astro +13 -3
- package/src/components/dropdown-menu/DropdownMenuRadioGroup.astro +17 -2
- package/src/components/dropdown-menu/DropdownMenuTrigger.astro +7 -1
- 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/field/CHANGELOG.md +18 -0
- package/src/components/field/Field.astro +18 -0
- package/src/components/field/FieldError.astro +13 -1
- package/src/components/field/FieldGroup.astro +12 -3
- package/src/components/hover-card/CHANGELOG.md +9 -0
- package/src/components/hover-card/HoverCardTrigger.astro +5 -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/notifications-menu/CHANGELOG.md +29 -0
- package/src/components/notifications-menu/NotificationsMenu.astro +375 -0
- package/src/components/notifications-menu/NotificationsMenuItem.astro +130 -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 +26 -0
- package/src/components/option-card/OptionCard.astro +143 -0
- package/src/components/option-card/index.ts +1 -0
- package/src/components/organisms/top-bar/CHANGELOG.md +27 -0
- package/src/components/organisms/top-bar/TopBar.astro +257 -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 +80 -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/popover/CHANGELOG.md +14 -0
- package/src/components/popover/PopoverClose.astro +2 -1
- package/src/components/popover/PopoverTrigger.astro +7 -1
- 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/select/CHANGELOG.md +8 -0
- package/src/components/select/SelectTrigger.astro +1 -1
- package/src/components/table/CHANGELOG.md +15 -0
- package/src/components/table/TableCell.astro +6 -0
- package/src/components/table/TableEditableCell.astro +2 -2
- package/src/components/table/TableHead.astro +5 -1
- package/src/components/table/TablePagination.astro +4 -4
- package/src/components/table/TableRow.astro +3 -1
- package/src/components/table/table-cell-variants.ts +2 -0
- package/src/components/tabs/CHANGELOG.md +16 -0
- package/src/components/tabs/Tabs.astro +26 -0
- package/src/components/tabs/TabsList.astro +30 -3
- 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/components/toggle-group/CHANGELOG.md +11 -0
- package/src/components/toggle-group/ToggleGroup.astro +19 -2
- package/src/components/toggle-group/ToggleGroupItem.astro +10 -0
- package/src/components/tooltip/CHANGELOG.md +26 -0
- package/src/components/tooltip/Tooltip.astro +31 -3
- package/src/components/tooltip/TooltipTrigger.astro +5 -1
- package/src/lib/command-search.ts +78 -0
- package/src/lib/dialog-focus.ts +73 -0
- package/src/lib/dropdown-menu-radio-groups.ts +79 -0
- package/src/lib/loading-overlay.ts +174 -0
- package/src/lib/scroll-lock.ts +97 -0
- package/src/lib/toast.ts +127 -0
- package/src/lib/toggle-shared.ts +9 -1
- package/src/lib/workspace-palette.ts +119 -0
- package/src/styles/CHANGELOG.md +35 -0
- package/src/styles/globals.css +109 -0
|
@@ -99,6 +99,8 @@ const {
|
|
|
99
99
|
data-slot="input-group"
|
|
100
100
|
data-size={size}
|
|
101
101
|
class={cn(
|
|
102
|
+
// A text prefix ("…lightning.com/"): the input starts right after it, with no left padding.
|
|
103
|
+
"[&>[data-align=inline-start]:has(>[data-slot=input-group-text])+[data-slot=input-group-control]]:pl-0!",
|
|
102
104
|
"group/input-group border-input bg-card has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 data-[size=sm]:h-8 data-[size=default]:h-9 data-[size=lg]:h-10 rounded-lg border shadow-xs transition-[color,box-shadow] in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto! has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto! has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto!",
|
|
103
105
|
className,
|
|
104
106
|
)}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `LoadingOverlay` component (and its controller in `@cl-light-ds/ui/lib/loading-overlay`) are documented in this file.
|
|
4
|
+
|
|
5
|
+
## Date 15th Oct 2026
|
|
6
|
+
## Workspace selection handover (N14)
|
|
7
|
+
|
|
8
|
+
New component, replacing the workspace-selection screen's hand-composed `OpeningOverlay.astro` and its `runOverlay` / `revealWorkspace` / `hideOverlay` code.
|
|
9
|
+
|
|
10
|
+
### `LoadingOverlay`
|
|
11
|
+
|
|
12
|
+
- The whole screen dimmed and blurred (`bg-foreground/60`, `backdrop-blur-sm`), a 48px `Spinner`, a 30px title (`font-heading text-3xl font-semibold`) and a status line (`text-base`, `background/80`) (D117, D120).
|
|
13
|
+
- Props: `title`, `lines` (status lines), `interval` (ms between lines, default 900, D135), `loop` (cycle instead of stopping on the last line), `open` (render shown).
|
|
14
|
+
- `behind` slot: content shown under the dimming, faded in with `setLoadingOverlayBehind(true)` or `showLoadingOverlay({ behind: true | ms })` (D135, D191). Generalises the screen's workspace iframe.
|
|
15
|
+
- Reveal (D143): `revealLoadingOverlay()` fades the dimming (700ms) and the spinner and text (500ms) away, then hides; returns a Promise.
|
|
16
|
+
- A11y: `aria-busy="true"` while running; the title and status line are a polite live region (`role="status"`); the spinner is hidden from assistive tech (the text says it). On show, focus moves to the overlay (off whatever it covers) and returns on hide.
|
|
17
|
+
- `prefers-reduced-motion`: no fades on reveal or behind.
|
|
18
|
+
|
|
19
|
+
### Controller (`@cl-light-ds/ui/lib/loading-overlay`)
|
|
20
|
+
|
|
21
|
+
- `showLoadingOverlay`, `setLoadingOverlayBehind`, `revealLoadingOverlay`, `hideLoadingOverlay`, and the matching `loading-overlay:*` events on the element; fires `loading-overlay:change` (`{ open }`).
|
|
22
|
+
|
|
23
|
+
### Storybook
|
|
24
|
+
|
|
25
|
+
- "Loading Overlay" section: run it with changing lines and a reveal, with content behind, and pinned open in a frame.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* @component LoadingOverlay
|
|
4
|
+
* @title Loading Overlay
|
|
5
|
+
* @description The global loading pattern: the whole screen dimmed and blurred, a 48px spinner, a 30px title and status lines that change as the work goes on. It can show what comes next behind the dimming, then fade away to reveal it.
|
|
6
|
+
*
|
|
7
|
+
* @preview
|
|
8
|
+
*
|
|
9
|
+
* <LoadingOverlay
|
|
10
|
+
* id="demo-overlay"
|
|
11
|
+
* title="Opening workspace…"
|
|
12
|
+
* lines={["Loading your recordings…", "Fetching guides and assets…", "Getting your workspace ready…"]}
|
|
13
|
+
* />
|
|
14
|
+
* <Button data-demo-overlay>Show for 4 seconds</Button>
|
|
15
|
+
*
|
|
16
|
+
* @usage
|
|
17
|
+
*
|
|
18
|
+
* ```astro nocollapse
|
|
19
|
+
* ---
|
|
20
|
+
* import { LoadingOverlay } from '@cl-light-ds/ui/components/loading-overlay';
|
|
21
|
+
* ---
|
|
22
|
+
*
|
|
23
|
+
* <LoadingOverlay title="Creating workspace…" lines={["Creating scaffolding…", "Setting up roles and permissions…"]} />
|
|
24
|
+
*
|
|
25
|
+
* <script>
|
|
26
|
+
* import { showLoadingOverlay, revealLoadingOverlay } from '@cl-light-ds/ui/lib/loading-overlay';
|
|
27
|
+
*
|
|
28
|
+
* showLoadingOverlay();
|
|
29
|
+
* await createWorkspace();
|
|
30
|
+
* await revealLoadingOverlay();
|
|
31
|
+
* </script>
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @api
|
|
35
|
+
*
|
|
36
|
+
* ### JS (`@cl-light-ds/ui/lib/loading-overlay`)
|
|
37
|
+
*
|
|
38
|
+
* | Function | Description |
|
|
39
|
+
* |----------|-------------|
|
|
40
|
+
* | `showLoadingOverlay({ title, lines, interval, loop, behind, overlay })` | Show it (or restart with new text) |
|
|
41
|
+
* | `setLoadingOverlayBehind(show)` | Fade the `behind` slot in or out under the dimming |
|
|
42
|
+
* | `revealLoadingOverlay({ duration })` | Fade the dimming and text away, then hide; returns a Promise |
|
|
43
|
+
* | `hideLoadingOverlay()` | Hide at once |
|
|
44
|
+
*
|
|
45
|
+
* ### Events (on the overlay)
|
|
46
|
+
*
|
|
47
|
+
* Inbound: `loading-overlay:show` (detail: show options), `loading-overlay:hide`, `loading-overlay:reveal` (`{ duration }`), `loading-overlay:behind` (`{ show }`).
|
|
48
|
+
* Outbound: `loading-overlay:change` (`{ open }`).
|
|
49
|
+
*
|
|
50
|
+
* ### Data attributes
|
|
51
|
+
*
|
|
52
|
+
* `data-reveal` (`on` | `off`), `data-behind` (`on` | `off`), `aria-busy` while running.
|
|
53
|
+
*/
|
|
54
|
+
import { cn } from "@cl-light-ds/ui/lib/utils";
|
|
55
|
+
import type { HTMLAttributes } from "astro/types";
|
|
56
|
+
|
|
57
|
+
import Spinner from "../spinner/Spinner.astro";
|
|
58
|
+
|
|
59
|
+
interface Props extends HTMLAttributes<"div"> {
|
|
60
|
+
/** The big title, e.g. "Opening workspace…". */
|
|
61
|
+
title?: string;
|
|
62
|
+
/** Status lines shown in turn under the title. */
|
|
63
|
+
lines?: string[];
|
|
64
|
+
/** ms between status lines (D135: 0.9s). */
|
|
65
|
+
interval?: number;
|
|
66
|
+
/** Start again after the last line instead of staying on it. */
|
|
67
|
+
loop?: boolean;
|
|
68
|
+
/** Render it shown (static screens, docs). Otherwise hidden until shown from JS. */
|
|
69
|
+
open?: boolean;
|
|
70
|
+
class?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const {
|
|
74
|
+
title = "Loading…",
|
|
75
|
+
lines = [],
|
|
76
|
+
interval = 900,
|
|
77
|
+
loop = false,
|
|
78
|
+
open = false,
|
|
79
|
+
class: className = "",
|
|
80
|
+
...rest
|
|
81
|
+
} = Astro.props as Props;
|
|
82
|
+
|
|
83
|
+
const hasBehind = Astro.slots.has("behind");
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
<div
|
|
87
|
+
data-slot="loading-overlay"
|
|
88
|
+
data-lines={JSON.stringify(lines)}
|
|
89
|
+
data-interval={interval}
|
|
90
|
+
data-loop={loop ? "true" : "false"}
|
|
91
|
+
data-reveal="off"
|
|
92
|
+
data-behind="off"
|
|
93
|
+
aria-busy={open ? "true" : undefined}
|
|
94
|
+
tabindex="-1"
|
|
95
|
+
hidden={!open}
|
|
96
|
+
class={cn(
|
|
97
|
+
"group/overlay text-background fixed inset-0 z-50 flex flex-col items-center justify-center gap-5 outline-none",
|
|
98
|
+
className,
|
|
99
|
+
)}
|
|
100
|
+
{...rest}
|
|
101
|
+
>
|
|
102
|
+
{
|
|
103
|
+
/* D135, D191 — what comes next, behind the dimming; fades in on `data-behind="on"`. Not interactive. */
|
|
104
|
+
hasBehind && (
|
|
105
|
+
<div
|
|
106
|
+
data-slot="loading-overlay-behind"
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
inert
|
|
109
|
+
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-700 group-data-[behind=on]/overlay:opacity-100 motion-reduce:transition-none"
|
|
110
|
+
>
|
|
111
|
+
<slot name="behind" />
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
{/* D117 — the dimming; D143 — fades away on reveal. */}
|
|
116
|
+
<div
|
|
117
|
+
data-slot="loading-overlay-backdrop"
|
|
118
|
+
aria-hidden="true"
|
|
119
|
+
class="bg-foreground/60 absolute inset-0 backdrop-blur-sm transition-opacity duration-700 group-data-[reveal=on]/overlay:opacity-0 motion-reduce:transition-none"
|
|
120
|
+
>
|
|
121
|
+
</div>
|
|
122
|
+
<Spinner
|
|
123
|
+
aria-hidden="true"
|
|
124
|
+
role="presentation"
|
|
125
|
+
class="relative size-12 transition-opacity duration-500 group-data-[reveal=on]/overlay:opacity-0 motion-reduce:transition-none"
|
|
126
|
+
/>
|
|
127
|
+
<div
|
|
128
|
+
role="status"
|
|
129
|
+
aria-live="polite"
|
|
130
|
+
class="relative flex flex-col items-center gap-1 px-6 text-center transition-opacity duration-500 group-data-[reveal=on]/overlay:opacity-0 motion-reduce:transition-none"
|
|
131
|
+
>
|
|
132
|
+
<p data-slot="loading-overlay-title" class="font-heading text-3xl font-semibold tracking-tight">
|
|
133
|
+
{title}
|
|
134
|
+
</p>
|
|
135
|
+
<p
|
|
136
|
+
data-slot="loading-overlay-status"
|
|
137
|
+
class="text-background/80 text-base"
|
|
138
|
+
hidden={lines.length === 0}
|
|
139
|
+
>
|
|
140
|
+
{lines[0]}
|
|
141
|
+
</p>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<script>
|
|
146
|
+
import {
|
|
147
|
+
hideLoadingOverlay,
|
|
148
|
+
revealLoadingOverlay,
|
|
149
|
+
setLoadingOverlayBehind,
|
|
150
|
+
showLoadingOverlay,
|
|
151
|
+
type LoadingOverlayShowOptions,
|
|
152
|
+
} from "@cl-light-ds/ui/lib/loading-overlay";
|
|
153
|
+
|
|
154
|
+
document.querySelectorAll<HTMLElement>('[data-slot="loading-overlay"]').forEach((root) => {
|
|
155
|
+
if (root.dataset.wired === "true") return;
|
|
156
|
+
root.dataset.wired = "true";
|
|
157
|
+
root.addEventListener("loading-overlay:show", (event) =>
|
|
158
|
+
showLoadingOverlay({
|
|
159
|
+
...((event as CustomEvent<LoadingOverlayShowOptions>).detail ?? {}),
|
|
160
|
+
overlay: root,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
root.addEventListener("loading-overlay:hide", () => hideLoadingOverlay(root));
|
|
164
|
+
root.addEventListener("loading-overlay:reveal", (event) =>
|
|
165
|
+
revealLoadingOverlay({
|
|
166
|
+
...((event as CustomEvent<{ duration?: number }>).detail ?? {}),
|
|
167
|
+
overlay: root,
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
root.addEventListener("loading-overlay:behind", (event) =>
|
|
171
|
+
setLoadingOverlayBehind(
|
|
172
|
+
(event as CustomEvent<{ show?: boolean }>).detail?.show ?? true,
|
|
173
|
+
root,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
// Rendered open: run its status lines.
|
|
177
|
+
if (!root.hidden) showLoadingOverlay({ overlay: root });
|
|
178
|
+
});
|
|
179
|
+
</script>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as LoadingOverlay } from "./LoadingOverlay.astro";
|
|
2
|
+
export {
|
|
3
|
+
showLoadingOverlay,
|
|
4
|
+
hideLoadingOverlay,
|
|
5
|
+
revealLoadingOverlay,
|
|
6
|
+
setLoadingOverlayBehind,
|
|
7
|
+
} from "../../lib/loading-overlay";
|
|
8
|
+
export type { LoadingOverlayShowOptions } from "../../lib/loading-overlay";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `NotificationsMenu` component family (`NotificationsMenu`, `NotificationsMenuItem`) are documented in this file.
|
|
4
|
+
|
|
5
|
+
## Date 15th Oct 2026
|
|
6
|
+
## Handover gaps: triggers don't submit forms (C29, gap G18)
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **The bell, Clear all and each ✕ have `type="button"`.** The bell is a `PopoverTrigger` (fixed there); Clear all and ✕ are `Button`s, which have no type, so inside a `<form>` they submitted it.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## Workspace selection handover (N10)
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `NotificationsMenu`: a ghost `icon-sm` bell (`PopoverTrigger`) with a red `destructive` dot while any notification is unseen, opening a 360px (`w-90`) `PopoverContent` (`align="end"`, `sideOffset` 6): a header with the title and a link-style **Clear all**, a separator, a scrolling list (max 320px), and an empty state.
|
|
18
|
+
- `NotificationsMenuItem`: avatar (DS `Avatar size="sm"`, initials or image) or a 24px icon chip (`tone`: `default` / `success` / `warning` / `destructive`), title, text, a "context · time" line (`<time>` when `datetime` is given), a blue `primary` "new" dot centred on the right (D154, no tint, D162) with sr-only "(new)", and a ✕ (`Button` ghost `icon-xs`, C25) shown on hover / focus-within (always on coarse pointers). Default slot for extra content (e.g. a Download button); `media` slot to replace the avatar / icon.
|
|
19
|
+
- Items come from the `notifications` prop (JSON-serialisable data) and/or slotted `NotificationsMenuItem`s.
|
|
20
|
+
- Seen logic (D141, D165): an item counts as seen once `seenThreshold` (60%) of it has been in view in the open list, or at once when hovered or keyboard-focused. Hovered / focused items lose their dot immediately; scrolled-to ones when the popover closes. The bell's dot and accessible name ("Notifications — {count} new") stay in sync.
|
|
21
|
+
- Empty state (D203): the designer's `all-caught-up.svg` (144×104) with "You're all caught up". Copy, illustration (`emptyIllustration`, or `false`) and the whole block (`empty` slot) are overridable.
|
|
22
|
+
- Events on the root: `notifications:dismiss` `{ id }` and `notifications:clear` `{ ids }` (both cancelable), `notifications:seen` `{ ids }`, `notifications:change` `{ total, unseen }`. Inbound `notifications:add` `{ item, position }`; any other DOM change to the list is picked up by a `MutationObserver`.
|
|
23
|
+
- After a dismiss, focus moves to the next (or previous) item's ✕, else to the popover.
|
|
24
|
+
|
|
25
|
+
### Generalised from the reference (cl-design `NotificationsPopover.astro`)
|
|
26
|
+
|
|
27
|
+
- "workspace · time" → `context` + `time`; all copy (title, labels, empty state) is props, with the reference's English as defaults except the empty description, which is now generic.
|
|
28
|
+
- Screen-specific export/duplicate templates are not part of the component: render a `NotificationsMenuItem` (e.g. in a `<template>`) and add it with `notifications:add`.
|
|
29
|
+
- The bell is the trigger itself (`PopoverTrigger` renders the `Button`), so `aria-expanded` / `aria-controls` land on it, not on a wrapper.
|
|
@@ -0,0 +1,375 @@
|
|
|
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
|
+
type="button"
|
|
169
|
+
variant="link"
|
|
170
|
+
size="sm"
|
|
171
|
+
class="group-data-[empty=true]/notifications:hidden"
|
|
172
|
+
data-notifications-clear
|
|
173
|
+
>
|
|
174
|
+
{clearLabel}
|
|
175
|
+
</Button>
|
|
176
|
+
</PopoverHeader>
|
|
177
|
+
|
|
178
|
+
<Separator />
|
|
179
|
+
|
|
180
|
+
<ul
|
|
181
|
+
data-slot="notifications-menu-list"
|
|
182
|
+
aria-label={title}
|
|
183
|
+
class="flex max-h-80 flex-col overflow-y-auto group-data-[empty=true]/notifications:hidden"
|
|
184
|
+
>
|
|
185
|
+
{
|
|
186
|
+
notifications.map((n) => (
|
|
187
|
+
<NotificationsMenuItem {...n} dismissLabel={dismissLabel} newLabel={newLabel} />
|
|
188
|
+
))
|
|
189
|
+
}
|
|
190
|
+
<slot />
|
|
191
|
+
</ul>
|
|
192
|
+
|
|
193
|
+
<div
|
|
194
|
+
data-slot="notifications-menu-empty"
|
|
195
|
+
class="hidden flex-col items-center gap-2 px-6 py-10 text-center group-data-[empty=true]/notifications:flex"
|
|
196
|
+
>
|
|
197
|
+
{
|
|
198
|
+
hasEmptySlot ? (
|
|
199
|
+
<slot name="empty" />
|
|
200
|
+
) : (
|
|
201
|
+
<>
|
|
202
|
+
{emptyIllustration && (
|
|
203
|
+
<img src={emptyIllustration} alt="" width="144" height="104" class="-my-2" />
|
|
204
|
+
)}
|
|
205
|
+
<p class="text-sm font-medium">{emptyTitle}</p>
|
|
206
|
+
{emptyDescription && <p class="text-muted-foreground text-xs">{emptyDescription}</p>}
|
|
207
|
+
</>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
</div>
|
|
211
|
+
</PopoverContent>
|
|
212
|
+
</Popover>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<script>
|
|
216
|
+
const wired = new WeakSet<Element>();
|
|
217
|
+
|
|
218
|
+
function wire(root: HTMLElement) {
|
|
219
|
+
if (wired.has(root)) return;
|
|
220
|
+
wired.add(root);
|
|
221
|
+
|
|
222
|
+
// Hold references now: the popover portals its content to <body> while open.
|
|
223
|
+
const popover = root.querySelector<HTMLElement>('[data-slot="popover"]');
|
|
224
|
+
const bell = root.querySelector<HTMLElement>("[data-notifications-bell]");
|
|
225
|
+
const content = root.querySelector<HTMLElement>("[data-notifications-content]");
|
|
226
|
+
const list = root.querySelector<HTMLElement>('[data-slot="notifications-menu-list"]');
|
|
227
|
+
const clear = root.querySelector<HTMLElement>("[data-notifications-clear]");
|
|
228
|
+
if (!popover || !bell || !content || !list) return;
|
|
229
|
+
|
|
230
|
+
const label = root.dataset.label ?? "Notifications";
|
|
231
|
+
const unseenLabel = root.dataset.unseenLabel ?? "{count}";
|
|
232
|
+
const threshold = Number(root.dataset.seenThreshold ?? 0.6);
|
|
233
|
+
let open = false;
|
|
234
|
+
let last = "";
|
|
235
|
+
|
|
236
|
+
const items = () =>
|
|
237
|
+
Array.from(list.querySelectorAll<HTMLElement>('[data-slot="notifications-menu-item"]'));
|
|
238
|
+
const emit = (name: string, detail: unknown, cancelable = false) =>
|
|
239
|
+
root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, cancelable }));
|
|
240
|
+
|
|
241
|
+
const update = () => {
|
|
242
|
+
const all = items();
|
|
243
|
+
const unseen = all.filter(
|
|
244
|
+
(n) => n.dataset.new === "true" && !n.hasAttribute("data-seen"),
|
|
245
|
+
).length;
|
|
246
|
+
bell.dataset.unseen = String(unseen > 0);
|
|
247
|
+
bell.setAttribute(
|
|
248
|
+
"aria-label",
|
|
249
|
+
unseen ? unseenLabel.replace("{count}", String(unseen)) : label,
|
|
250
|
+
);
|
|
251
|
+
content.dataset.empty = String(all.length === 0);
|
|
252
|
+
const key = `${all.length}:${unseen}`;
|
|
253
|
+
if (key !== last) {
|
|
254
|
+
last = key;
|
|
255
|
+
emit("notifications:change", { total: all.length, unseen });
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** Mark as seen. `now` also drops the blue dot (hover / focus); otherwise it goes on close. */
|
|
260
|
+
const see = (notes: HTMLElement[], now: boolean) => {
|
|
261
|
+
const fresh = notes.filter(
|
|
262
|
+
(n) => n.dataset.new === "true" && !n.hasAttribute("data-seen"),
|
|
263
|
+
);
|
|
264
|
+
for (const n of notes) {
|
|
265
|
+
if (n.dataset.new !== "true") continue;
|
|
266
|
+
if (now) {
|
|
267
|
+
n.dataset.new = "false";
|
|
268
|
+
n.removeAttribute("data-seen");
|
|
269
|
+
} else n.setAttribute("data-seen", "");
|
|
270
|
+
}
|
|
271
|
+
if (fresh.length) emit("notifications:seen", { ids: fresh.map((n) => n.dataset.notification) });
|
|
272
|
+
update();
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const observer = new IntersectionObserver(
|
|
276
|
+
(entries) => {
|
|
277
|
+
if (!open) return;
|
|
278
|
+
see(
|
|
279
|
+
entries.filter((e) => e.isIntersecting).map((e) => e.target as HTMLElement),
|
|
280
|
+
false,
|
|
281
|
+
);
|
|
282
|
+
},
|
|
283
|
+
{ root: list, threshold },
|
|
284
|
+
);
|
|
285
|
+
const watch = () => {
|
|
286
|
+
observer.disconnect();
|
|
287
|
+
for (const n of items()) observer.observe(n);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
popover.addEventListener("popover:change", (event) => {
|
|
291
|
+
open = (event as CustomEvent<{ open: boolean }>).detail.open;
|
|
292
|
+
if (open) {
|
|
293
|
+
// Let it lay out before anything counts as in view.
|
|
294
|
+
requestAnimationFrame(watch);
|
|
295
|
+
} else {
|
|
296
|
+
observer.disconnect();
|
|
297
|
+
for (const n of items()) {
|
|
298
|
+
if (n.hasAttribute("data-seen")) {
|
|
299
|
+
n.dataset.new = "false";
|
|
300
|
+
n.removeAttribute("data-seen");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
update();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Hovering or keyboard-focusing a notification is seeing it (D165).
|
|
308
|
+
const noteOf = (event: Event) =>
|
|
309
|
+
(event.target as HTMLElement).closest<HTMLElement>('[data-slot="notifications-menu-item"]');
|
|
310
|
+
list.addEventListener("mouseover", (event) => {
|
|
311
|
+
const note = noteOf(event);
|
|
312
|
+
if (note) see([note], true);
|
|
313
|
+
});
|
|
314
|
+
list.addEventListener("focusin", (event) => {
|
|
315
|
+
// Only keyboard focus: the popover may focus the first item on open.
|
|
316
|
+
const note = noteOf(event);
|
|
317
|
+
if (note && (event.target as HTMLElement).matches(":focus-visible")) see([note], true);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const focusAfterRemoval = (next: HTMLElement | null) => {
|
|
321
|
+
const target = next?.querySelector<HTMLElement>("[data-notifications-dismiss]");
|
|
322
|
+
if (target) target.focus();
|
|
323
|
+
else {
|
|
324
|
+
if (!content.hasAttribute("tabindex")) content.tabIndex = -1;
|
|
325
|
+
content.focus();
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
list.addEventListener("click", (event) => {
|
|
330
|
+
const dismiss = (event.target as HTMLElement).closest("[data-notifications-dismiss]");
|
|
331
|
+
const note = dismiss?.closest<HTMLElement>('[data-slot="notifications-menu-item"]');
|
|
332
|
+
if (!note) return;
|
|
333
|
+
if (!emit("notifications:dismiss", { id: note.dataset.notification }, true)) return;
|
|
334
|
+
const next =
|
|
335
|
+
(note.nextElementSibling as HTMLElement | null) ??
|
|
336
|
+
(note.previousElementSibling as HTMLElement | null);
|
|
337
|
+
note.remove();
|
|
338
|
+
update();
|
|
339
|
+
focusAfterRemoval(next?.matches('[data-slot="notifications-menu-item"]') ? next : null);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
clear?.addEventListener("click", () => {
|
|
343
|
+
const all = items();
|
|
344
|
+
if (!emit("notifications:clear", { ids: all.map((n) => n.dataset.notification) }, true)) return;
|
|
345
|
+
for (const n of all) n.remove();
|
|
346
|
+
update();
|
|
347
|
+
focusAfterRemoval(null);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
root.addEventListener("notifications:add", (event) => {
|
|
351
|
+
const { item, position = "start" } =
|
|
352
|
+
(event as CustomEvent<{ item?: HTMLElement; position?: "start" | "end" }>).detail ?? {};
|
|
353
|
+
if (!item) return;
|
|
354
|
+
if (position === "end") list.append(item);
|
|
355
|
+
else list.prepend(item);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Items added / removed / re-flagged by app code stay in sync.
|
|
359
|
+
new MutationObserver((records) => {
|
|
360
|
+
if (open) {
|
|
361
|
+
for (const r of records)
|
|
362
|
+
for (const node of r.addedNodes)
|
|
363
|
+
if (node instanceof HTMLElement && node.matches('[data-slot="notifications-menu-item"]'))
|
|
364
|
+
observer.observe(node);
|
|
365
|
+
}
|
|
366
|
+
update();
|
|
367
|
+
}).observe(list, { childList: true, subtree: true, attributeFilter: ["data-new"] });
|
|
368
|
+
|
|
369
|
+
update();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
document
|
|
373
|
+
.querySelectorAll<HTMLElement>('[data-slot="notifications-menu"]')
|
|
374
|
+
.forEach(wire);
|
|
375
|
+
</script>
|