@elabs-ai/components-ui 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/dist/chunk-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +340 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +501 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +189 -12
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +46 -3
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +75 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +92 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-mobile.ts +12 -6
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
|
@@ -19,6 +19,7 @@ import { useIsMobile } from "../../lib/use-mobile";
|
|
|
19
19
|
import { Button } from "../button";
|
|
20
20
|
import { useCollapsiblePanel } from "../collapsible-panel";
|
|
21
21
|
import { Input } from "../input";
|
|
22
|
+
import { useLocale } from "../locale-provider";
|
|
22
23
|
import { Separator } from "../separator";
|
|
23
24
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../sheet";
|
|
24
25
|
import { Skeleton } from "../skeleton";
|
|
@@ -55,12 +56,36 @@ export const SidebarProvider = forwardRef<
|
|
|
55
56
|
defaultOpen?: boolean;
|
|
56
57
|
open?: boolean;
|
|
57
58
|
onOpenChange?: (open: boolean) => void;
|
|
59
|
+
/**
|
|
60
|
+
* Drives the frame's inset treatment from an ANCESTOR the whole frame can
|
|
61
|
+
* see — `SidebarInset` (#342 fix) reads it as `data-variant` on this
|
|
62
|
+
* wrapper via `group-data-[variant=inset]/sidebar-wrapper:`, which reaches
|
|
63
|
+
* regardless of DOM order (unlike the legacy `peer-*` combinator, which
|
|
64
|
+
* only matches a Sidebar that comes AFTER). Optional and unset by
|
|
65
|
+
* default, so an existing caller that only sets `variant` on `Sidebar`
|
|
66
|
+
* is unaffected.
|
|
67
|
+
*/
|
|
68
|
+
variant?: "sidebar" | "floating" | "inset";
|
|
69
|
+
/**
|
|
70
|
+
* Default `"app"` — today's behaviour, byte-identical. `"nested"` is for
|
|
71
|
+
* a `SidebarProvider` a compound component (e.g. `ContextRail`, ADR 0035
|
|
72
|
+
* §4) mounts internally so `useSidebar()` reports ITS OWN state to its
|
|
73
|
+
* own parts: it still provides `SidebarContext` and the two
|
|
74
|
+
* `--sidebar-width*` custom properties (which survive `display:
|
|
75
|
+
* contents`), but renders no frame box, no `data-slot`/`data-variant`/
|
|
76
|
+
* `data-state`, registers no global `⌘B`/`Ctrl+B` listener, and writes no
|
|
77
|
+
* `sidebar_state` cookie — so a nested rail can never be mistaken for a
|
|
78
|
+
* second app frame. See the emission table in ADR 0035 §4.
|
|
79
|
+
*/
|
|
80
|
+
frame?: "app" | "nested";
|
|
58
81
|
}
|
|
59
82
|
>(function SidebarProvider(
|
|
60
83
|
{
|
|
61
84
|
defaultOpen = true,
|
|
62
85
|
open: openProp,
|
|
63
86
|
onOpenChange: setOpenProp,
|
|
87
|
+
variant,
|
|
88
|
+
frame = "app",
|
|
64
89
|
className,
|
|
65
90
|
style,
|
|
66
91
|
children,
|
|
@@ -68,6 +93,7 @@ export const SidebarProvider = forwardRef<
|
|
|
68
93
|
},
|
|
69
94
|
ref,
|
|
70
95
|
) {
|
|
96
|
+
const isNested = frame === "nested";
|
|
71
97
|
const isMobile = useIsMobile();
|
|
72
98
|
const [openMobile, setOpenMobile] = useState(false);
|
|
73
99
|
|
|
@@ -78,11 +104,14 @@ export const SidebarProvider = forwardRef<
|
|
|
78
104
|
const openState = typeof value === "function" ? value(open) : value;
|
|
79
105
|
if (setOpenProp) setOpenProp(openState);
|
|
80
106
|
else _setOpen(openState);
|
|
81
|
-
|
|
107
|
+
// A nested provider (ADR 0035 §4) is not the app frame, so it must not
|
|
108
|
+
// persist ITS state as if it were — only `frame="app"` (the default)
|
|
109
|
+
// owns the `sidebar_state` cookie.
|
|
110
|
+
if (!isNested && typeof document !== "undefined") {
|
|
82
111
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
|
83
112
|
}
|
|
84
113
|
},
|
|
85
|
-
[setOpenProp, open],
|
|
114
|
+
[setOpenProp, open, isNested],
|
|
86
115
|
);
|
|
87
116
|
|
|
88
117
|
const toggleSidebar = useCallback(() => {
|
|
@@ -90,6 +119,10 @@ export const SidebarProvider = forwardRef<
|
|
|
90
119
|
}, [isMobile, setOpen]);
|
|
91
120
|
|
|
92
121
|
useEffect(() => {
|
|
122
|
+
// Same reasoning as the cookie write above: the global keyboard shortcut
|
|
123
|
+
// belongs to the ONE app frame, not to every nested provider a compound
|
|
124
|
+
// component happens to mount.
|
|
125
|
+
if (isNested) return;
|
|
93
126
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
94
127
|
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
|
95
128
|
event.preventDefault();
|
|
@@ -98,7 +131,7 @@ export const SidebarProvider = forwardRef<
|
|
|
98
131
|
};
|
|
99
132
|
window.addEventListener("keydown", handleKeyDown);
|
|
100
133
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
101
|
-
}, [toggleSidebar]);
|
|
134
|
+
}, [isNested, toggleSidebar]);
|
|
102
135
|
|
|
103
136
|
const state = open ? "expanded" : "collapsed";
|
|
104
137
|
|
|
@@ -112,9 +145,26 @@ export const SidebarProvider = forwardRef<
|
|
|
112
145
|
<TooltipProvider delayDuration={0}>
|
|
113
146
|
<div
|
|
114
147
|
ref={ref}
|
|
115
|
-
data-slot
|
|
148
|
+
// `data-slot`/`data-variant`/`data-state`/`group/sidebar-wrapper`
|
|
149
|
+
// (in the class list below) are ADR 0035 §8 refinement 3's
|
|
150
|
+
// `frame="app"` surface. `frame="nested"` (ADR 0035 §4, Task 9A)
|
|
151
|
+
// omits all four: a nested rail's own state must never drive the
|
|
152
|
+
// outer frame's geometry through the same group name or be mistaken
|
|
153
|
+
// for a second "sidebar wrapper" by a selector/test targeting the
|
|
154
|
+
// slot. The literal string "sidebar-wrapper" stays below (as a
|
|
155
|
+
// conditional value) so `pnpm data-slot:check` still sees this
|
|
156
|
+
// module's declaration.
|
|
157
|
+
data-slot={isNested ? undefined : "sidebar-wrapper"}
|
|
158
|
+
data-variant={isNested ? undefined : variant}
|
|
159
|
+
data-state={isNested ? undefined : state}
|
|
116
160
|
style={
|
|
117
161
|
{
|
|
162
|
+
// These two custom properties are the ONE thing a nested
|
|
163
|
+
// provider still emits (ADR 0035 §4) — they survive `display:
|
|
164
|
+
// contents` because custom properties inherit down the DOM
|
|
165
|
+
// tree regardless of the box an element generates, which is
|
|
166
|
+
// exactly how a nested rail publishes its width to whatever it
|
|
167
|
+
// wraps. Never move them onto a child.
|
|
118
168
|
"--sidebar-width": SIDEBAR_WIDTH,
|
|
119
169
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
|
120
170
|
...style,
|
|
@@ -131,7 +181,28 @@ export const SidebarProvider = forwardRef<
|
|
|
131
181
|
// for every element that inherits its colour (outline Buttons, list
|
|
132
182
|
// rows). See the chrome<canvas elevation invariant in
|
|
133
183
|
// .claude/rules/styling-and-tokens.md.
|
|
134
|
-
"
|
|
184
|
+
// Omitted under `frame="nested"` — a nested provider is a pure
|
|
185
|
+
// context + custom-property carrier, not a second frame box.
|
|
186
|
+
!isNested && "group/sidebar-wrapper flex min-h-svh w-full text-foreground",
|
|
187
|
+
// The frame's OWN `variant` is authoritative once set (ADR 0035 §8
|
|
188
|
+
// refinement 4): resolved in JS, not by a CSS descendant match, so
|
|
189
|
+
// a nested rail three levels down that happens to render
|
|
190
|
+
// `variant="inset"` can never repaint THIS frame's ground merely
|
|
191
|
+
// because `:has()` is depth-unlimited. `has-data-[variant=inset]`
|
|
192
|
+
// stays as the fallback ONLY while this provider's own `variant`
|
|
193
|
+
// is unset, which is exactly every existing caller (this prop
|
|
194
|
+
// didn't exist before #342) — so they render exactly as today.
|
|
195
|
+
// Also omitted under `frame="nested"`, same reasoning as above.
|
|
196
|
+
!isNested &&
|
|
197
|
+
(variant === undefined
|
|
198
|
+
? "has-data-[variant=inset]:bg-sidebar"
|
|
199
|
+
: variant === "inset" && "bg-sidebar"),
|
|
200
|
+
// `frame="nested"` renders no box of its own: `display: contents`
|
|
201
|
+
// (the literal Tailwind class, never an inline style or a
|
|
202
|
+
// concatenated name) removes this element from layout while
|
|
203
|
+
// keeping its children — and the custom properties above —
|
|
204
|
+
// reachable.
|
|
205
|
+
isNested && "contents",
|
|
135
206
|
className,
|
|
136
207
|
)}
|
|
137
208
|
{...props}
|
|
@@ -155,6 +226,7 @@ export const Sidebar = forwardRef<
|
|
|
155
226
|
ref,
|
|
156
227
|
) {
|
|
157
228
|
const { isMobile, openMobile, setOpenMobile, open, setOpen } = useSidebar();
|
|
229
|
+
const { t } = useLocale();
|
|
158
230
|
|
|
159
231
|
// The collapse mechanism (gap spacer + fixed slide) is the canonical
|
|
160
232
|
// useCollapsiblePanel hook (#190, research 09 §B.2) — Sidebar passes its
|
|
@@ -196,11 +268,11 @@ export const Sidebar = forwardRef<
|
|
|
196
268
|
data-slot="sidebar"
|
|
197
269
|
data-mobile="true"
|
|
198
270
|
side={side}
|
|
199
|
-
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
|
271
|
+
className="w-[var(--sidebar-width)] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
|
200
272
|
style={{ "--sidebar-width": SIDEBAR_WIDTH_MOBILE } as CSSProperties}
|
|
201
273
|
>
|
|
202
274
|
<SheetHeader className="sr-only">
|
|
203
|
-
<SheetTitle>
|
|
275
|
+
<SheetTitle>{t("ui.sidebar.title")}</SheetTitle>
|
|
204
276
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
|
205
277
|
</SheetHeader>
|
|
206
278
|
<div className="flex h-full w-full flex-col">{children}</div>
|
|
@@ -235,7 +307,16 @@ export const Sidebar = forwardRef<
|
|
|
235
307
|
panel.containerClassName,
|
|
236
308
|
variant === "floating" || variant === "inset"
|
|
237
309
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
|
238
|
-
:
|
|
310
|
+
: // `border-sidebar-border`, not the inherited canvas `--border`:
|
|
311
|
+
// this edge is CHROME, so it takes the chrome edge token — the
|
|
312
|
+
// same one the `floating` variant's inner surface and
|
|
313
|
+
// `SidebarSeparator` already use. Left to the canvas token it was
|
|
314
|
+
// a near-white hairline (light: L 0.88), invisible against the
|
|
315
|
+
// page but a bright line as soon as the rail sits on a
|
|
316
|
+
// `bg-sidebar` ground — which is exactly what an `inset` frame
|
|
317
|
+
// puts behind it, measured at the dashboard shell's right-hand
|
|
318
|
+
// `ContextRail`.
|
|
319
|
+
"group-data-[collapsible=icon]:w-(--sidebar-width-icon) border-sidebar-border group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
|
239
320
|
className,
|
|
240
321
|
)}
|
|
241
322
|
{...props}
|
|
@@ -254,7 +335,12 @@ export const Sidebar = forwardRef<
|
|
|
254
335
|
|
|
255
336
|
export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeof Button>>(
|
|
256
337
|
function SidebarTrigger({ className, onClick, ...props }, ref) {
|
|
257
|
-
const { toggleSidebar } = useSidebar();
|
|
338
|
+
const { isMobile, open, openMobile, toggleSidebar } = useSidebar();
|
|
339
|
+
// `toggleSidebar` flips `openMobile` below the mobile breakpoint and `open`
|
|
340
|
+
// above it, so the state this button EXPOSES has to be read the same way —
|
|
341
|
+
// reporting the desktop `open` on a mobile viewport would announce the
|
|
342
|
+
// opposite of what the button does.
|
|
343
|
+
const expanded = isMobile ? openMobile : open;
|
|
258
344
|
return (
|
|
259
345
|
<Button
|
|
260
346
|
ref={ref}
|
|
@@ -262,6 +348,18 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeo
|
|
|
262
348
|
data-slot="sidebar-trigger"
|
|
263
349
|
variant="ghost"
|
|
264
350
|
size="icon"
|
|
351
|
+
// A disclosure control must EXPOSE the state it toggles (WCAG 4.1.2).
|
|
352
|
+
// This button's whole accessible name is the static "Toggle Sidebar"
|
|
353
|
+
// below, so without this attribute nothing tells a screen-reader user
|
|
354
|
+
// whether the rail is currently open — and no axe rule catches it,
|
|
355
|
+
// because a <button> has no REQUIRED expanded state.
|
|
356
|
+
//
|
|
357
|
+
// `aria-controls` is deliberately omitted, not forgotten: on mobile the
|
|
358
|
+
// sidebar renders into a `Sheet` that is not in the document while
|
|
359
|
+
// closed, so the attribute would point at an absent id — worse than
|
|
360
|
+
// leaving it off. Spread last, so a caller that really does own a
|
|
361
|
+
// stable target can still supply both.
|
|
362
|
+
aria-expanded={expanded}
|
|
265
363
|
className={cn("size-7", className)}
|
|
266
364
|
onClick={(event) => {
|
|
267
365
|
onClick?.(event);
|
|
@@ -300,22 +398,124 @@ export const SidebarRail = forwardRef<HTMLButtonElement, ComponentProps<"button"
|
|
|
300
398
|
},
|
|
301
399
|
);
|
|
302
400
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
401
|
+
/**
|
|
402
|
+
* Which sides of the floating "inset" surface get a gutter, once an ancestor
|
|
403
|
+
* drives the treatment (`SidebarProvider variant="inset"`, or the legacy
|
|
404
|
+
* immediately-preceding `Sidebar variant="inset"`).
|
|
405
|
+
*
|
|
406
|
+
* - `"auto"` (default) — today's rule, unchanged: a gutter on every side
|
|
407
|
+
* except the leading edge (`m-2 ms-0`, because the classic layout puts the
|
|
408
|
+
* sidebar there), plus the leading edge's gutter returns (`ms-2`) once that
|
|
409
|
+
* sidebar collapses and its own gap closes.
|
|
410
|
+
* - `"none"` — no gutter margin (the unconditional radius/shadow below still
|
|
411
|
+
* apply).
|
|
412
|
+
* - An object — pick sides explicitly. `{ start: true, bottom: true }` is the
|
|
413
|
+
* "flush rail" geometry: a leading + bottom gutter only, no top, no
|
|
414
|
+
* trailing, because the trailing edge sits flush against a rail — a tab
|
|
415
|
+
* must touch the page it belongs to.
|
|
416
|
+
*
|
|
417
|
+
* PRECEDENCE (fix round 1, #342): a caller composing BOTH mechanisms at once
|
|
418
|
+
* — `SidebarProvider variant="inset"` AND a LEFT `Sidebar variant="inset"`,
|
|
419
|
+
* the shape a left-hand shell (e.g. the sidebar-02 rebuild) uses — never hits
|
|
420
|
+
* a class-order race, because the ancestor-scoped and legacy peer-scoped
|
|
421
|
+
* margin classes are BOTH derived from this same `gutter` value, so whenever
|
|
422
|
+
* both selectors match they emit identical declarations instead of competing
|
|
423
|
+
* ones; the resolved geometry is always exactly what `gutter` says, decided
|
|
424
|
+
* by this prop, never by the generated stylesheet's rule order.
|
|
425
|
+
*/
|
|
426
|
+
export type SidebarInsetGutter =
|
|
427
|
+
| "auto"
|
|
428
|
+
| "none"
|
|
429
|
+
| { top?: boolean; bottom?: boolean; start?: boolean; end?: boolean };
|
|
430
|
+
|
|
431
|
+
export interface SidebarInsetProps extends ComponentProps<"main"> {
|
|
432
|
+
gutter?: SidebarInsetGutter;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Each side is a COMPLETE literal utility string, one per SELECTOR SCOPE:
|
|
436
|
+
// `ancestor` (`group-data-…/sidebar-wrapper:`, reaches a `SidebarProvider
|
|
437
|
+
// variant="inset"` regardless of DOM order — the #342 fix) and `legacy`
|
|
438
|
+
// (`peer-data-…:`, reaches a `Sidebar variant="inset"` that immediately
|
|
439
|
+
// precedes this element — every caller before #342). Both scopes read the
|
|
440
|
+
// SAME `gutter` value below, which is what keeps them from ever disagreeing.
|
|
441
|
+
// Tailwind's content scanner only recognises literal class text in source —
|
|
442
|
+
// never a name assembled by concatenation/interpolation
|
|
443
|
+
// (.claude/rules/styling-and-tokens.md) — so the object form below picks
|
|
444
|
+
// among these literals, it never builds one.
|
|
445
|
+
const SIDEBAR_INSET_GUTTER_SIDE_CLASS = {
|
|
446
|
+
top: {
|
|
447
|
+
ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:mt-2",
|
|
448
|
+
legacy: "md:peer-data-[variant=inset]:mt-2",
|
|
317
449
|
},
|
|
318
|
-
|
|
450
|
+
bottom: {
|
|
451
|
+
ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:mb-2",
|
|
452
|
+
legacy: "md:peer-data-[variant=inset]:mb-2",
|
|
453
|
+
},
|
|
454
|
+
start: {
|
|
455
|
+
ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:ms-2",
|
|
456
|
+
legacy: "md:peer-data-[variant=inset]:ms-2",
|
|
457
|
+
},
|
|
458
|
+
end: {
|
|
459
|
+
ancestor: "md:group-data-[variant=inset]/sidebar-wrapper:me-2",
|
|
460
|
+
legacy: "md:peer-data-[variant=inset]:me-2",
|
|
461
|
+
},
|
|
462
|
+
} as const;
|
|
463
|
+
|
|
464
|
+
// "auto" is its own complete literal pair, not composed from the map above,
|
|
465
|
+
// because it also carries the collapsed-state clause: the sidebar's own gap
|
|
466
|
+
// closes on collapse, so the inset's leading margin has to come back.
|
|
467
|
+
const SIDEBAR_INSET_GUTTER_AUTO_CLASS = {
|
|
468
|
+
ancestor:
|
|
469
|
+
"md:group-data-[variant=inset]/sidebar-wrapper:m-2 md:group-data-[variant=inset]/sidebar-wrapper:ms-0 md:group-data-[variant=inset]/sidebar-wrapper:group-data-[state=collapsed]/sidebar-wrapper:ms-2",
|
|
470
|
+
legacy:
|
|
471
|
+
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2",
|
|
472
|
+
} as const;
|
|
473
|
+
|
|
474
|
+
function sidebarInsetGutterClassName(
|
|
475
|
+
gutter: SidebarInsetGutter,
|
|
476
|
+
scope: "ancestor" | "legacy",
|
|
477
|
+
): string {
|
|
478
|
+
if (gutter === "auto") return SIDEBAR_INSET_GUTTER_AUTO_CLASS[scope];
|
|
479
|
+
if (gutter === "none") return "";
|
|
480
|
+
return cn(
|
|
481
|
+
gutter.top && SIDEBAR_INSET_GUTTER_SIDE_CLASS.top[scope],
|
|
482
|
+
gutter.bottom && SIDEBAR_INSET_GUTTER_SIDE_CLASS.bottom[scope],
|
|
483
|
+
gutter.start && SIDEBAR_INSET_GUTTER_SIDE_CLASS.start[scope],
|
|
484
|
+
gutter.end && SIDEBAR_INSET_GUTTER_SIDE_CLASS.end[scope],
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export const SidebarInset = forwardRef<HTMLDivElement, SidebarInsetProps>(function SidebarInset(
|
|
489
|
+
{ className, gutter = "auto", ...props },
|
|
490
|
+
ref,
|
|
491
|
+
) {
|
|
492
|
+
return (
|
|
493
|
+
<main
|
|
494
|
+
ref={ref}
|
|
495
|
+
data-slot="sidebar-inset"
|
|
496
|
+
className={cn(
|
|
497
|
+
"relative flex w-full flex-1 flex-col bg-background",
|
|
498
|
+
// Ancestor-scoped (#342 fix): `group/sidebar-wrapper` spans the whole
|
|
499
|
+
// frame, so this reaches a right-hand or reordered `Sidebar` the old
|
|
500
|
+
// peer-* combinator could not (it only matches a sibling that comes
|
|
501
|
+
// AFTER). Reads `SidebarProvider`'s own `data-variant`/`data-state`.
|
|
502
|
+
// Radius/shadow are unconditional here — not gated by `gutter`.
|
|
503
|
+
"md:group-data-[variant=inset]/sidebar-wrapper:rounded-xl md:group-data-[variant=inset]/sidebar-wrapper:shadow-sm",
|
|
504
|
+
sidebarInsetGutterClassName(gutter, "ancestor"),
|
|
505
|
+
// Legacy peer rule — reaches a shell that sets `variant` only on
|
|
506
|
+
// `Sidebar` (every caller before #342). Radius/shadow unconditional
|
|
507
|
+
// here too; the margin is driven by the SAME `gutter` value as the
|
|
508
|
+
// ancestor rule above (fix round 1, #342), so a caller composing both
|
|
509
|
+
// mechanisms at once never hits a stylesheet-order race — whichever
|
|
510
|
+
// selector matches emits the identical declaration.
|
|
511
|
+
"md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
|
|
512
|
+
sidebarInsetGutterClassName(gutter, "legacy"),
|
|
513
|
+
className,
|
|
514
|
+
)}
|
|
515
|
+
{...props}
|
|
516
|
+
/>
|
|
517
|
+
);
|
|
518
|
+
});
|
|
319
519
|
|
|
320
520
|
export const SidebarInput = forwardRef<HTMLInputElement, ComponentProps<typeof Input>>(
|
|
321
521
|
function SidebarInput({ className, ...props }, ref) {
|
|
@@ -416,8 +616,8 @@ export const SidebarGroupLabel = forwardRef<
|
|
|
416
616
|
data-slot="sidebar-group-label"
|
|
417
617
|
data-sidebar="group-label"
|
|
418
618
|
className={cn(
|
|
419
|
-
"flex h-8 shrink-0 items-center rounded-md px-2 text-meta font-medium text-sidebar-muted-foreground
|
|
420
|
-
"group-data-[collapsible=icon]
|
|
619
|
+
"flex h-8 shrink-0 items-center rounded-md px-2 text-meta font-medium text-sidebar-muted-foreground focus-ring [--focus-ring-color:var(--sidebar-ring)] transition-[opacity] duration-base ease-linear [&>svg]:size-4 [&>svg]:shrink-0",
|
|
620
|
+
"group-data-[collapsible=icon]:hidden",
|
|
421
621
|
className,
|
|
422
622
|
)}
|
|
423
623
|
{...props}
|
|
@@ -436,7 +636,7 @@ export const SidebarGroupAction = forwardRef<
|
|
|
436
636
|
data-slot="sidebar-group-action"
|
|
437
637
|
data-sidebar="group-action"
|
|
438
638
|
className={cn(
|
|
439
|
-
"absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground
|
|
639
|
+
"absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground focus-ring [--focus-ring-color:var(--sidebar-ring)] transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden",
|
|
440
640
|
"group-data-[collapsible=icon]:hidden",
|
|
441
641
|
className,
|
|
442
642
|
)}
|
|
@@ -489,7 +689,7 @@ export const SidebarMenuItem = forwardRef<HTMLLIElement, ComponentProps<"li">>(
|
|
|
489
689
|
);
|
|
490
690
|
|
|
491
691
|
export const sidebarMenuButtonVariants = cva(
|
|
492
|
-
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-body text-sidebar-foreground
|
|
692
|
+
"peer/menu-button relative flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-body text-sidebar-foreground focus-ring [--focus-ring-color:var(--sidebar-ring)] transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary data-[active=true]:before:pointer-events-none data-[active=true]:before:absolute data-[active=true]:before:inset-y-1.5 data-[active=true]:before:start-0 data-[active=true]:before:w-1 data-[active=true]:before:rounded-full data-[active=true]:before:bg-sidebar-primary data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
|
493
693
|
{
|
|
494
694
|
variants: {
|
|
495
695
|
variant: {
|
|
@@ -573,7 +773,7 @@ export const SidebarMenuAction = forwardRef<
|
|
|
573
773
|
data-slot="sidebar-menu-action"
|
|
574
774
|
data-sidebar="menu-action"
|
|
575
775
|
className={cn(
|
|
576
|
-
"absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground
|
|
776
|
+
"absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground focus-ring [--focus-ring-color:var(--sidebar-ring)] transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden",
|
|
577
777
|
"group-data-[collapsible=icon]:hidden",
|
|
578
778
|
showOnHover &&
|
|
579
779
|
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
|
@@ -617,9 +817,21 @@ export function SidebarMenuSkeleton({
|
|
|
617
817
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
|
618
818
|
{...props}
|
|
619
819
|
>
|
|
620
|
-
{
|
|
820
|
+
{/* `bg-sidebar-accent`, overriding `Skeleton`'s own `bg-muted`. `--muted`
|
|
821
|
+
is a CANVAS token: in the light theme it is a near-white
|
|
822
|
+
`oklch(0.968 …)` sitting on this sidebar's dark `oklch(0.3 …)` ground,
|
|
823
|
+
which measures 12.42:1 — the placeholder becomes the loudest thing on
|
|
824
|
+
a screen that has nothing loaded yet. The sidebar's own quiet rung
|
|
825
|
+
measures 1.26:1 on light and 1.29:1 on dark, i.e. a placeholder in
|
|
826
|
+
both themes instead of an inversion in one. */}
|
|
827
|
+
{showIcon && (
|
|
828
|
+
<Skeleton
|
|
829
|
+
className="size-4 rounded-md bg-sidebar-accent"
|
|
830
|
+
data-sidebar="menu-skeleton-icon"
|
|
831
|
+
/>
|
|
832
|
+
)}
|
|
621
833
|
<Skeleton
|
|
622
|
-
className="h-4 max-w-(--skeleton-width) flex-1"
|
|
834
|
+
className="h-4 max-w-(--skeleton-width) flex-1 bg-sidebar-accent"
|
|
623
835
|
data-sidebar="menu-skeleton-text"
|
|
624
836
|
style={{ "--skeleton-width": width } as CSSProperties}
|
|
625
837
|
/>
|
|
@@ -675,8 +887,8 @@ export const SidebarMenuSubButton = forwardRef<
|
|
|
675
887
|
data-size={size}
|
|
676
888
|
data-active={isActive}
|
|
677
889
|
className={cn(
|
|
678
|
-
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground
|
|
679
|
-
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
|
890
|
+
"relative flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground focus-ring [--focus-ring-color:var(--sidebar-ring)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
|
891
|
+
"data-[active=true]:bg-sidebar-accent data-[active=true]:font-semibold data-[active=true]:text-sidebar-accent-foreground data-[active=true]:before:pointer-events-none data-[active=true]:before:absolute data-[active=true]:before:inset-y-1.5 data-[active=true]:before:start-0 data-[active=true]:before:w-1 data-[active=true]:before:rounded-full data-[active=true]:before:bg-sidebar-primary",
|
|
680
892
|
size === "sm" && "text-meta",
|
|
681
893
|
size === "md" && "text-body",
|
|
682
894
|
"group-data-[collapsible=icon]:hidden",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./skip-link";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, userEvent } from "storybook/test";
|
|
3
|
+
import { CommandTrigger } from "../command-trigger";
|
|
4
|
+
import { SkipLink } from "./skip-link";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Navigation/SkipLink",
|
|
8
|
+
component: SkipLink,
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'The first focusable element of an application. Invisible until it receives focus, then a pill in the top-start corner that jumps past the whole nav rail to the page\'s `<main>`. Give the target `id="main-content"` and `tabIndex={-1}` so focus lands there rather than merely scrolling.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
tags: ["autodocs"],
|
|
18
|
+
} satisfies Meta<typeof SkipLink>;
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
/** Press Tab: the link appears. It is the only way a keyboard user skips the rail. */
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
render: () => (
|
|
25
|
+
<div>
|
|
26
|
+
<SkipLink />
|
|
27
|
+
<nav aria-label="Primary" className="p-4 text-body text-muted-foreground">
|
|
28
|
+
nav links live here
|
|
29
|
+
</nav>
|
|
30
|
+
<main id="main-content" tabIndex={-1} className="p-4 text-body">
|
|
31
|
+
Main content
|
|
32
|
+
</main>
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
play: async ({ canvasElement }) => {
|
|
36
|
+
await userEvent.tab();
|
|
37
|
+
const link = canvasElement.querySelector('[data-slot="skip-link"]') as HTMLElement;
|
|
38
|
+
await expect(link).toHaveFocus();
|
|
39
|
+
// Not-sr-only when focused: it has real painted size.
|
|
40
|
+
await expect(link.getBoundingClientRect().width).toBeGreaterThan(40);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Finding 3 (task-11f-brief.md): the `Default` story above has no competing
|
|
46
|
+
* focusable furniture, so its own "the link comes first" assertion is
|
|
47
|
+
* vacuous — it would pass on a page with the link anywhere in the tab order,
|
|
48
|
+
* because there is nothing else to come before. This story composes the
|
|
49
|
+
* link inside a realistic shell — a `CommandTrigger` plus real nav
|
|
50
|
+
* `<a>`/`<button>` elements genuinely competing for first place — and a real
|
|
51
|
+
* `<main id="main-content" tabIndex={-1}>` target. It proves the tab-order
|
|
52
|
+
* claim for real: tabbing from a blurred page lands on the skip link ahead of
|
|
53
|
+
* every one of those competing controls. The second claim — that ACTIVATING
|
|
54
|
+
* the link moves real focus rather than merely scrolling — is only checked at
|
|
55
|
+
* the precondition level here (see the comment in the play function); this
|
|
56
|
+
* harness cannot exercise the activation itself.
|
|
57
|
+
*/
|
|
58
|
+
export const RealisticShell: Story = {
|
|
59
|
+
render: () => (
|
|
60
|
+
<div>
|
|
61
|
+
<SkipLink />
|
|
62
|
+
<header className="flex items-center justify-between gap-4 border-b border-border p-4">
|
|
63
|
+
<nav aria-label="Primary" className="flex items-center gap-4">
|
|
64
|
+
<a href="#dashboard" className="text-body text-foreground">
|
|
65
|
+
Dashboard
|
|
66
|
+
</a>
|
|
67
|
+
<a href="#reports" className="text-body text-foreground">
|
|
68
|
+
Reports
|
|
69
|
+
</a>
|
|
70
|
+
<button type="button" className="text-body text-foreground">
|
|
71
|
+
Settings
|
|
72
|
+
</button>
|
|
73
|
+
</nav>
|
|
74
|
+
<CommandTrigger />
|
|
75
|
+
</header>
|
|
76
|
+
<main id="main-content" tabIndex={-1} className="p-4 text-body">
|
|
77
|
+
Main content
|
|
78
|
+
</main>
|
|
79
|
+
</div>
|
|
80
|
+
),
|
|
81
|
+
play: async ({ canvasElement }) => {
|
|
82
|
+
// Start from a blurred page so Tab lands on whatever is genuinely FIRST
|
|
83
|
+
// in the DOM's tab order — not on whatever the previous story left
|
|
84
|
+
// focused.
|
|
85
|
+
(document.activeElement as HTMLElement | null)?.blur();
|
|
86
|
+
await userEvent.tab();
|
|
87
|
+
const link = canvasElement.querySelector('[data-slot="skip-link"]') as HTMLElement;
|
|
88
|
+
await expect(link).toHaveFocus();
|
|
89
|
+
|
|
90
|
+
// PRECONDITIONS only, not proof: activating the link really moving focus
|
|
91
|
+
// to the main landmark cannot be exercised in this harness — Playwright's
|
|
92
|
+
// pointer-driven `.click()` on a same-document `href="#…"` anchor makes
|
|
93
|
+
// this `@vitest/browser` composed-story runner tear down the page and
|
|
94
|
+
// report its own WebSocket connection as closed, even though the
|
|
95
|
+
// navigation itself is ordinary. So this locks the two things the
|
|
96
|
+
// focus-move mechanism depends on instead: the href fragment targets the
|
|
97
|
+
// right id, and that id is programmatically focusable.
|
|
98
|
+
const main = canvasElement.querySelector("#main-content") as HTMLElement;
|
|
99
|
+
await expect(link.getAttribute("href")).toBe(`#${main.id}`);
|
|
100
|
+
// Assert the ATTRIBUTE, not the `tabIndex` IDL property: for a
|
|
101
|
+
// non-interactive element the property getter returns -1 whether or not
|
|
102
|
+
// the attribute is present, so it cannot detect a missing attribute —
|
|
103
|
+
// the one thing this precondition exists to catch.
|
|
104
|
+
await expect(main.getAttribute("tabindex")).toBe("-1");
|
|
105
|
+
},
|
|
106
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { SkipLink } from "./skip-link";
|
|
4
|
+
|
|
5
|
+
describe("SkipLink", () => {
|
|
6
|
+
it("points at the main landmark and is reachable by keyboard", () => {
|
|
7
|
+
render(<SkipLink />);
|
|
8
|
+
const link = screen.getByRole("link", { name: "Skip to main content" });
|
|
9
|
+
expect(link).toHaveAttribute("href", "#main-content");
|
|
10
|
+
expect(link).not.toHaveAttribute("tabindex", "-1");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("accepts a different target", () => {
|
|
14
|
+
render(<SkipLink targetId="reading-pane">Skip to the message</SkipLink>);
|
|
15
|
+
expect(screen.getByRole("link", { name: "Skip to the message" })).toHaveAttribute(
|
|
16
|
+
"href",
|
|
17
|
+
"#reading-pane",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { forwardRef, type ComponentProps } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface SkipLinkProps extends ComponentProps<"a"> {
|
|
5
|
+
/** The id of the landmark to jump to. Defaults to "main-content". */
|
|
6
|
+
targetId?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The first focusable element of an application: invisible until focused, then a
|
|
11
|
+
* token-styled pill pinned to the top-start corner. Give the target element
|
|
12
|
+
* `id={targetId}` and `tabIndex={-1}` so focus actually lands there.
|
|
13
|
+
*/
|
|
14
|
+
export const SkipLink = forwardRef<HTMLAnchorElement, SkipLinkProps>(function SkipLink(
|
|
15
|
+
{ targetId = "main-content", className, children, ...props },
|
|
16
|
+
ref,
|
|
17
|
+
) {
|
|
18
|
+
return (
|
|
19
|
+
<a
|
|
20
|
+
ref={ref}
|
|
21
|
+
data-slot="skip-link"
|
|
22
|
+
href={`#${targetId}`}
|
|
23
|
+
className={cn(
|
|
24
|
+
"sr-only focus-visible:not-sr-only focus-visible:absolute focus-visible:start-4 focus-visible:top-4 focus-visible:z-50",
|
|
25
|
+
"focus-visible:rounded-md focus-visible:bg-card focus-visible:px-3 focus-visible:py-2 focus-visible:text-body focus-visible:text-foreground focus-visible:shadow-ring-md",
|
|
26
|
+
"focus-ring",
|
|
27
|
+
className,
|
|
28
|
+
)}
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
{children ?? "Skip to main content"}
|
|
32
|
+
</a>
|
|
33
|
+
);
|
|
34
|
+
});
|
|
@@ -124,7 +124,7 @@ export const Slider = forwardRef<ElementRef<typeof SliderPrimitive.Root>, Slider
|
|
|
124
124
|
aria-valuetext={ariaValueText}
|
|
125
125
|
{...safeThumbProps}
|
|
126
126
|
className={cn(
|
|
127
|
-
"block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-
|
|
127
|
+
"block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50",
|
|
128
128
|
safeThumbProps?.className,
|
|
129
129
|
)}
|
|
130
130
|
/>
|
|
@@ -4,7 +4,23 @@ import { SplitPanel } from "./split-panel";
|
|
|
4
4
|
const meta = {
|
|
5
5
|
title: "Layout/SplitPanel",
|
|
6
6
|
component: SplitPanel,
|
|
7
|
-
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "fullscreen",
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"A fixed two-pane layout with per-pane surface tones — panes the user can drag to " +
|
|
14
|
+
"resize are `Layout/Resizable`, see " +
|
|
15
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
16
|
+
'`SplitPanel` sizes the start pane with a CSS grid track (`startSize`, e.g. `"320px"`, ' +
|
|
17
|
+
'`"40%"`, `"1fr"`) rather than a percentage, so layout is predictable in tests and SSR ' +
|
|
18
|
+
"with no client-side measurement. The three-tone `plain`/`muted`/`card` ground-offset " +
|
|
19
|
+
"system on `startTone`/`endTone` is exported as `splitPaneVariants` — apply it to a " +
|
|
20
|
+
"`ResizablePanel` for the identical tiering on a draggable layout.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
8
24
|
} satisfies Meta<typeof SplitPanel>;
|
|
9
25
|
export default meta;
|
|
10
26
|
type Story = StoryObj<typeof meta>;
|
|
@@ -28,7 +44,7 @@ export const MasterDetail: Story = {
|
|
|
28
44
|
* the page itself is recessed below white; see research/structural-design 08 §H). A
|
|
29
45
|
* `muted` list well is a light-theme-only enhancement (in dark, `--surface-muted` reads
|
|
30
46
|
* lighter than `--card`), so this demo keeps the list `plain` to read correctly across
|
|
31
|
-
*
|
|
47
|
+
* every theme.
|
|
32
48
|
*/
|
|
33
49
|
export const Tiered: Story = {
|
|
34
50
|
render: () => (
|