@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
/**
|
|
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
|
+
}
|
package/src/lib/toggle-shared.ts
CHANGED
|
@@ -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",
|
package/src/styles/CHANGELOG.md
CHANGED
|
@@ -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
|
|
package/src/styles/globals.css
CHANGED
|
@@ -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;
|