@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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/components/accordion/AccordionTrigger.astro +5 -1
  3. package/src/components/accordion/CHANGELOG.md +9 -0
  4. package/src/components/alert/Alert.astro +35 -2
  5. package/src/components/alert/CHANGELOG.md +10 -0
  6. package/src/components/collapsible/CHANGELOG.md +9 -0
  7. package/src/components/collapsible/CollapsibleTrigger.astro +5 -1
  8. package/src/components/combobox/CHANGELOG.md +13 -0
  9. package/src/components/combobox/Combobox.astro +18 -1
  10. package/src/components/combobox/ComboboxTrigger.astro +1 -1
  11. package/src/components/command-search/CHANGELOG.md +36 -0
  12. package/src/components/command-search/CommandSearch.astro +84 -5
  13. package/src/components/command-search/CommandSearchGroup.astro +14 -3
  14. package/src/components/dialog/CHANGELOG.md +37 -0
  15. package/src/components/dialog/Dialog.astro +14 -3
  16. package/src/components/dialog/DialogClose.astro +4 -2
  17. package/src/components/dialog/DialogContent.astro +19 -1
  18. package/src/components/dialog/DialogTrigger.astro +5 -1
  19. package/src/components/dropdown-menu/CHANGELOG.md +25 -0
  20. package/src/components/dropdown-menu/DropdownMenu.astro +13 -3
  21. package/src/components/dropdown-menu/DropdownMenuRadioGroup.astro +17 -2
  22. package/src/components/dropdown-menu/DropdownMenuTrigger.astro +7 -1
  23. package/src/components/field/CHANGELOG.md +18 -0
  24. package/src/components/field/Field.astro +18 -0
  25. package/src/components/field/FieldError.astro +13 -1
  26. package/src/components/field/FieldGroup.astro +12 -3
  27. package/src/components/hover-card/CHANGELOG.md +9 -0
  28. package/src/components/hover-card/HoverCardTrigger.astro +5 -1
  29. package/src/components/notifications-menu/CHANGELOG.md +7 -0
  30. package/src/components/notifications-menu/NotificationsMenu.astro +1 -0
  31. package/src/components/notifications-menu/NotificationsMenuItem.astro +1 -0
  32. package/src/components/option-card/CHANGELOG.md +10 -0
  33. package/src/components/option-card/OptionCard.astro +9 -1
  34. package/src/components/organisms/top-bar/CHANGELOG.md +12 -0
  35. package/src/components/organisms/top-bar/TopBar.astro +17 -13
  36. package/src/components/organisms/workspace/CHANGELOG.md +7 -0
  37. package/src/components/popover/CHANGELOG.md +14 -0
  38. package/src/components/popover/PopoverClose.astro +2 -1
  39. package/src/components/popover/PopoverTrigger.astro +7 -1
  40. package/src/components/select/CHANGELOG.md +8 -0
  41. package/src/components/select/SelectTrigger.astro +1 -1
  42. package/src/components/table/CHANGELOG.md +6 -0
  43. package/src/components/table/TableEditableCell.astro +2 -2
  44. package/src/components/table/TablePagination.astro +4 -4
  45. package/src/components/tabs/CHANGELOG.md +16 -0
  46. package/src/components/tabs/Tabs.astro +26 -0
  47. package/src/components/tabs/TabsList.astro +30 -3
  48. package/src/components/toggle-group/CHANGELOG.md +11 -0
  49. package/src/components/toggle-group/ToggleGroup.astro +19 -2
  50. package/src/components/toggle-group/ToggleGroupItem.astro +10 -0
  51. package/src/components/tooltip/CHANGELOG.md +26 -0
  52. package/src/components/tooltip/Tooltip.astro +31 -3
  53. package/src/components/tooltip/TooltipTrigger.astro +5 -1
  54. package/src/lib/command-search.ts +5 -0
  55. package/src/lib/dialog-focus.ts +73 -0
  56. package/src/lib/dropdown-menu-radio-groups.ts +79 -0
  57. package/src/lib/scroll-lock.ts +97 -0
  58. package/src/lib/toggle-shared.ts +6 -1
  59. package/src/styles/CHANGELOG.md +13 -0
  60. package/src/styles/globals.css +22 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cl-light-ds/ui",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "ClickLearn Lightning Design System — Astro UI component library.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,7 +10,11 @@ type IndicatorIcon = "plus-minus" | "chevron";
10
10
 
11
11
  interface Props extends HTMLAttributes<"button"> {
12
12
  class?: string;
13
- /** Radix-like: render wrapper so the child can be the interactive element */
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
- /** Radix-like: render wrapper so the child can be the interactive element */
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
@@ -25,8 +25,8 @@ const {
25
25
  */
26
26
  }
27
27
  <button
28
- {...rest}
29
28
  type="button"
29
+ {...rest}
30
30
  tabindex={tabindex}
31
31
  data-slot="combobox-trigger"
32
32
  data-size={size}
@@ -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 replaced. */
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 list.querySelectorAll<HTMLElement>('[data-slot="command-group"]')) {
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
- const n = [...group.querySelectorAll<HTMLElement>('[data-slot="command-item"]')].filter((i) => !i.hidden).length;
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(visible.length)) : (root.dataset.emptyTitle ?? ""));
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 data-slot="command-group" class={cn(commandSearchGroupClasses, className)} {...rest}>
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
- .querySelectorAll<HTMLElement>('[data-slot="dialog"]')
195
- .forEach((el) => createDialog(el));
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 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
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
- /** Radix-like: render wrapper so the child can be the interactive element */
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