@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
@@ -101,6 +101,18 @@
101
101
  * <TabsContent value="reports">Reports content.</TabsContent>
102
102
  * </Tabs>
103
103
  *
104
+ * ### Bordered
105
+ *
106
+ * `bordered` on `TabsList` adds a 1px `border`-token outline round the list,
107
+ * for a list on a light grey page beside bordered controls. Off by default.
108
+ *
109
+ * <Tabs defaultValue="all">
110
+ * <TabsList bordered>
111
+ * <TabsTrigger value="all">All</TabsTrigger>
112
+ * <TabsTrigger value="favourites">Favourites</TabsTrigger>
113
+ * </TabsList>
114
+ * </Tabs>
115
+ *
104
116
  * ### Vertical
105
117
  *
106
118
  * <Tabs defaultValue="profile" orientation="vertical" class="w-full max-w-xl">
@@ -303,4 +315,18 @@ const {
303
315
  document
304
316
  .querySelectorAll('[data-slot="tabs"]')
305
317
  .forEach((el) => createTabs(el));
318
+
319
+ // Along the list (left when horizontal, top when vertical) the runtime measures
320
+ // the active tab from inside the list's border, then subtracts the border width
321
+ // again, so on a list with a real border the indicator sits off by that width.
322
+ // Record the border widths; the indicator adds them back on that axis.
323
+ const syncBorders = (list: HTMLElement) => {
324
+ list.style.setProperty("--tabs-list-border-l", `${list.clientLeft}px`);
325
+ list.style.setProperty("--tabs-list-border-t", `${list.clientTop}px`);
326
+ };
327
+ const borders = new ResizeObserver((entries) => entries.forEach((e) => syncBorders(e.target as HTMLElement)));
328
+ document.querySelectorAll<HTMLElement>('[data-slot="tabs-list"]').forEach((list) => {
329
+ syncBorders(list);
330
+ borders.observe(list);
331
+ });
306
332
  </script>
@@ -1,4 +1,17 @@
1
1
  ---
2
+ /**
3
+ * TabsList — the row (or column) of tab triggers.
4
+ *
5
+ * `bordered` (default `false`) adds a 1px `border`-token outline round the
6
+ * list, for a list sitting on a light grey page beside bordered controls
7
+ * (search field, buttons), where the muted fill alone blends in (C11, D104).
8
+ * The list keeps its height: the border sits inside it.
9
+ *
10
+ * Drawn as an inset ring with 1px more padding, not a CSS border: the
11
+ * runtime positions the indicator from `offsetLeft` and then subtracts the
12
+ * list's `clientLeft` (its border width) again, so a real border shifts the
13
+ * indicator 1px off its tab. Same geometry as `border` + the usual padding.
14
+ */
2
15
  import { cva, type VariantProps } from "class-variance-authority";
3
16
  import { cn } from "@cl-light-ds/ui/lib/utils";
4
17
  import type { HTMLAttributes } from "astro/types";
@@ -14,27 +27,40 @@ export const tabsListVariants = cva(
14
27
  "line-animated":
15
28
  "rounded-none gap-1 bg-transparent p-0",
16
29
  },
30
+ bordered: {
31
+ true: "ring-1 ring-inset ring-border",
32
+ false: "",
33
+ },
17
34
  size: {
18
35
  default: "group-data-horizontal/tabs:h-9",
19
36
  sm: "group-data-horizontal/tabs:h-8",
20
37
  lg: "group-data-horizontal/tabs:h-10",
21
38
  },
22
39
  },
40
+ compoundVariants: [
41
+ // 1px more padding where the border would be (C11).
42
+ { bordered: true, variant: "indicator", class: "p-[3px]" },
43
+ { bordered: true, variant: "default", class: "p-[4px]" },
44
+ ],
23
45
  defaultVariants: {
24
46
  variant: "indicator",
25
47
  size: "default",
48
+ bordered: false,
26
49
  },
27
50
  },
28
51
  );
29
52
 
30
53
  interface Props
31
54
  extends VariantProps<typeof tabsListVariants>, HTMLAttributes<"div"> {
55
+ /** A 1px `border`-token outline round the list (C11). Off by default. */
56
+ bordered?: boolean;
32
57
  class?: string;
33
58
  }
34
59
 
35
60
  const {
36
61
  variant = "indicator",
37
62
  size = "default",
63
+ bordered = false,
38
64
  class: className = "",
39
65
  ...rest
40
66
  } = Astro.props as Props;
@@ -45,14 +71,15 @@ const {
45
71
  role="tablist"
46
72
  data-slot="tabs-list"
47
73
  data-variant={variant}
48
- class={cn("", tabsListVariants({ variant, size }), className)}
74
+ data-bordered={bordered ? "" : undefined}
75
+ class={cn("", tabsListVariants({ variant, size, bordered }), className)}
49
76
  >
50
77
  {
51
78
  variant === "indicator" && (
52
79
  <div
53
80
  aria-hidden="true"
54
81
  data-slot="tabs-indicator"
55
- class="gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*='size-'])]:size-4 pointer-events-none absolute top-(--active-tab-top) left-(--active-tab-left) z-0 h-(--active-tab-height) w-(--active-tab-width) bg-background shadow-sm transition-all duration-150 ease dark:border-input dark:bg-input/30"
82
+ class="gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*='size-'])]:size-4 pointer-events-none absolute top-(--active-tab-top) left-(--active-tab-left) group-data-[orientation=horizontal]/tabs:left-[calc(var(--active-tab-left)+var(--tabs-list-border-l,0px))] group-data-[orientation=vertical]/tabs:top-[calc(var(--active-tab-top)+var(--tabs-list-border-t,0px))] z-0 h-(--active-tab-height) w-(--active-tab-width) bg-background shadow-sm transition-all duration-150 ease dark:border-input dark:bg-input/30"
56
83
  />
57
84
  )
58
85
  }
@@ -61,7 +88,7 @@ const {
61
88
  <div
62
89
  aria-hidden="true"
63
90
  data-slot="tabs-indicator"
64
- class="pointer-events-none absolute z-0 rounded-full bg-foreground transition-all duration-150 ease group-data-[orientation=horizontal]/tabs:left-[var(--active-tab-left)] group-data-[orientation=horizontal]/tabs:top-[calc(var(--active-tab-top)+var(--active-tab-height)-2px)] group-data-[orientation=horizontal]/tabs:h-0.5 group-data-[orientation=horizontal]/tabs:w-[var(--active-tab-width)] group-data-[orientation=vertical]/tabs:top-[var(--active-tab-top)] group-data-[orientation=vertical]/tabs:left-[calc(var(--active-tab-left)+var(--active-tab-width)-2px)] group-data-[orientation=vertical]/tabs:h-[var(--active-tab-height)] group-data-[orientation=vertical]/tabs:w-0.5"
91
+ class="pointer-events-none absolute z-0 rounded-full bg-foreground transition-all duration-150 ease group-data-[orientation=horizontal]/tabs:left-[calc(var(--active-tab-left)+var(--tabs-list-border-l,0px))] group-data-[orientation=horizontal]/tabs:top-[calc(var(--active-tab-top)+var(--active-tab-height)-2px)] group-data-[orientation=horizontal]/tabs:h-0.5 group-data-[orientation=horizontal]/tabs:w-[var(--active-tab-width)] group-data-[orientation=vertical]/tabs:top-[calc(var(--active-tab-top)+var(--tabs-list-border-t,0px))] group-data-[orientation=vertical]/tabs:left-[calc(var(--active-tab-left)+var(--active-tab-width)-2px)] group-data-[orientation=vertical]/tabs:h-[var(--active-tab-height)] group-data-[orientation=vertical]/tabs:w-0.5"
65
92
  />
66
93
  )
67
94
  }
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to the `ToggleGroup` component will be documented in this file.
4
4
 
5
+ ## Date 15th Oct 2026
6
+ ## Workspace selection handover (C26)
7
+
8
+ ### Added
9
+
10
+ - `variant="segmented"` (`variant: "default" | "outline" | "segmented"`): the outline look (shared borders, shadow) with the pressed item filled `muted` in `foreground` text, and kept `muted` on hover. For a view switcher (card/list), where one item is always the current view. Replaces the workspace-selection screen's `group-data-[view=…]` classes on each item.
11
+ - The fill follows the runtime's own pressed state (`aria-pressed`, set by `@data-slot/toggle-group`), not app classes: set it with `defaultValue` or the `toggle-group:set` event. The pressed colours use a plain ancestor selector (`[data-slot=toggle-group][data-variant=segmented] …[aria-pressed=true]`), which outranks the shared pressed and hover backgrounds; Tailwind's `group-data-*` wraps the group in `:where()` and would only tie with them.
12
+ - Added to the shared `TOGGLE_VARIANTS` / `toggleVariants` (`lib/toggle-shared.ts`) so the controller gives items the outline borders; a standalone `Toggle` accepts it too (outline with a `muted` pressed state), though it's made for groups.
13
+ - Opt-in: `default` and `outline` groups are unchanged.
14
+ - Storybook: a "Segmented" preview beside the outline one.
15
+
5
16
  ## 2026-09-18 — Figma alignment (ClickLearn Lightning Design Library, node `5455-20808`)
6
17
 
7
18
  Figma documents ToggleGroup across four pages: States (Default, Hover, Pressed, Focus Default, Focus Pressed, Disabled), Sizes (Small 28px, Medium 32px, Large 36px — matching `Toggle`'s own scale), Items (Lead/Middle/Tail item positions × sizes × states), and the top-level Variants page (Small/Medium/Large).
@@ -98,6 +98,22 @@
98
98
  * </ToggleGroupItem>
99
99
  * </ToggleGroup>
100
100
  *
101
+ * ### Segmented Variant
102
+ *
103
+ * `variant="segmented"` is the outline look with the pressed item filled
104
+ * `muted` in `foreground` text, also on hover: a view switcher, where one item
105
+ * is always the current view. The fill follows the group's own pressed state
106
+ * (`aria-pressed`), so drive it with `defaultValue` or `toggle-group:set`.
107
+ *
108
+ * <ToggleGroup variant="segmented" defaultValue="card" aria-label="Collection view">
109
+ * <ToggleGroupItem value="card" aria-label="Card view">
110
+ * <LayoutGridIcon />
111
+ * </ToggleGroupItem>
112
+ * <ToggleGroupItem value="list" aria-label="List view">
113
+ * <ListIcon />
114
+ * </ToggleGroupItem>
115
+ * </ToggleGroup>
116
+ *
101
117
  * ### Sizes
102
118
  *
103
119
  * <div class="flex flex-col gap-4">
@@ -181,7 +197,8 @@ import type { HTMLAttributes } from "astro/types";
181
197
  import { cn } from "@cl-light-ds/ui/lib/utils";
182
198
 
183
199
  interface Props extends Omit<HTMLAttributes<"div">, "class"> {
184
- variant?: "default" | "outline";
200
+ /** `"segmented"`: outline, with the pressed item `muted` in `foreground` text (C26). */
201
+ variant?: "default" | "outline" | "segmented";
185
202
  size?: "xs" | "default" | "sm" | "lg";
186
203
  spacing?: number;
187
204
  defaultValue?: string | string[];
@@ -218,7 +235,7 @@ const {
218
235
  data-default-value={Array.isArray(defaultValue) ? defaultValue.join(",") : defaultValue}
219
236
  data-multiple={multiple ? "true" : undefined}
220
237
  class={cn(
221
- "rounded-lg data-[spacing=0]:data-[variant=outline]:shadow-xs",
238
+ "rounded-lg data-[spacing=0]:data-[variant=outline]:shadow-xs data-[spacing=0]:data-[variant=segmented]:shadow-xs",
222
239
  "group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-vertical:flex-col data-vertical:items-stretch",
223
240
  className
224
241
  )}
@@ -40,6 +40,16 @@ const {
40
40
  "group-data-vertical/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=outline]/toggle-group:border-t-0",
41
41
  "group-data-horizontal/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=outline]/toggle-group:first:border-s",
42
42
  "group-data-vertical/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=outline]/toggle-group:first:border-t",
43
+ // C26 — segmented: the outline's shared borders, and the pressed item
44
+ // `muted` in `foreground` text, also on hover. The pressed colours use a
45
+ // plain ancestor selector (Tailwind's group-data-* wraps the group in
46
+ // :where(), adding no weight) so they outrank the shared pressed and hover
47
+ // backgrounds.
48
+ "group-data-horizontal/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=segmented]/toggle-group:border-s-0",
49
+ "group-data-vertical/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=segmented]/toggle-group:border-t-0",
50
+ "group-data-horizontal/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=segmented]/toggle-group:first:border-s",
51
+ "group-data-vertical/toggle-group:group-data-[spacing=0]/toggle-group:group-data-[variant=segmented]/toggle-group:first:border-t",
52
+ "[[data-slot=toggle-group][data-variant=segmented]_&]:aria-pressed:bg-muted [[data-slot=toggle-group][data-variant=segmented]_&]:aria-pressed:text-foreground",
43
53
  className
44
54
  )}
45
55
  {...rest}
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to the `Tooltip` component family (`Tooltip`, `TooltipTrigger`, `TooltipContent`) are documented in this file.
4
4
 
5
+ ## Date 15th Oct 2026
6
+ ## Handover gaps: Tooltip on these pages (C22, gap G2)
7
+
8
+ ### Fixed
9
+
10
+ - **`TooltipTrigger asChild` binds your control, not the wrapper.** The handover recorded Tooltip as "doesn't work on these pages" (G2, workspace G10). That note dates from when the screens were static (no JavaScript), so no tooltip could work. Retested with 1.1.2 in the storybook and in a test page shaped like the screen (sticky top bar, `WorkspaceCard` with its stretched link, `ToggleGroup` view switcher, `FavoriteToggle`, a `Dialog`): hover shows the tooltip everywhere, on top of everything (portal, `z-50`), including in a dialog and over a sticky bar. What really failed, for every `asChild` trigger (the usual way to put a tooltip on a DS `Button`):
11
+ - **Keyboard:** the runtime listens for `focus` on whatever carries `data-slot="tooltip-trigger"`. With `asChild` that was the wrapper `<div>`, and `focus` doesn't bubble, so tabbing to the button never opened the tooltip.
12
+ - **Screen readers:** `aria-describedby` went on the wrapper `<div>`, not on the focused control.
13
+ - **Position:** the tooltip centred on the wrapper. In a grid cell, a flex column or a dialog body the wrapper stretches to the full width, so the tooltip sat off to one side (163px off in a 448px dialog).
14
+ - `Tooltip`'s script now hands the slot to the wrapper's first child while binding, then gives the child its own `data-slot` back (the runtime keeps the element). Keyboard focus opens the tooltip, `aria-describedby` is on the control, and the tooltip centres on it. Markup and props are unchanged.
15
+ - A **disabled** child (`:disabled` when the page loads) keeps the wrapper as trigger, as before: a disabled button fires no pointer events, so this keeps hovering it showing the tooltip.
16
+
17
+ ### Notes
18
+
19
+ - Like the other overlays, tooltips bind once, when the page loads. A tooltip added to the page later needs `create()` from `@data-slot/tooltip`, and is bound to its wrapper.
20
+ - The screens can now use `Tooltip` on icon-only controls, keeping their `aria-label`s.
21
+
22
+
23
+ ## Handover gaps: triggers don't submit forms (C29, gap G18)
24
+
25
+ ### Docs
26
+
27
+ - `TooltipTrigger` already renders `type="button"` unless you pass a `type`; no change.
28
+ - **`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).
29
+
30
+
5
31
  ## 2026-09-18 — Figma alignment (ClickLearn Lightning Design Library, node `5275-137`)
6
32
 
7
33
  Figma documents Tooltip across four pages: Contents (6 side/align positions — already fully covered by the existing `side`/`align` props), Variants (Default, Outline), Sizes (Small, Large), and the top-level Component page.
@@ -351,7 +351,35 @@ const {
351
351
  <script>
352
352
  import { createTooltip } from "@data-slot/tooltip";
353
353
 
354
- document
355
- .querySelectorAll('[data-slot="tooltip"]')
356
- .forEach((el) => createTooltip(el));
354
+ /*
355
+ * `TooltipTrigger asChild` renders a wrapper <div data-slot="tooltip-trigger">
356
+ * around your control, and @data-slot/tooltip binds to whatever carries that
357
+ * slot. Bound to the wrapper, the tooltip never opened on keyboard focus
358
+ * (focus doesn't bubble to the div), put aria-describedby on the div instead
359
+ * of the control, and centred on the div, which stretches in grid/flex
360
+ * columns (C22). So bind the child: give it the slot while binding, then give
361
+ * its own data-slot back (the runtime keeps the element, not the selector).
362
+ * A disabled child fires no pointer events, so it keeps the wrapper as its
363
+ * trigger, as before: hovering a disabled control still shows its tooltip.
364
+ */
365
+ const bind = (root: Element) => {
366
+ const wrapper = root.querySelector<HTMLElement>('[data-slot="tooltip-trigger"][data-as-child]');
367
+ const child = wrapper?.firstElementChild as HTMLElement | null | undefined;
368
+ if (!wrapper || !child || wrapper.closest('[data-slot="tooltip"]') !== root || child.matches(":disabled")) {
369
+ createTooltip(root);
370
+ return;
371
+ }
372
+ const childSlot = child.getAttribute("data-slot");
373
+ wrapper.setAttribute("data-slot", "tooltip-trigger-wrapper");
374
+ child.setAttribute("data-slot", "tooltip-trigger");
375
+ try {
376
+ createTooltip(root);
377
+ } finally {
378
+ if (childSlot === null) child.removeAttribute("data-slot");
379
+ else child.setAttribute("data-slot", childSlot);
380
+ wrapper.setAttribute("data-slot", "tooltip-trigger");
381
+ }
382
+ };
383
+
384
+ document.querySelectorAll('[data-slot="tooltip"]').forEach(bind);
357
385
  </script>
@@ -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
 
@@ -30,6 +30,11 @@ export interface CommandSearchResultGroup {
30
30
  items: CommandSearchResultItem[];
31
31
  /** Total matches, when more exist than `items` holds. Defaults to `items.length`. */
32
32
  count?: number;
33
+ /**
34
+ * Show at most this many of `items`; the count stays `count ?? items.length`.
35
+ * Defaults to `CommandSearch`'s `limit`; 0 for no limit.
36
+ */
37
+ limit?: number;
33
38
  }
34
39
 
35
40
  type Target = string | HTMLElement | undefined;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Focus inside popovers that open in a dialog.
3
+ *
4
+ * The popover runtime moves an open popover's content to <body> (a positioner),
5
+ * so it is outside the dialog: the dialog's focus trap sees focus there as
6
+ * "escaped" and pulls it back, and Tab from the popover's trigger skips the
7
+ * popover. A popover opened from a dialog acts as a nested modal instead:
8
+ *
9
+ * - Tab from its trigger goes into the popover (Shift+Tab to its last control);
10
+ * - Tab and Shift+Tab go round the popover's own controls;
11
+ * - Escape (the popover's own) closes it and returns to the trigger.
12
+ *
13
+ * The popover is tied to its dialog through its trigger (`aria-controls`).
14
+ * Installed once by Dialog; runs before the dialog's trap (window, capture).
15
+ */
16
+
17
+ const TABBABLE =
18
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
19
+
20
+ let installed = false;
21
+
22
+ /** The open popover whose trigger is in an open dialog and which holds or triggers `el`. */
23
+ function popoverFor(el: Element): { content: HTMLElement; fromTrigger: boolean } | null {
24
+ const content = el.closest<HTMLElement>('[data-slot="popover-content"]');
25
+ if (content) return !content.hidden && inOpenDialog(triggerOf(content)) ? { content, fromTrigger: false } : null;
26
+
27
+ const trigger = el.closest<HTMLElement>('[aria-controls][aria-expanded="true"]');
28
+ const target = trigger && document.getElementById(trigger.getAttribute("aria-controls")!);
29
+ if (target?.dataset.slot === "popover-content" && !target.hidden && inOpenDialog(trigger)) {
30
+ return { content: target, fromTrigger: true };
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function triggerOf(content: HTMLElement) {
36
+ return content.id ? document.querySelector(`[aria-controls="${CSS.escape(content.id)}"]`) : null;
37
+ }
38
+
39
+ function inOpenDialog(el: Element | null) {
40
+ return !!el?.closest('[data-slot="dialog-content"][data-open]');
41
+ }
42
+
43
+ export function trapTabInPopoversInDialogs() {
44
+ if (installed) return;
45
+ installed = true;
46
+
47
+ window.addEventListener(
48
+ "keydown",
49
+ (event) => {
50
+ if (event.key !== "Tab" || event.defaultPrevented) return;
51
+ const active = document.activeElement;
52
+ const found = active && popoverFor(active);
53
+ if (!found) return;
54
+
55
+ const tabbables = Array.from(found.content.querySelectorAll<HTMLElement>(TABBABLE)).filter(
56
+ (el) => !el.closest("[hidden]") && el.getClientRects().length > 0,
57
+ );
58
+ if (tabbables.length === 0) return;
59
+
60
+ event.preventDefault();
61
+ event.stopPropagation();
62
+ const at = found.fromTrigger ? -1 : tabbables.indexOf(active as HTMLElement);
63
+ const next =
64
+ at === -1
65
+ ? event.shiftKey
66
+ ? tabbables.length - 1
67
+ : 0
68
+ : (at + (event.shiftKey ? -1 : 1) + tabbables.length) % tabbables.length;
69
+ tabbables[next].focus();
70
+ },
71
+ true,
72
+ );
73
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Several radio groups in one DropdownMenu.
3
+ *
4
+ * The @data-slot/dropdown-menu runtime holds a single radio value per menu, so a
5
+ * second group (Sort by + Order) would untick the first. A *named*
6
+ * DropdownMenuRadioGroup is run on the runtime's checkbox values instead: its
7
+ * items are registered as checkbox items, exactly one is kept checked, and they
8
+ * are still announced as radios (`menuitemradio`).
9
+ *
10
+ * The group's value is mirrored on the group (`data-value`) and announced with
11
+ * `dropdown-menu:group-value-change` ({ name, value, previousValue }) on the root.
12
+ */
13
+
14
+ type Menu = { values: string[]; set(detail: { values?: string[] }): void };
15
+
16
+ const GROUP = '[data-slot="dropdown-menu-radio-group"][data-name]';
17
+ const ITEM = '[data-slot="dropdown-menu-radio-item"]';
18
+ const GROUPED_ITEM = "[data-radio-group-item]";
19
+
20
+ /** Before the runtime starts: turn named groups' items into checkbox items. */
21
+ export function prepareRadioGroups(root: HTMLElement) {
22
+ const defaults: string[] = [];
23
+ for (const group of root.querySelectorAll<HTMLElement>(GROUP)) {
24
+ const items = Array.from(group.querySelectorAll<HTMLElement>(ITEM));
25
+ const initial =
26
+ group.dataset.defaultValue ?? items.find((item) => item.hasAttribute("data-default-checked"))?.dataset.value;
27
+ for (const item of items) {
28
+ item.dataset.slot = "dropdown-menu-checkbox-item";
29
+ item.setAttribute("data-radio-group-item", group.dataset.name!);
30
+ item.toggleAttribute("data-default-checked", item.dataset.value === initial);
31
+ }
32
+ if (initial) {
33
+ defaults.push(initial);
34
+ group.dataset.value = initial;
35
+ }
36
+ }
37
+ // Root defaults win over item defaults in the runtime, so merge ours in.
38
+ if (defaults.length && root.dataset.defaultValues) {
39
+ const values = JSON.parse(root.dataset.defaultValues) as string[];
40
+ root.dataset.defaultValues = JSON.stringify([...values, ...defaults.filter((v) => !values.includes(v))]);
41
+ }
42
+ }
43
+
44
+ /** After the runtime starts: keep one item per group checked. */
45
+ export function bindRadioGroups(root: HTMLElement, menu: Menu) {
46
+ const items = Array.from(root.querySelectorAll<HTMLElement>(GROUPED_ITEM));
47
+ if (items.length === 0) return;
48
+ // The runtime re-sets checkbox items' role when the menu opens; keep them radios.
49
+ const asRadio = () => {
50
+ for (const item of items) if (item.getAttribute("role") !== "menuitemradio") item.setAttribute("role", "menuitemradio");
51
+ };
52
+ asRadio();
53
+ const roles = new MutationObserver(asRadio);
54
+ for (const item of items) roles.observe(item, { attributeFilter: ["role"] });
55
+
56
+ // Picking the checked item again would untick it: a radio can't be unset.
57
+ root.addEventListener("dropdown-menu:select", (event) => {
58
+ const item = (event as CustomEvent<{ item: HTMLElement | null }>).detail.item;
59
+ if (item?.hasAttribute("data-radio-group-item") && item.hasAttribute("data-checked")) event.preventDefault();
60
+ });
61
+
62
+ root.addEventListener("dropdown-menu:values-change", (event) => {
63
+ const { item, checked } = (event as CustomEvent<{ item: HTMLElement | null; checked: boolean }>).detail;
64
+ const name = item?.getAttribute("data-radio-group-item");
65
+ if (!item || !name || !checked) return;
66
+ const siblings = items.filter((other) => other !== item && other.getAttribute("data-radio-group-item") === name);
67
+ const drop = new Set(siblings.map((other) => other.dataset.value));
68
+ menu.set({ values: menu.values.filter((value) => !drop.has(value)) });
69
+
70
+ const group = item.closest<HTMLElement>(GROUP);
71
+ const previousValue = group?.dataset.value ?? null;
72
+ if (group) group.dataset.value = item.dataset.value;
73
+ root.dispatchEvent(
74
+ new CustomEvent("dropdown-menu:group-value-change", {
75
+ detail: { name, value: item.dataset.value, previousValue },
76
+ }),
77
+ );
78
+ });
79
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Page scroll lock for dialogs and menus that leaves the scrollbar where it is.
3
+ *
4
+ * The @data-slot runtimes lock scroll by setting `overflow: hidden` and
5
+ * `scrollbar-gutter: stable` on <html>. The reserved gutter isn't covered by a
6
+ * dialog's overlay (a white band down the right edge), and on a page with no
7
+ * scrollbar the gutter appears from nowhere and the page jumps sideways. So the
8
+ * components pass `lockScroll: false` to the runtime and lock here instead:
9
+ * nothing about the layout changes; the wheel, touch and the page-scrolling keys
10
+ * just don't scroll the page while a lock is held. Anything under the pointer
11
+ * that can scroll by itself (a long list in the dialog) still does, and a
12
+ * dragged scrollbar is put back.
13
+ */
14
+
15
+ let holds = 0;
16
+ let savedY = 0;
17
+ let installed = false;
18
+
19
+ /** Whether something between `target` and the page can scroll by `dy` itself. */
20
+ function scrollsItself(target: EventTarget | null, dy: number) {
21
+ for (
22
+ let el = target instanceof Element ? target : null;
23
+ el && el !== document.body && el !== document.documentElement;
24
+ el = el.parentElement
25
+ ) {
26
+ const { overflowY } = getComputedStyle(el);
27
+ if (!/(auto|scroll)/.test(overflowY)) continue;
28
+ if (el.scrollHeight <= el.clientHeight) continue;
29
+ if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight) return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ function install() {
35
+ if (installed) return;
36
+ installed = true;
37
+
38
+ window.addEventListener(
39
+ "wheel",
40
+ (event) => {
41
+ if (holds > 0 && !scrollsItself(event.target, event.deltaY)) event.preventDefault();
42
+ },
43
+ { passive: false },
44
+ );
45
+
46
+ let touchY = 0;
47
+ window.addEventListener("touchstart", (event) => (touchY = event.touches[0]?.clientY ?? 0), {
48
+ passive: true,
49
+ });
50
+ window.addEventListener(
51
+ "touchmove",
52
+ (event) => {
53
+ const dy = touchY - (event.touches[0]?.clientY ?? touchY);
54
+ if (holds > 0 && !scrollsItself(event.target, dy)) event.preventDefault();
55
+ },
56
+ { passive: false },
57
+ );
58
+
59
+ window.addEventListener("keydown", (event) => {
60
+ if (holds === 0 || !["PageUp", "PageDown", "Home", "End", " "].includes(event.key)) return;
61
+ const target = event.target as HTMLElement;
62
+ if (target.closest("input, textarea, select, button, [contenteditable]")) return;
63
+ const dy = event.key === "PageUp" || event.key === "Home" ? -1 : 1;
64
+ if (!scrollsItself(target, dy)) event.preventDefault();
65
+ });
66
+
67
+ window.addEventListener("scroll", () => {
68
+ if (holds > 0 && window.scrollY !== savedY) window.scrollTo(window.scrollX, savedY);
69
+ });
70
+ }
71
+
72
+ /** Take a lock. Locks nest: the page scrolls again once every lock is released. */
73
+ export function holdScrollLock() {
74
+ install();
75
+ if (holds === 0) savedY = window.scrollY;
76
+ holds += 1;
77
+ }
78
+
79
+ export function releaseScrollLock() {
80
+ holds = Math.max(0, holds - 1);
81
+ }
82
+
83
+ /**
84
+ * Hold the lock while `root` is open. `event` is the runtime's open-change event
85
+ * (its detail has `open`); `isOpen` reads the state for roots that start open.
86
+ */
87
+ export function lockScrollWhileOpen(root: HTMLElement, event: string, isOpen: () => boolean) {
88
+ let open = false;
89
+ const set = (next: boolean) => {
90
+ if (next === open) return;
91
+ open = next;
92
+ if (next) holdScrollLock();
93
+ else releaseScrollLock();
94
+ };
95
+ root.addEventListener(event, (e) => set(Boolean((e as CustomEvent<{ open?: boolean }>).detail?.open)));
96
+ set(isOpen());
97
+ }
@@ -1,6 +1,6 @@
1
1
  import { cva, type VariantProps } from "class-variance-authority";
2
2
 
3
- export const TOGGLE_VARIANTS = ["default", "outline", "ghost"] as const;
3
+ export const TOGGLE_VARIANTS = ["default", "outline", "ghost", "segmented"] as const;
4
4
  export const TOGGLE_SIZES = ["xs", "default", "sm", "lg"] as const;
5
5
 
6
6
  export type ToggleVariant = (typeof TOGGLE_VARIANTS)[number];
@@ -16,6 +16,11 @@ export const toggleVariants = cva(
16
16
  // Pressed shows in the content only (e.g. a filled star): no pressed
17
17
  // background, the normal hover background on hover.
18
18
  ghost: "bg-transparent aria-pressed:bg-transparent aria-pressed:hover:bg-background-hover",
19
+ // C26 — outline, with the pressed item `muted` and in `foreground` text
20
+ // (a view switcher). Made for ToggleGroup, which also keeps it `muted`
21
+ // on hover; follows the runtime's `aria-pressed`.
22
+ segmented:
23
+ "border-input hover:bg-background-hover border bg-transparent shadow-xs aria-pressed:bg-muted aria-pressed:text-foreground",
19
24
  },
20
25
  size: {
21
26
  xs: "h-7 min-w-7 px-1",
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to the design tokens in `globals.css` will be documented in this file.
4
4
 
5
+ ## Date 15th Oct 2026
6
+ ## Workspace selection handover (C27)
7
+
8
+ ### Type styles
9
+
10
+ - Three utilities (`@utility`) for page and section headings (D98–D101), so screens don't repeat the size, weight and colour:
11
+ - `type-page-greeting`: the page title as a greeting ("Good morning, …"), `font-heading` 24/32 (`text-2xl`), semibold, `tracking-tight`, `foreground`.
12
+ - `type-section-title`: a section heading ("Recents", "All workspaces"), 16/24 (`text-base`) semibold, `foreground`.
13
+ - `type-section-meta`: the small muted text beside a section heading (a subtext, a count), 14/20 (`text-sm`) regular, `muted-foreground`; resets weight and tracking, so it can sit inside the heading (on its baseline: `flex items-baseline gap-2` on the heading).
14
+ - Named `type-*` rather than `text-*`, so tailwind-merge (`cn()`) doesn't read them as a text colour or size and drop them next to one. Layout (baseline row, gaps) stays with the screen.
15
+ - Replaces the workspace-selection screen's `sectionHeading` / `besideHeading` class strings and the greeting's classes. **Not in Figma yet.**
16
+ - Storybook: a "Type styles" section on Foundations › Fonts.
17
+
5
18
  ## Date 15th Oct 2026
6
19
  ## Workspace selection handover (N1, N8, N19, C17)
7
20
 
@@ -280,6 +280,28 @@
280
280
  --table-row-selected: color-mix(in oklab, var(--primary-500) 20%, var(--background));
281
281
  }
282
282
 
283
+ /*
284
+ * Type styles (C27, D98–D101): page and section headings in one class each,
285
+ * so screens don't repeat the size, weight and colour.
286
+ * - type-page-greeting: the page title as a greeting, 24/32 heading font, semibold.
287
+ * - type-section-title: a section heading ("Recents", "All workspaces"), 16/24 semibold.
288
+ * - type-section-meta: the small muted text beside a section heading (a subtext,
289
+ * a count), 14/20 regular; resets weight and tracking when nested in the title.
290
+ * Named type-* rather than text-*, so tailwind-merge doesn't read them as a
291
+ * text colour or size and drop them next to one in cn().
292
+ */
293
+ @utility type-page-greeting {
294
+ @apply font-heading text-2xl font-semibold tracking-tight text-foreground;
295
+ }
296
+
297
+ @utility type-section-title {
298
+ @apply text-base font-semibold text-foreground;
299
+ }
300
+
301
+ @utility type-section-meta {
302
+ @apply text-sm font-normal tracking-normal text-muted-foreground;
303
+ }
304
+
283
305
  @layer base {
284
306
  * {
285
307
  @apply border-border outline-ring/50;