@cl-light-ds/ui 0.5.0 → 0.6.1
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 +1 -1
- package/src/components/accordion/AccordionTrigger.astro +5 -1
- package/src/components/accordion/CHANGELOG.md +9 -0
- package/src/components/alert/Alert.astro +35 -2
- package/src/components/alert/CHANGELOG.md +10 -0
- package/src/components/collapsible/CHANGELOG.md +9 -0
- package/src/components/collapsible/CollapsibleTrigger.astro +5 -1
- package/src/components/combobox/CHANGELOG.md +13 -0
- package/src/components/combobox/Combobox.astro +18 -1
- package/src/components/combobox/ComboboxTrigger.astro +1 -1
- package/src/components/command-search/CHANGELOG.md +36 -0
- package/src/components/command-search/CommandSearch.astro +84 -5
- package/src/components/command-search/CommandSearchGroup.astro +14 -3
- package/src/components/dialog/CHANGELOG.md +37 -0
- package/src/components/dialog/Dialog.astro +14 -3
- package/src/components/dialog/DialogClose.astro +4 -2
- package/src/components/dialog/DialogContent.astro +19 -1
- package/src/components/dialog/DialogTrigger.astro +5 -1
- package/src/components/dropdown-menu/CHANGELOG.md +25 -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/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/notifications-menu/CHANGELOG.md +7 -0
- package/src/components/notifications-menu/NotificationsMenu.astro +1 -0
- package/src/components/notifications-menu/NotificationsMenuItem.astro +1 -0
- package/src/components/option-card/CHANGELOG.md +10 -0
- package/src/components/option-card/OptionCard.astro +9 -1
- package/src/components/organisms/top-bar/CHANGELOG.md +12 -0
- package/src/components/organisms/top-bar/TopBar.astro +17 -13
- package/src/components/organisms/workspace/CHANGELOG.md +7 -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/select/CHANGELOG.md +8 -0
- package/src/components/select/SelectTrigger.astro +1 -1
- package/src/components/table/CHANGELOG.md +6 -0
- package/src/components/table/TableEditableCell.astro +2 -2
- package/src/components/table/TablePagination.astro +4 -4
- 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/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 +5 -0
- package/src/lib/dialog-focus.ts +73 -0
- package/src/lib/dropdown-menu-radio-groups.ts +79 -0
- package/src/lib/scroll-lock.ts +97 -0
- package/src/lib/toggle-shared.ts +6 -1
- package/src/styles/CHANGELOG.md +13 -0
- package/src/styles/globals.css +22 -0
package/package.json
CHANGED
|
@@ -10,7 +10,11 @@ type IndicatorIcon = "plus-minus" | "chevron";
|
|
|
10
10
|
|
|
11
11
|
interface Props extends HTMLAttributes<"button"> {
|
|
12
12
|
class?: string;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Radix-like: render wrapper so the child can be the interactive element.
|
|
15
|
+
* The wrapper is not a button, so the child sets its own type: give a
|
|
16
|
+
* `<button>`/`<Button>` child `type="button"` inside a `<form>` or it submits it.
|
|
17
|
+
*/
|
|
14
18
|
asChild?: boolean;
|
|
15
19
|
/** Leading icon rendered before the label (e.g. a lucide-astro icon). */
|
|
16
20
|
icon?: AstroComponent;
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `Accordion` component family (`Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`) are documented in this file.
|
|
4
4
|
|
|
5
|
+
## Date 15th Oct 2026
|
|
6
|
+
## Handover gaps: triggers don't submit forms (C29, gap G18)
|
|
7
|
+
|
|
8
|
+
### Docs
|
|
9
|
+
|
|
10
|
+
- `AccordionTrigger` already renders `type="button"` unless you pass a `type`; no change.
|
|
11
|
+
- **`asChild`:** the wrapper is a `<div>`, not a button, so it can't set a type. Inside a `<form>`, give your child `type="button"` (now in the prop's JSDoc).
|
|
12
|
+
|
|
13
|
+
|
|
5
14
|
## 2026-09-08 — Figma alignment (ClickLearn Lightning Design Library, node `133-7547`)
|
|
6
15
|
|
|
7
16
|
Aligned the component visually and functionally with Figma, while preserving the existing 4-part structure and all pre-existing props.
|
|
@@ -30,6 +30,20 @@
|
|
|
30
30
|
* </Alert>
|
|
31
31
|
* </div>
|
|
32
32
|
*
|
|
33
|
+
* ### Destructive as a tint
|
|
34
|
+
*
|
|
35
|
+
* `appearance="tint"` on the destructive variant draws it as a plain light-red
|
|
36
|
+
* note: `destructive` at 10% behind it, no border, the title in `destructive`
|
|
37
|
+
* and the description in `destructive` at 80% (D65). For a warning inside a
|
|
38
|
+
* dialog (delete), where the white card with a red icon reads too loud. Only
|
|
39
|
+
* the destructive variant has a tint (`warning` is always one); on the others
|
|
40
|
+
* `appearance` does nothing.
|
|
41
|
+
*
|
|
42
|
+
* <Alert variant="destructive" appearance="tint" size="large" dismissible={false}>
|
|
43
|
+
* <AlertTitle>This can't be undone</AlertTitle>
|
|
44
|
+
* <AlertDescription>Deleted workspaces and their recordings can't be restored.</AlertDescription>
|
|
45
|
+
* </Alert>
|
|
46
|
+
*
|
|
33
47
|
* @usage
|
|
34
48
|
*
|
|
35
49
|
* ```astro nocollapse
|
|
@@ -51,10 +65,13 @@ import type { HTMLAttributes } from "astro/types";
|
|
|
51
65
|
|
|
52
66
|
type AlertVariant = "default" | "destructive" | "success" | "warning";
|
|
53
67
|
type AlertSize = "default" | "large";
|
|
68
|
+
type AlertAppearance = "default" | "tint";
|
|
54
69
|
|
|
55
70
|
interface Props extends HTMLAttributes<"div"> {
|
|
56
71
|
variant?: AlertVariant;
|
|
57
72
|
size?: AlertSize;
|
|
73
|
+
/** `"tint"`: the destructive variant as a light-red note, no border (C18, D65). */
|
|
74
|
+
appearance?: AlertAppearance;
|
|
58
75
|
icon?: AstroComponent;
|
|
59
76
|
dismissible?: boolean;
|
|
60
77
|
class?: string;
|
|
@@ -64,6 +81,7 @@ const {
|
|
|
64
81
|
class: className = "",
|
|
65
82
|
variant = "default",
|
|
66
83
|
size = "default",
|
|
84
|
+
appearance = "default",
|
|
67
85
|
icon,
|
|
68
86
|
dismissible = true,
|
|
69
87
|
...rest
|
|
@@ -87,10 +105,24 @@ const alertVariants = cva(
|
|
|
87
105
|
default: "items-center gap-2 p-2",
|
|
88
106
|
large: "items-start gap-4 p-4",
|
|
89
107
|
},
|
|
108
|
+
appearance: {
|
|
109
|
+
default: "",
|
|
110
|
+
tint: "",
|
|
111
|
+
},
|
|
90
112
|
},
|
|
113
|
+
compoundVariants: [
|
|
114
|
+
// C18, D65 — a plain light-red note: no border, red title, red text at 80%.
|
|
115
|
+
{
|
|
116
|
+
variant: "destructive",
|
|
117
|
+
appearance: "tint",
|
|
118
|
+
class:
|
|
119
|
+
"bg-destructive/10 border-transparent [&_[data-slot=alert-title]]:text-destructive [&_[data-slot=alert-description]]:text-destructive/80",
|
|
120
|
+
},
|
|
121
|
+
],
|
|
91
122
|
defaultVariants: {
|
|
92
123
|
variant: "default",
|
|
93
124
|
size: "default",
|
|
125
|
+
appearance: "default",
|
|
94
126
|
},
|
|
95
127
|
},
|
|
96
128
|
);
|
|
@@ -130,8 +162,9 @@ const contentVariants = cva("flex flex-1 text-sm", {
|
|
|
130
162
|
data-slot="alert"
|
|
131
163
|
data-variant={variant}
|
|
132
164
|
data-size={size}
|
|
165
|
+
data-appearance={appearance}
|
|
133
166
|
role="alert"
|
|
134
|
-
class={cn(alertVariants({ variant, size }), className)}
|
|
167
|
+
class={cn(alertVariants({ variant, size, appearance }), className)}
|
|
135
168
|
>
|
|
136
169
|
{
|
|
137
170
|
hasIcon && (
|
|
@@ -163,7 +196,7 @@ const contentVariants = cva("flex flex-1 text-sm", {
|
|
|
163
196
|
<button
|
|
164
197
|
type="button"
|
|
165
198
|
data-slot="alert-dismiss"
|
|
166
|
-
class="flex shrink-0 items-center justify-center gap-0.5 rounded-lg bg-background p-2 group-data-[variant=warning]/alert:bg-transparent [&>svg]:size-4"
|
|
199
|
+
class="flex shrink-0 items-center justify-center gap-0.5 rounded-lg bg-background p-2 group-data-[variant=warning]/alert:bg-transparent group-data-[variant=destructive]/alert:group-data-[appearance=tint]/alert:bg-transparent [&>svg]:size-4"
|
|
167
200
|
>
|
|
168
201
|
<XIcon />
|
|
169
202
|
<span class="sr-only">Dismiss</span>
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `Alert` component will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## Date 15th Oct 2026
|
|
6
|
+
## Workspace selection handover (C18)
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `appearance` prop (`"default" | "tint"`, default `"default"`), sets `data-appearance`. `variant="destructive" appearance="tint"` draws the destructive alert as a plain light-red note (D65): background `destructive` at 10%, no border, `AlertTitle` in `destructive` and `AlertDescription` in `destructive` at 80%; the dismiss button is transparent, as on `warning`. Replaces the workspace-selection delete dialog's `class="bg-destructive/10 border-transparent"` and its title/description colour classes.
|
|
11
|
+
- Uses `destructive` at 10% rather than `callout-error` (Red/50): D65 specifies the destructive red at 10%, which is a little stronger than Red/50, and the title and description in the destructive red (not `callout-foreground-error`), so the tint matches the reference exactly.
|
|
12
|
+
- Opt-in: the destructive alert's default look (white card, border, red icon) is unchanged. Only the destructive variant has a tint (`warning` is always tinted); on the other variants `appearance` does nothing.
|
|
13
|
+
- Storybook: a "Destructive as a tint" preview.
|
|
14
|
+
|
|
5
15
|
## Date 15th Oct 2026
|
|
6
16
|
## Workspace selection handover (N19, gap G13)
|
|
7
17
|
|
|
@@ -2,4 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `Collapsible` component will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## Date 15th Oct 2026
|
|
6
|
+
## Handover gaps: triggers don't submit forms (C29, gap G18)
|
|
7
|
+
|
|
8
|
+
### Docs
|
|
9
|
+
|
|
10
|
+
- `CollapsibleTrigger` already renders `type="button"` unless you pass a `type`; no change.
|
|
11
|
+
- **`asChild`:** the wrapper is a `<div>`, not a button, so it can't set a type. Inside a `<form>`, give your child `type="button"` (now in the prop's JSDoc).
|
|
12
|
+
|
|
13
|
+
|
|
5
14
|
## Unreleased
|
|
@@ -4,7 +4,11 @@ import type { HTMLAttributes } from "astro/types";
|
|
|
4
4
|
|
|
5
5
|
interface Props extends HTMLAttributes<"button"> {
|
|
6
6
|
class?: string;
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Radix-like: render wrapper so the child can be the interactive element.
|
|
9
|
+
* The wrapper is not a button, so the child sets its own type: give a
|
|
10
|
+
* `<button>`/`<Button>` child `type="button"` inside a `<form>` or it submits it.
|
|
11
|
+
*/
|
|
8
12
|
asChild?: boolean;
|
|
9
13
|
}
|
|
10
14
|
|
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
All notable changes to the `Combobox` component family (`Combobox`, `ComboboxInput`, `ComboboxContent`, `ComboboxList`, `ComboboxItem`, `ComboboxGroup`, `ComboboxLabel`, `ComboboxSeparator`, `ComboboxEmpty`, `ComboboxTrigger`, `ComboboxValue`) are documented in this file.
|
|
4
4
|
|
|
5
5
|
## Date 15th Oct 2026
|
|
6
|
+
## Workspace selection handover (C7, gap G17)
|
|
7
|
+
|
|
8
|
+
- In "search in popup" mode, focus goes back to the trigger when the list closes (a pick or Escape), as with a native select. It dropped to the page, and out of a surrounding dialog. A workaround for a runtime bug (bejamas/data-slot).
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## Date 15th Oct 2026
|
|
12
|
+
## Handover gaps: triggers don't submit forms (C29, gap G18)
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`ComboboxTrigger`: a `type` you pass now wins.** It always rendered `type="button"`, but after your attributes, so it overrode yours. The default is unchanged.
|
|
17
|
+
|
|
18
|
+
|
|
6
19
|
## Workspace selection handover (C6; gaps G12, G17)
|
|
7
20
|
|
|
8
21
|
### `ComboboxTrigger` (C6)
|
|
@@ -346,8 +346,25 @@ const {
|
|
|
346
346
|
<script>
|
|
347
347
|
import { createCombobox } from "@data-slot/combobox";
|
|
348
348
|
|
|
349
|
-
document.querySelectorAll('[data-slot="combobox"]').forEach((el) => {
|
|
349
|
+
document.querySelectorAll<HTMLElement>('[data-slot="combobox"]').forEach((el) => {
|
|
350
|
+
// Found before the runtime starts: it moves the content to <body> when it opens.
|
|
351
|
+
const content = el.querySelector('[data-slot="combobox-content"]');
|
|
352
|
+
const trigger = Array.from(el.querySelectorAll<HTMLElement>('[data-slot="combobox-trigger"]')).find(
|
|
353
|
+
(t) => !content?.contains(t),
|
|
354
|
+
);
|
|
355
|
+
|
|
350
356
|
createCombobox(el);
|
|
357
|
+
|
|
358
|
+
// "Search in popup": the search input lives in the list, which hides on a
|
|
359
|
+
// pick or Escape, so focus would drop to the page (and out of a dialog).
|
|
360
|
+
// Put it back on the trigger, as a native select does.
|
|
361
|
+
el.addEventListener("combobox:open-change", (event) => {
|
|
362
|
+
if ((event as CustomEvent<{ open: boolean }>).detail.open || !trigger) return;
|
|
363
|
+
requestAnimationFrame(() => {
|
|
364
|
+
const at = document.activeElement;
|
|
365
|
+
if (!at || at === document.body || content?.contains(at)) trigger.focus();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
351
368
|
});
|
|
352
369
|
|
|
353
370
|
// Clear button delegation
|
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
All notable changes to the `CommandSearch` component family (`CommandSearch`, `CommandSearchGroup`, `CommandSearchItem`, and the helpers in `@cl-light-ds/ui/lib/command-search`) are documented in this file.
|
|
4
4
|
|
|
5
5
|
## Date 15th Oct 2026
|
|
6
|
+
## Idle hint centred
|
|
7
|
+
|
|
8
|
+
- The hint shown when the palette opens ("Type a search term and press Enter, or click Search.") sat at the left: its show/hide class makes it a flex container while idle, where `text-center` has no effect. It now centres (`justify-center`), like the loading and "Nothing matches" states.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## Date 15th Oct 2026
|
|
12
|
+
## Results announcement counts every match
|
|
13
|
+
|
|
14
|
+
- With a per-group `limit`, the screen-reader announcement ("{count} results") counted only the rows shown. It now announces every match: the sum of the group counts (including a remote search's own counts), or a group's matches where it shows no count.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Date 15th Oct 2026
|
|
18
|
+
## Handover gaps: per-group limit (C30, gap G19)
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`limit`: at most N matches per group** (D67 asks for the first five). Each group shows its first `limit` matches. The count beside the heading still counts every match. Set it on `CommandSearch` for every group, or on one `CommandSearchGroup` (its own wins; `0` means no limit). In `remote` mode it applies to the rendered groups too (`limit` on a result group, or `CommandSearch`'s). There the count stays `count ?? items.length`.
|
|
23
|
+
- The rows past the limit are `hidden` (and marked `data-over-limit`), so ↑ ↓, Home/End, hover and Enter skip them like any non-match. The highlight moves to a shown row if its row is capped.
|
|
24
|
+
- **How it holds:** `@data-slot/command` sets `hidden` on every item each time it filters. It filters again whenever the list's content changes (its own MutationObserver), so a cap applied once would be undone. So the cap is reapplied after every filter pass: an observer on the items' `hidden` attribute runs it again, writing only what differs, so it settles at once. It works out matches with the same filter the runtime uses.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Rows added to the list after a search are now matched by their label when the runtime re-filters. Before, their label hadn't been read yet, so they were matched by `value`.
|
|
29
|
+
|
|
30
|
+
### Storybook
|
|
31
|
+
|
|
32
|
+
- "Limit: the first 3 matches per group" preview (`limit={3}`, `limit={5}` on Recordings); the remote demo uses `limit={3}`.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
## Handover gaps: triggers don't submit forms (C29, gap G18)
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **The trigger button has `type="button"`.** It sits in `DialogTrigger asChild`, whose wrapper can't set a type, so inside a `<form>` opening command search submitted the form.
|
|
40
|
+
|
|
41
|
+
|
|
6
42
|
## Workspace selection handover (N11)
|
|
7
43
|
|
|
8
44
|
New component, replacing the workspace-selection screen's hand-composed `CommandPalette.astro` and its palette code in `interactions.ts` (gap G8).
|
|
@@ -103,8 +103,14 @@ interface Props extends HTMLAttributes<"div"> {
|
|
|
103
103
|
loadingText?: string;
|
|
104
104
|
emptyTitle?: string;
|
|
105
105
|
emptyDescription?: string;
|
|
106
|
-
/** Announced after a search; `{count}` is
|
|
106
|
+
/** Announced after a search; `{count}` is the number of matches (all of them, not only the rows a `limit` shows). */
|
|
107
107
|
resultsAnnouncement?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Show at most this many matches in each group (D67: 5); the count beside
|
|
110
|
+
* the heading still says how many match. A group's own `limit` wins. No
|
|
111
|
+
* limit when unset or 0.
|
|
112
|
+
*/
|
|
113
|
+
limit?: number;
|
|
108
114
|
/** Classes for the trigger button. */
|
|
109
115
|
triggerClass?: string;
|
|
110
116
|
/** Classes for the dialog panel. */
|
|
@@ -128,6 +134,7 @@ const {
|
|
|
128
134
|
emptyTitle = "Nothing matches",
|
|
129
135
|
emptyDescription = "Check the spelling, or try another word.",
|
|
130
136
|
resultsAnnouncement = "{count} results",
|
|
137
|
+
limit,
|
|
131
138
|
triggerClass = "",
|
|
132
139
|
contentClass = "",
|
|
133
140
|
class: className = "",
|
|
@@ -149,6 +156,7 @@ const show = {
|
|
|
149
156
|
data-delay={delay}
|
|
150
157
|
data-shortcut={shortcut ? "true" : "false"}
|
|
151
158
|
data-results-announcement={resultsAnnouncement}
|
|
159
|
+
data-limit={limit && limit > 0 ? limit : undefined}
|
|
152
160
|
data-loading-text={loadingText}
|
|
153
161
|
data-empty-title={emptyTitle}
|
|
154
162
|
class={className}
|
|
@@ -158,6 +166,7 @@ const show = {
|
|
|
158
166
|
!hideTrigger && (
|
|
159
167
|
<DialogTrigger asChild>
|
|
160
168
|
<Button
|
|
169
|
+
type="button"
|
|
161
170
|
variant="outline"
|
|
162
171
|
size="sm"
|
|
163
172
|
class={cn("text-muted-foreground w-56 justify-start", triggerClass)}
|
|
@@ -217,7 +226,7 @@ const show = {
|
|
|
217
226
|
|
|
218
227
|
<div class="flex max-h-96 flex-col gap-3 overflow-y-auto p-2">
|
|
219
228
|
{/* D116 — before a search: a hint, no lists. */}
|
|
220
|
-
<p class={cn(show.idle, "text-muted-foreground px-6 py-8 text-center text-sm")}>
|
|
229
|
+
<p class={cn(show.idle, "text-muted-foreground justify-center px-6 py-8 text-center text-sm")}>
|
|
221
230
|
{hint}
|
|
222
231
|
</p>
|
|
223
232
|
|
|
@@ -302,6 +311,7 @@ const show = {
|
|
|
302
311
|
|
|
303
312
|
const remote = root.dataset.mode === "remote";
|
|
304
313
|
const delay = Number(root.dataset.delay) || 0;
|
|
314
|
+
const defaultLimit = Number(root.dataset.limit) || 0;
|
|
305
315
|
|
|
306
316
|
for (const key of root.querySelectorAll<HTMLElement>("[data-command-search-shortcut]"))
|
|
307
317
|
key.textContent = isMac ? "⌘K" : "Ctrl K";
|
|
@@ -328,6 +338,8 @@ const show = {
|
|
|
328
338
|
const filter = (value: string, search: string, keywords?: string[]) => {
|
|
329
339
|
const q = search.trim().toLowerCase();
|
|
330
340
|
if (!q) return 1;
|
|
341
|
+
// Rows added since the search ran (the runtime re-filters them) aren't read yet.
|
|
342
|
+
if (!labels.has(value)) readLabels();
|
|
331
343
|
const text = (labels.get(value) ?? value).toLowerCase();
|
|
332
344
|
return text.includes(q) || (keywords ?? []).some((k) => k.toLowerCase().includes(q)) ? 1 : 0;
|
|
333
345
|
};
|
|
@@ -379,23 +391,89 @@ const show = {
|
|
|
379
391
|
onSelect: choose,
|
|
380
392
|
});
|
|
381
393
|
|
|
394
|
+
// Per-group limit (D67, C30). The runtime decides what matches and sets
|
|
395
|
+
// `hidden` on every item each time it filters, which happens again
|
|
396
|
+
// whenever the list's content changes (its own MutationObserver), so a
|
|
397
|
+
// cap applied once would be undone. Instead the cap is applied after
|
|
398
|
+
// every filter pass: an observer on `hidden` re-applies it (it only
|
|
399
|
+
// writes what differs, so it settles at once). Capped items are `hidden`,
|
|
400
|
+
// so arrow keys, hover and Enter skip them like any non-match.
|
|
401
|
+
const groups = () => [...list.querySelectorAll<HTMLElement>('[data-slot="command-group"]')];
|
|
402
|
+
const groupItems = (group: HTMLElement) =>
|
|
403
|
+
[...group.querySelectorAll<HTMLElement>('[data-slot="command-item"]')];
|
|
404
|
+
const limitOf = (group: HTMLElement) => {
|
|
405
|
+
const own = group.dataset.limit;
|
|
406
|
+
return own !== undefined && own !== "" ? Number(own) || 0 : defaultLimit;
|
|
407
|
+
};
|
|
408
|
+
const keywordsOf = (item: HTMLElement) =>
|
|
409
|
+
(item.dataset.keywords ?? "").split(/[,\n]/).map((k) => k.trim()).filter(Boolean);
|
|
410
|
+
/** Whether the runtime shows this item for the current search (same rule it uses). */
|
|
411
|
+
const matches = (item: HTMLElement) => {
|
|
412
|
+
if (remote || item.dataset.forceMount === "true") return true;
|
|
413
|
+
const query = controller.search;
|
|
414
|
+
if (!query.trim()) return true;
|
|
415
|
+
return filter(item.dataset.value ?? item.dataset.label ?? "", query, keywordsOf(item)) > 0;
|
|
416
|
+
};
|
|
417
|
+
const applyLimit = () => {
|
|
418
|
+
for (const group of groups()) {
|
|
419
|
+
const limit = limitOf(group);
|
|
420
|
+
let shownCount = 0;
|
|
421
|
+
for (const item of groupItems(group)) {
|
|
422
|
+
const over = item.hasAttribute("data-over-limit");
|
|
423
|
+
if (!matches(item)) {
|
|
424
|
+
if (over) item.removeAttribute("data-over-limit"); // the runtime hides it
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
shownCount += 1;
|
|
428
|
+
if (limit > 0 && shownCount > limit) {
|
|
429
|
+
if (!over) item.setAttribute("data-over-limit", "");
|
|
430
|
+
if (!item.hidden) item.hidden = true;
|
|
431
|
+
} else if (over) {
|
|
432
|
+
item.removeAttribute("data-over-limit");
|
|
433
|
+
item.hidden = false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Keep the highlight on something you can see.
|
|
438
|
+
const active = controller.value;
|
|
439
|
+
const visible = shown();
|
|
440
|
+
if (active !== null && !visible.some((item) => item.dataset.value === active))
|
|
441
|
+
controller.select(visible.find((item) => item.getAttribute("aria-disabled") !== "true")?.dataset.value ?? null);
|
|
442
|
+
};
|
|
443
|
+
|
|
382
444
|
const updateCounts = () => {
|
|
383
|
-
for (const group of
|
|
445
|
+
for (const group of groups()) {
|
|
384
446
|
const count = group.querySelector<HTMLElement>('[data-slot="command-search-count"]');
|
|
385
447
|
if (!count || count.dataset.fixed === "true") continue;
|
|
386
|
-
|
|
448
|
+
// Every match, including those past the limit.
|
|
449
|
+
const n = groupItems(group).filter(matches).length;
|
|
387
450
|
if (count.textContent !== String(n)) count.textContent = String(n);
|
|
388
451
|
}
|
|
389
452
|
};
|
|
390
453
|
|
|
454
|
+
new MutationObserver(() => {
|
|
455
|
+
if (command.dataset.status !== "results") return;
|
|
456
|
+
applyLimit();
|
|
457
|
+
updateCounts();
|
|
458
|
+
}).observe(list, { subtree: true, attributes: true, attributeFilter: ["hidden"] });
|
|
459
|
+
|
|
391
460
|
const showResults = () => {
|
|
461
|
+
applyLimit();
|
|
392
462
|
updateCounts();
|
|
393
463
|
const visible = shown();
|
|
394
464
|
if (empty) empty.hidden = visible.length > 0;
|
|
395
465
|
controller.select(visible.find((item) => item.getAttribute("aria-disabled") !== "true")?.dataset.value ?? null);
|
|
396
466
|
setStatus("results");
|
|
467
|
+
// Announce every match, not just the rows a per-group limit leaves showing:
|
|
468
|
+
// the sum of the group counts (which include a remote search's own counts).
|
|
469
|
+
const counted = groups().reduce((sum, group) => {
|
|
470
|
+
const label = group.querySelector('[data-slot="command-search-count"]')?.textContent;
|
|
471
|
+
const n = label ? Number(label) : NaN;
|
|
472
|
+
return sum + (Number.isFinite(n) ? n : groupItems(group).filter(matches).length);
|
|
473
|
+
}, 0);
|
|
474
|
+
const total = Math.max(counted, visible.length);
|
|
397
475
|
const template = root.dataset.resultsAnnouncement ?? "{count} results";
|
|
398
|
-
announce(visible.length ? template.replace("{count}", String(
|
|
476
|
+
announce(visible.length ? template.replace("{count}", String(total)) : (root.dataset.emptyTitle ?? ""));
|
|
399
477
|
};
|
|
400
478
|
|
|
401
479
|
const clearRendered = () => {
|
|
@@ -408,6 +486,7 @@ const show = {
|
|
|
408
486
|
const wrap = el("div", commandSearchGroupClasses);
|
|
409
487
|
wrap.dataset.slot = "command-group";
|
|
410
488
|
wrap.dataset.commandSearchRendered = "";
|
|
489
|
+
if (group.limit !== undefined) wrap.dataset.limit = String(group.limit);
|
|
411
490
|
const heading = el("div", commandSearchHeadingClasses, group.heading);
|
|
412
491
|
heading.dataset.slot = "command-group-heading";
|
|
413
492
|
const count = el("span", commandSearchCountClasses, String(group.count ?? group.items.length));
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* A group of results in a `CommandSearch`: a small caps heading with the
|
|
4
4
|
* number of matches beside it, then its `CommandSearchItem`s. Hidden while
|
|
5
|
-
* none of its items match.
|
|
5
|
+
* none of its items match. With `limit`, only the first matches show; the
|
|
6
|
+
* count beside the heading still counts them all.
|
|
6
7
|
*/
|
|
7
8
|
import { cn } from "@cl-light-ds/ui/lib/utils";
|
|
8
9
|
import type { HTMLAttributes } from "astro/types";
|
|
@@ -17,13 +18,23 @@ interface Props extends HTMLAttributes<"div"> {
|
|
|
17
18
|
heading: string;
|
|
18
19
|
/** Show how many items match beside the heading (D67). */
|
|
19
20
|
showCount?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Show at most this many matches (D67: 5); the count still says how many
|
|
23
|
+
* match. Overrides `CommandSearch`'s `limit`; 0 for no limit.
|
|
24
|
+
*/
|
|
25
|
+
limit?: number;
|
|
20
26
|
class?: string;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
const { heading, showCount = true, class: className = "", ...rest } = Astro.props as Props;
|
|
29
|
+
const { heading, showCount = true, limit, class: className = "", ...rest } = Astro.props as Props;
|
|
24
30
|
---
|
|
25
31
|
|
|
26
|
-
<div
|
|
32
|
+
<div
|
|
33
|
+
data-slot="command-group"
|
|
34
|
+
data-limit={limit !== undefined ? limit : undefined}
|
|
35
|
+
class={cn(commandSearchGroupClasses, className)}
|
|
36
|
+
{...rest}
|
|
37
|
+
>
|
|
27
38
|
<div data-slot="command-group-heading" class={commandSearchHeadingClasses}>
|
|
28
39
|
{heading}
|
|
29
40
|
{showCount && <span data-slot="command-search-count" class={commandSearchCountClasses}></span>}
|
|
@@ -3,6 +3,43 @@
|
|
|
3
3
|
All notable changes to the `Dialog` component family (`Dialog`, `DialogTrigger`, `DialogContent`, `DialogOverlay`, `DialogHeader`, `DialogFooter`, `DialogTitle`, `DialogDescription`, `DialogClose`) are documented in this file.
|
|
4
4
|
|
|
5
5
|
## Date 15th Oct 2026
|
|
6
|
+
## Workspace selection handover (C7, gap G17)
|
|
7
|
+
|
|
8
|
+
### Popovers inside a dialog
|
|
9
|
+
|
|
10
|
+
- The popover runtime moves open content to `<body>`, out of the dialog, so the dialog's focus trap pulled focus back and Tab walked out of the popover. Now (`lib/dialog-focus.ts`, installed by `Dialog`) Tab from a popover's trigger goes into it, Tab and Shift+Tab go round its controls, and Escape returns to the trigger. Tied to the dialog through the trigger's `aria-controls`. A workaround for a runtime bug (bejamas/data-slot).
|
|
11
|
+
- Storybook: "Popover and combobox inside a dialog" preview.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Date 15th Oct 2026
|
|
15
|
+
## Workspace selection handover (C8, gap G5)
|
|
16
|
+
|
|
17
|
+
### Scroll lock
|
|
18
|
+
|
|
19
|
+
- `lockScroll` no longer uses the `@data-slot/dialog` runtime's lock (`overflow: hidden` + `scrollbar-gutter: stable` on `<html>`), which left an undimmed white band where the scrollbar was. `lib/scroll-lock.ts` locks instead: the scrollbar stays and nothing moves; the wheel, touch and paging keys don't scroll the page; scrollable areas under the pointer (a long list in the dialog) still scroll; a dragged scrollbar is put back. `lockScroll={false}` still turns it off. A workaround for a runtime bug (bejamas/data-slot); remove it if the runtime fixes its lock.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Date 15th Oct 2026
|
|
23
|
+
## Handover gaps: dialog widths (C24)
|
|
24
|
+
|
|
25
|
+
### `DialogContent`
|
|
26
|
+
|
|
27
|
+
- **`size` prop** (`"sm" | "md" | "lg" | "xl"`, default `"sm"`): the panel's width from the `sm` breakpoint. `sm` 448px (`sm:max-w-md`, today's default, unchanged), `md` 512px (`sm:max-w-lg`, the delete, duplicate and export confirmations, D111), `lg` 672px (`sm:max-w-2xl`, command search), `xl` 1024px (`sm:max-w-5xl`, New workspace, D89). Below `sm` the panel is still the viewport minus 2rem. Sets `data-size`. A `sm:max-w-*` class you pass still wins.
|
|
28
|
+
|
|
29
|
+
### Storybook
|
|
30
|
+
|
|
31
|
+
- "Sizes" preview with all four; the "Cancel with DialogClose asChild" preview uses `size="md"` instead of a class.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## Handover gaps: triggers don't submit forms (C29, gap G18)
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **`DialogClose` renders `type="button"`** unless you pass a `type` (including the ✕ that `DialogContent` adds). It renders a `Button`, which has no type, so a dialog whose content is a `<form>` was submitted by its own close button.
|
|
39
|
+
- `DialogTrigger` already rendered `type="button"`; no change.
|
|
40
|
+
- **`asChild` (`DialogTrigger`, `DialogClose`):** the wrapper is a `<div>`, not a button, so it can't set a type. Inside a `<form>`, give your child `type="button"` (now in the props' JSDoc).
|
|
41
|
+
|
|
42
|
+
|
|
6
43
|
## Workspace selection handover (C9, C16; gap G15)
|
|
7
44
|
|
|
8
45
|
### `DialogClose`
|
|
@@ -189,8 +189,19 @@ const {
|
|
|
189
189
|
|
|
190
190
|
<script>
|
|
191
191
|
import { createDialog } from "@data-slot/dialog";
|
|
192
|
+
import { lockScrollWhileOpen } from "@cl-light-ds/ui/lib/scroll-lock";
|
|
193
|
+
import { trapTabInPopoversInDialogs } from "@cl-light-ds/ui/lib/dialog-focus";
|
|
192
194
|
|
|
193
|
-
document
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
document.querySelectorAll<HTMLElement>('[data-slot="dialog"]').forEach((el) => {
|
|
196
|
+
// The runtime's own lock reserves a scrollbar gutter the overlay doesn't
|
|
197
|
+
// cover; lib/scroll-lock keeps the scrollbar and blocks scrolling instead.
|
|
198
|
+
const dialog = createDialog(el, { lockScroll: false });
|
|
199
|
+
if (el.dataset.lockScroll !== "false") {
|
|
200
|
+
lockScrollWhileOpen(el, "dialog:change", () => dialog.isOpen);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Tab stays inside a popover opened from a dialog (the runtime moves the
|
|
205
|
+
// popover to <body>, out of the dialog's focus trap).
|
|
206
|
+
trapTabInPopoversInDialogs();
|
|
196
207
|
</script>
|
|
@@ -8,12 +8,13 @@ interface Props extends HTMLAttributes<"button"> {
|
|
|
8
8
|
/**
|
|
9
9
|
* Radix-like: render a wrapper so your own element closes the dialog, e.g. a
|
|
10
10
|
* Cancel `<Button>`. The wrapper is `display: contents`, so the child lays out
|
|
11
|
-
* as if it were placed directly.
|
|
11
|
+
* as if it were placed directly. The wrapper is not a button: inside a
|
|
12
|
+
* `<form>`, give the child `type="button"` or it submits the form.
|
|
12
13
|
*/
|
|
13
14
|
asChild?: boolean;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
const { class: className = "", asChild = false, ...rest } = Astro.props as Props;
|
|
17
|
+
const { class: className = "", asChild = false, type = "button", ...rest } = Astro.props as Props;
|
|
17
18
|
---
|
|
18
19
|
|
|
19
20
|
{
|
|
@@ -24,6 +25,7 @@ const { class: className = "", asChild = false, ...rest } = Astro.props as Props
|
|
|
24
25
|
) : (
|
|
25
26
|
<Button
|
|
26
27
|
{...rest}
|
|
28
|
+
type={type}
|
|
27
29
|
data-slot="dialog-close"
|
|
28
30
|
data-dialog-close
|
|
29
31
|
variant="ghost"
|
|
@@ -5,12 +5,28 @@ import type { HTMLAttributes } from "astro/types";
|
|
|
5
5
|
import DialogClose from "./DialogClose.astro";
|
|
6
6
|
import DialogOverlay from "./DialogOverlay.astro";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Widths from the `sm` breakpoint (below it the panel is the viewport minus
|
|
10
|
+
* 2rem): `sm` 448px (default), `md` 512px (delete, duplicate and export
|
|
11
|
+
* confirmations, D111), `lg` 672px (command search), `xl` 1024px (New
|
|
12
|
+
* workspace, D89).
|
|
13
|
+
*/
|
|
14
|
+
const sizes = {
|
|
15
|
+
sm: "sm:max-w-md",
|
|
16
|
+
md: "sm:max-w-lg",
|
|
17
|
+
lg: "sm:max-w-2xl",
|
|
18
|
+
xl: "sm:max-w-5xl",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
8
21
|
interface Props extends HTMLAttributes<"div"> {
|
|
9
22
|
showCloseButton?: boolean;
|
|
23
|
+
/** Panel width: `sm` 448px (default), `md` 512px, `lg` 672px, `xl` 1024px. */
|
|
24
|
+
size?: keyof typeof sizes;
|
|
10
25
|
}
|
|
11
26
|
|
|
12
27
|
const {
|
|
13
28
|
showCloseButton = true,
|
|
29
|
+
size = "sm",
|
|
14
30
|
class: className = "",
|
|
15
31
|
...rest
|
|
16
32
|
} = Astro.props as Props;
|
|
@@ -21,8 +37,10 @@ const {
|
|
|
21
37
|
<div
|
|
22
38
|
data-slot="dialog-content"
|
|
23
39
|
hidden
|
|
40
|
+
data-size={size}
|
|
24
41
|
class={cn(
|
|
25
|
-
"bg-card border-border data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 max-w-[calc(100%-2rem)] rounded-lg border shadow-lg duration-100
|
|
42
|
+
"bg-card border-border data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 max-w-[calc(100%-2rem)] rounded-lg border shadow-lg duration-100 fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
|
43
|
+
sizes[size] ?? sizes.sm,
|
|
26
44
|
className,
|
|
27
45
|
)}
|
|
28
46
|
{...rest}
|
|
@@ -4,7 +4,11 @@ import type { HTMLAttributes } from "astro/types";
|
|
|
4
4
|
|
|
5
5
|
interface Props extends HTMLAttributes<"button"> {
|
|
6
6
|
class?: string;
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Radix-like: render wrapper so the child can be the interactive element.
|
|
9
|
+
* The wrapper is not a button, so the child sets its own type: give a
|
|
10
|
+
* `<button>`/`<Button>` child `type="button"` inside a `<form>` or it submits it.
|
|
11
|
+
*/
|
|
8
12
|
asChild?: boolean;
|
|
9
13
|
}
|
|
10
14
|
|