@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
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SideDock — a *summoned* panel that docks to one edge of the content area:
|
|
5
|
+
* closed by default, opened by something the user does, resizable by
|
|
6
|
+
* pointer AND keyboard, and — below a viewport threshold — presented as an
|
|
7
|
+
* overlay `Sheet` instead of a column. See
|
|
8
|
+
* `docs/ADR/0035-context-rail-and-side-dock.md` §5. `ContextRail` (the
|
|
9
|
+
* sibling in this package) is a *persistent* rail with a 48px icon collapsed
|
|
10
|
+
* state; SideDock is not persistent and has no icon state.
|
|
11
|
+
*
|
|
12
|
+
* Built on the mandatory `useCollapsiblePanel` mechanism (open/close tween)
|
|
13
|
+
* and `useIsMobile(overlayBreakpoint)` (the column/overlay switch) — both
|
|
14
|
+
* shared with `ContextRail`, never re-implemented here.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
forwardRef,
|
|
18
|
+
useEffect,
|
|
19
|
+
useId,
|
|
20
|
+
useMemo,
|
|
21
|
+
useRef,
|
|
22
|
+
useState,
|
|
23
|
+
type ComponentProps,
|
|
24
|
+
type CSSProperties,
|
|
25
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
26
|
+
type PointerEvent as ReactPointerEvent,
|
|
27
|
+
type ReactNode,
|
|
28
|
+
type Ref,
|
|
29
|
+
} from "react";
|
|
30
|
+
import { X } from "lucide-react";
|
|
31
|
+
import { cn } from "../../lib/cn";
|
|
32
|
+
import { mergeRefs } from "../../lib/merge-refs";
|
|
33
|
+
import { useIsMobile } from "../../lib/use-mobile";
|
|
34
|
+
import { useCollapsiblePanel } from "../collapsible-panel";
|
|
35
|
+
import { useLocale } from "../locale-provider/locale-provider";
|
|
36
|
+
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../sheet";
|
|
37
|
+
|
|
38
|
+
export interface SideDockProps extends Omit<ComponentProps<"aside">, "title"> {
|
|
39
|
+
/**
|
|
40
|
+
* The dock's name. Rendered as its header heading, and as the `SheetTitle`
|
|
41
|
+
* of the overlay presentation. REQUIRED: the overlay cannot be accessible
|
|
42
|
+
* without it, and an optional prop with a generic default would ship a
|
|
43
|
+
* meaningless announced name into real apps.
|
|
44
|
+
*/
|
|
45
|
+
title: ReactNode;
|
|
46
|
+
/**
|
|
47
|
+
* Optional longer description. Visible under the title in the column
|
|
48
|
+
* presentation; `sr-only` in the overlay presentation (the compact sheet
|
|
49
|
+
* header has no room for it, but it still reaches assistive tech).
|
|
50
|
+
*/
|
|
51
|
+
description?: ReactNode;
|
|
52
|
+
/** Optional controls placed in the header row beside the built-in close button. */
|
|
53
|
+
headerActions?: ReactNode;
|
|
54
|
+
|
|
55
|
+
/** Controlled open state. Closed means zero width (content stays mounted, inert). */
|
|
56
|
+
open?: boolean;
|
|
57
|
+
/** Uncontrolled initial open state. Default `false` — a dock is summoned. */
|
|
58
|
+
defaultOpen?: boolean;
|
|
59
|
+
/** Called with the next open state. */
|
|
60
|
+
onOpenChange?: (open: boolean) => void;
|
|
61
|
+
|
|
62
|
+
/** Which edge it docks to. Default `"right"`. */
|
|
63
|
+
side?: "left" | "right";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `width`, `defaultWidth`, `minWidth`, `maxWidth`, `minContentWidth` and
|
|
67
|
+
* `resizable` (below) govern the COLUMN presentation only — viewport at or
|
|
68
|
+
* above `overlayBreakpoint`. Below that breakpoint the dock renders as an
|
|
69
|
+
* overlay `Sheet` sized by the viewport (`Sheet`'s own `w-3/4 max-w-sm`),
|
|
70
|
+
* so none of these props have any effect there.
|
|
71
|
+
*/
|
|
72
|
+
/** Controlled width in px. */
|
|
73
|
+
width?: number;
|
|
74
|
+
/** Uncontrolled initial width in px. Default `400`. */
|
|
75
|
+
defaultWidth?: number;
|
|
76
|
+
/** Fires continuously during a resize — drive layout from this. */
|
|
77
|
+
onWidthChange?: (width: number) => void;
|
|
78
|
+
/** Fires once when an interaction ends — PERSIST from this. */
|
|
79
|
+
onWidthCommit?: (width: number) => void;
|
|
80
|
+
|
|
81
|
+
/** Lower bound in px. Default `320`. */
|
|
82
|
+
minWidth?: number;
|
|
83
|
+
/** Upper bound in px. Default `640`. */
|
|
84
|
+
maxWidth?: number;
|
|
85
|
+
/** Content width preserved when clamping against the live viewport, px. Default `480`. */
|
|
86
|
+
minContentWidth?: number;
|
|
87
|
+
/** Whether the resize handle renders. Default `true`. */
|
|
88
|
+
resizable?: boolean;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Viewport width in px below which the dock renders as an overlay `Sheet`
|
|
92
|
+
* instead of a column. Default `1100`. Deliberately ABOVE the library's
|
|
93
|
+
* 768px mobile breakpoint: a 400px dock at 768px leaves ~360px of content.
|
|
94
|
+
* This is the switch point for the column-only sizing props above — the
|
|
95
|
+
* overlay presentation ignores them entirely.
|
|
96
|
+
*/
|
|
97
|
+
overlayBreakpoint?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DEFAULT_WIDTH = 400;
|
|
101
|
+
const DEFAULT_MIN_WIDTH = 320;
|
|
102
|
+
const DEFAULT_MAX_WIDTH = 640;
|
|
103
|
+
const DEFAULT_MIN_CONTENT_WIDTH = 480;
|
|
104
|
+
const DEFAULT_OVERLAY_BREAKPOINT = 1100;
|
|
105
|
+
// A single arrow-key step, and Shift's multiplier on it (Mechanism §3).
|
|
106
|
+
const KEY_STEP = 16;
|
|
107
|
+
const KEY_STEP_SHIFT_MULTIPLIER = 4;
|
|
108
|
+
// SSR-only fallback — "use client" means this never actually renders on a
|
|
109
|
+
// server, but a hook initializer still runs once during any non-browser
|
|
110
|
+
// render pass (e.g. a framework's SSR pass before hydration).
|
|
111
|
+
const FALLBACK_VIEWPORT_WIDTH = 1024;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The clamp, written out exactly as specified (Ruling 26 §3):
|
|
115
|
+
* `minWidth` wins last, so a hostile viewport can never render a
|
|
116
|
+
* sub-minimum dock.
|
|
117
|
+
*/
|
|
118
|
+
function clampWidth(
|
|
119
|
+
requested: number,
|
|
120
|
+
viewportWidth: number,
|
|
121
|
+
minWidth: number,
|
|
122
|
+
maxWidth: number,
|
|
123
|
+
minContentWidth: number,
|
|
124
|
+
): number {
|
|
125
|
+
const upperBound = Math.min(maxWidth, viewportWidth - minContentWidth);
|
|
126
|
+
return Math.max(minWidth, Math.min(requested, upperBound));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const SideDock = forwardRef<HTMLElement, SideDockProps>(function SideDock(
|
|
130
|
+
{
|
|
131
|
+
title,
|
|
132
|
+
description,
|
|
133
|
+
headerActions,
|
|
134
|
+
open,
|
|
135
|
+
defaultOpen = false,
|
|
136
|
+
onOpenChange,
|
|
137
|
+
side = "right",
|
|
138
|
+
width,
|
|
139
|
+
defaultWidth = DEFAULT_WIDTH,
|
|
140
|
+
onWidthChange,
|
|
141
|
+
onWidthCommit,
|
|
142
|
+
minWidth = DEFAULT_MIN_WIDTH,
|
|
143
|
+
maxWidth = DEFAULT_MAX_WIDTH,
|
|
144
|
+
minContentWidth = DEFAULT_MIN_CONTENT_WIDTH,
|
|
145
|
+
resizable = true,
|
|
146
|
+
overlayBreakpoint = DEFAULT_OVERLAY_BREAKPOINT,
|
|
147
|
+
className,
|
|
148
|
+
style,
|
|
149
|
+
children,
|
|
150
|
+
...props
|
|
151
|
+
},
|
|
152
|
+
ref,
|
|
153
|
+
) {
|
|
154
|
+
const { t, formatNumber } = useLocale();
|
|
155
|
+
const titleId = useId();
|
|
156
|
+
const resizeLabelId = useId();
|
|
157
|
+
|
|
158
|
+
const panel = useCollapsiblePanel({ side, open, defaultOpen, onOpenChange });
|
|
159
|
+
const isOverlay = useIsMobile(overlayBreakpoint);
|
|
160
|
+
|
|
161
|
+
// Focus restoration for the COLUMN presentation only (Finding 2,
|
|
162
|
+
// task-11f-brief.md). The overlay presentation is a Radix `Dialog` and
|
|
163
|
+
// already restores focus to its opener on close; the column `<aside>`
|
|
164
|
+
// has no such mechanism, and marking it `inert` while closed makes the
|
|
165
|
+
// browser's own HTML focus-fixup rule drop focus to `<body>` the instant
|
|
166
|
+
// a focused descendant becomes inert — the exact stranding this repairs
|
|
167
|
+
// (see `.claude/rules/interaction-guidelines.md`'s identical note about
|
|
168
|
+
// the native `disabled` attribute).
|
|
169
|
+
//
|
|
170
|
+
// `containerRef` is an internal mirror of the forwarded `ref` (merged
|
|
171
|
+
// below) so this effect can ask "was focus inside the dock?" regardless
|
|
172
|
+
// of what the caller's own `ref` points at. `groupRef` targets the outer
|
|
173
|
+
// wrapper, which never becomes `inert`, as the in-page fallback when the
|
|
174
|
+
// recorded opener is no longer usable.
|
|
175
|
+
const containerRef = useRef<HTMLElement | null>(null);
|
|
176
|
+
const mergedContainerRef = useMemo(() => mergeRefs<HTMLElement>(ref, containerRef), [ref]);
|
|
177
|
+
const groupRef = useRef<HTMLDivElement>(null);
|
|
178
|
+
const openerRef = useRef<Element | null>(null);
|
|
179
|
+
const wasOpenRef = useRef(panel.open);
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const wasOpen = wasOpenRef.current;
|
|
183
|
+
const isOpen = panel.open;
|
|
184
|
+
wasOpenRef.current = isOpen;
|
|
185
|
+
|
|
186
|
+
if (isOpen && !wasOpen) {
|
|
187
|
+
// Closed → open: remember whatever summoned the dock, so a later
|
|
188
|
+
// close can hand focus back to it.
|
|
189
|
+
openerRef.current = document.activeElement;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!isOpen && wasOpen) {
|
|
194
|
+
// Open → closed: only act if focus was actually inside the dock —
|
|
195
|
+
// never steal focus the user had already moved elsewhere.
|
|
196
|
+
const container = containerRef.current;
|
|
197
|
+
const active = document.activeElement;
|
|
198
|
+
const focusWasInside = container != null && active != null && container.contains(active);
|
|
199
|
+
if (!focusWasInside) return;
|
|
200
|
+
|
|
201
|
+
const opener = openerRef.current;
|
|
202
|
+
if (opener instanceof HTMLElement && opener.isConnected && opener !== document.body) {
|
|
203
|
+
opener.focus();
|
|
204
|
+
} else {
|
|
205
|
+
// The recorded opener unmounted (or there wasn't one) — land on
|
|
206
|
+
// the outer wrapper rather than let focus fall through to
|
|
207
|
+
// `<body>`. It never joins the tab order (`tabIndex={-1}`).
|
|
208
|
+
groupRef.current?.focus();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}, [panel.open]);
|
|
212
|
+
|
|
213
|
+
// The live viewport width the clamp reads — `useIsMobile` only exposes a
|
|
214
|
+
// boolean threshold, not the pixel value clamping needs.
|
|
215
|
+
const [viewportWidth, setViewportWidth] = useState<number>(() =>
|
|
216
|
+
typeof window !== "undefined" ? window.innerWidth : FALLBACK_VIEWPORT_WIDTH,
|
|
217
|
+
);
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const onResize = () => setViewportWidth(window.innerWidth);
|
|
220
|
+
window.addEventListener("resize", onResize);
|
|
221
|
+
return () => window.removeEventListener("resize", onResize);
|
|
222
|
+
}, []);
|
|
223
|
+
|
|
224
|
+
const isWidthControlled = width !== undefined;
|
|
225
|
+
const [uncontrolledWidth, setUncontrolledWidth] = useState(defaultWidth);
|
|
226
|
+
const requestedWidth = isWidthControlled ? (width as number) : uncontrolledWidth;
|
|
227
|
+
const upperBound = Math.min(maxWidth, viewportWidth - minContentWidth);
|
|
228
|
+
const clampedWidth = clampWidth(
|
|
229
|
+
requestedWidth,
|
|
230
|
+
viewportWidth,
|
|
231
|
+
minWidth,
|
|
232
|
+
maxWidth,
|
|
233
|
+
minContentWidth,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// A ref mirror of the currently-rendered clamped width, refreshed on every
|
|
237
|
+
// render (not via an effect) so the keyboard/pointer handlers below can
|
|
238
|
+
// read and update it synchronously between two native browser events —
|
|
239
|
+
// React may not have committed a re-render from the previous event yet.
|
|
240
|
+
const latestWidthRef = useRef(clampedWidth);
|
|
241
|
+
latestWidthRef.current = clampedWidth;
|
|
242
|
+
|
|
243
|
+
// Convergence (fixed contract decision): a CONTROLLED width outside the
|
|
244
|
+
// clamp renders the clamped number and reports it back once — on mount,
|
|
245
|
+
// and again whenever a viewport resize moves the bound. Deps are scoped to
|
|
246
|
+
// the VALUES only (not the callback props), so an inline
|
|
247
|
+
// onWidthChange/onWidthCommit from the caller can't re-trigger this on
|
|
248
|
+
// every render — it fires only when what it reports actually changes.
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!isWidthControlled) return;
|
|
251
|
+
if (clampedWidth === width) return;
|
|
252
|
+
onWidthChange?.(clampedWidth);
|
|
253
|
+
onWidthCommit?.(clampedWidth);
|
|
254
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
255
|
+
}, [isWidthControlled, clampedWidth, width]);
|
|
256
|
+
|
|
257
|
+
/** A no-op never emits (Ruling 26 §2). Returns whether it actually changed. */
|
|
258
|
+
function requestWidth(next: number): boolean {
|
|
259
|
+
if (next === latestWidthRef.current) return false;
|
|
260
|
+
latestWidthRef.current = next;
|
|
261
|
+
if (!isWidthControlled) setUncontrolledWidth(next);
|
|
262
|
+
onWidthChange?.(next);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Shared across the keyboard and pointer gestures below: did THIS gesture
|
|
267
|
+
// (the run of keydowns between focus and keyup, or the pointerdown-to-
|
|
268
|
+
// pointerup drag) actually change the value? `onWidthCommit` is a
|
|
269
|
+
// persistence trigger — firing it for a no-op gesture is noise.
|
|
270
|
+
const gestureChangedRef = useRef(false);
|
|
271
|
+
|
|
272
|
+
function handleResizeKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
|
273
|
+
// Physical, not logical/RTL-aware (Mechanism §3): on a right-hand dock
|
|
274
|
+
// ArrowLeft widens (the handle moves toward the content); a left-hand
|
|
275
|
+
// dock swaps the two.
|
|
276
|
+
const widenKey = side === "right" ? "ArrowLeft" : "ArrowRight";
|
|
277
|
+
const narrowKey = side === "right" ? "ArrowRight" : "ArrowLeft";
|
|
278
|
+
const step = event.shiftKey ? KEY_STEP * KEY_STEP_SHIFT_MULTIPLIER : KEY_STEP;
|
|
279
|
+
let requested: number | undefined;
|
|
280
|
+
if (event.key === widenKey) requested = latestWidthRef.current + step;
|
|
281
|
+
else if (event.key === narrowKey) requested = latestWidthRef.current - step;
|
|
282
|
+
else if (event.key === "Home") requested = minWidth;
|
|
283
|
+
else if (event.key === "End") requested = upperBound;
|
|
284
|
+
else return;
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
const next = clampWidth(requested, viewportWidth, minWidth, maxWidth, minContentWidth);
|
|
287
|
+
if (requestWidth(next)) gestureChangedRef.current = true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function handleResizeKeyUp() {
|
|
291
|
+
if (gestureChangedRef.current) onWidthCommit?.(latestWidthRef.current);
|
|
292
|
+
gestureChangedRef.current = false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const pointerStartRef = useRef<{ x: number; width: number } | null>(null);
|
|
296
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
297
|
+
|
|
298
|
+
function handleResizePointerDown(event: ReactPointerEvent<HTMLDivElement>) {
|
|
299
|
+
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
300
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
301
|
+
gestureChangedRef.current = false;
|
|
302
|
+
pointerStartRef.current = { x: event.clientX, width: latestWidthRef.current };
|
|
303
|
+
setIsDragging(true);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function handleResizePointerMove(event: ReactPointerEvent<HTMLDivElement>) {
|
|
307
|
+
const start = pointerStartRef.current;
|
|
308
|
+
if (!start) return;
|
|
309
|
+
const delta = event.clientX - start.x;
|
|
310
|
+
const signedDelta = side === "right" ? -delta : delta;
|
|
311
|
+
const next = clampWidth(
|
|
312
|
+
start.width + signedDelta,
|
|
313
|
+
viewportWidth,
|
|
314
|
+
minWidth,
|
|
315
|
+
maxWidth,
|
|
316
|
+
minContentWidth,
|
|
317
|
+
);
|
|
318
|
+
if (requestWidth(next)) gestureChangedRef.current = true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function endPointerGesture(event: ReactPointerEvent<HTMLDivElement>) {
|
|
322
|
+
if (!pointerStartRef.current) return;
|
|
323
|
+
pointerStartRef.current = null;
|
|
324
|
+
setIsDragging(false);
|
|
325
|
+
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
326
|
+
if (gestureChangedRef.current) onWidthCommit?.(latestWidthRef.current);
|
|
327
|
+
gestureChangedRef.current = false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The generic `close` key (messages.ts "Generic" section) — reused per the
|
|
331
|
+
// brief rather than minting a new one.
|
|
332
|
+
const closeLabel = t("close");
|
|
333
|
+
const resizeValueText = t("ui.sideDock.widthValue", {
|
|
334
|
+
count: Math.round(clampedWidth),
|
|
335
|
+
size: formatNumber(Math.round(clampedWidth)),
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const resizeHandle = resizable && (
|
|
339
|
+
<div
|
|
340
|
+
role="separator"
|
|
341
|
+
aria-orientation="vertical"
|
|
342
|
+
aria-labelledby={`${resizeLabelId} ${titleId}`}
|
|
343
|
+
aria-valuenow={Math.round(clampedWidth)}
|
|
344
|
+
aria-valuemin={minWidth}
|
|
345
|
+
aria-valuemax={Math.round(upperBound)}
|
|
346
|
+
aria-valuetext={resizeValueText}
|
|
347
|
+
tabIndex={0}
|
|
348
|
+
data-slot="side-dock-resize-handle"
|
|
349
|
+
onPointerDown={handleResizePointerDown}
|
|
350
|
+
onPointerMove={handleResizePointerMove}
|
|
351
|
+
onPointerUp={endPointerGesture}
|
|
352
|
+
onPointerCancel={endPointerGesture}
|
|
353
|
+
onKeyDown={handleResizeKeyDown}
|
|
354
|
+
onKeyUp={handleResizeKeyUp}
|
|
355
|
+
className={cn(
|
|
356
|
+
// A literal px hit box, not a `w-*` spacing utility — `--spacing` is
|
|
357
|
+
// what `data-density="compact"` rescales (Mechanism §3).
|
|
358
|
+
"absolute inset-y-0 w-[min(24px,50%)] cursor-col-resize touch-none select-none",
|
|
359
|
+
side === "right" ? "left-0" : "right-0",
|
|
360
|
+
"focus-visible:outline-none",
|
|
361
|
+
isDragging
|
|
362
|
+
? cn(
|
|
363
|
+
"after:absolute after:inset-y-0 after:w-1 after:bg-primary after:content-['']",
|
|
364
|
+
side === "right" ? "after:left-0" : "after:right-0",
|
|
365
|
+
)
|
|
366
|
+
: cn(
|
|
367
|
+
"after:absolute after:inset-y-0 after:w-px after:bg-muted-foreground after:content-['']",
|
|
368
|
+
"hover:after:w-1 focus-visible:after:w-1",
|
|
369
|
+
side === "right" ? "after:left-0" : "after:right-0",
|
|
370
|
+
),
|
|
371
|
+
"focus-visible:after:focus-ring-static",
|
|
372
|
+
)}
|
|
373
|
+
>
|
|
374
|
+
<span id={resizeLabelId} className="sr-only">
|
|
375
|
+
{t("ui.sideDock.resize")}
|
|
376
|
+
</span>
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
if (isOverlay) {
|
|
381
|
+
return (
|
|
382
|
+
<Sheet open={panel.open} onOpenChange={panel.setOpen}>
|
|
383
|
+
<SheetContent
|
|
384
|
+
// Runtime-safe: Radix's Content ref resolves to a plain
|
|
385
|
+
// HTMLDivElement, same as the column branch's <aside> ref target
|
|
386
|
+
// at the type level (both extend HTMLElement).
|
|
387
|
+
ref={ref as unknown as Ref<HTMLDivElement>}
|
|
388
|
+
side={side}
|
|
389
|
+
data-slot="side-dock"
|
|
390
|
+
className={cn("flex flex-col bg-card p-0", className)}
|
|
391
|
+
style={style}
|
|
392
|
+
// With no description the dialog is still named by its title, so
|
|
393
|
+
// opt out of `aria-describedby` explicitly rather than let Radix
|
|
394
|
+
// warn about a missing description (mirrors ExpandDialogContent).
|
|
395
|
+
{...(description ? {} : { "aria-describedby": undefined })}
|
|
396
|
+
{...props}
|
|
397
|
+
>
|
|
398
|
+
<SheetHeader
|
|
399
|
+
data-slot="side-dock-header"
|
|
400
|
+
className="flex-row items-start gap-2 space-y-0 border-b border-border px-4 py-3"
|
|
401
|
+
>
|
|
402
|
+
<div className="min-w-0 flex-1">
|
|
403
|
+
<SheetTitle data-slot="side-dock-title" className="text-title">
|
|
404
|
+
{title}
|
|
405
|
+
</SheetTitle>
|
|
406
|
+
</div>
|
|
407
|
+
{headerActions}
|
|
408
|
+
</SheetHeader>
|
|
409
|
+
{description && <SheetDescription className="sr-only">{description}</SheetDescription>}
|
|
410
|
+
<div
|
|
411
|
+
data-slot="side-dock-body"
|
|
412
|
+
// Focusable because it scrolls; `focus-ring-inset` because the Sheet
|
|
413
|
+
// panel clips anything drawn outside this box, and both layers of
|
|
414
|
+
// the plain rung are drawn outside it. A dock's children are
|
|
415
|
+
// caller-supplied and routinely have no focusable descendant (a
|
|
416
|
+
// change log, a read-only detail panel), so without a tab stop
|
|
417
|
+
// there is no keyboard route into the region once it overflows
|
|
418
|
+
// (WCAG 2.1.1, axe `scrollable-region-focusable`).
|
|
419
|
+
tabIndex={0}
|
|
420
|
+
className="min-h-0 flex-1 overflow-y-auto p-4 focus-ring-inset"
|
|
421
|
+
>
|
|
422
|
+
{children}
|
|
423
|
+
</div>
|
|
424
|
+
</SheetContent>
|
|
425
|
+
</Sheet>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
<div
|
|
431
|
+
data-slot="side-dock"
|
|
432
|
+
// `group`: the ancestor `group-data-[state=collapsed]:…` selectors in
|
|
433
|
+
// `panel.spacerClassName`/`panel.containerClassName` resolve against
|
|
434
|
+
// THIS element (Mechanism §1 — mirrors Sidebar's outer `group` div).
|
|
435
|
+
// `ref={groupRef}` + `tabIndex={-1}`: the focus-restoration fallback
|
|
436
|
+
// target (Finding 2) — this wrapper is never `inert`, unlike the
|
|
437
|
+
// `<aside>` below, so it is always a safe place to land focus. -1
|
|
438
|
+
// keeps it out of the normal tab order; it is only ever focused
|
|
439
|
+
// programmatically.
|
|
440
|
+
ref={groupRef}
|
|
441
|
+
tabIndex={-1}
|
|
442
|
+
className="group"
|
|
443
|
+
{...panel.attrs}
|
|
444
|
+
style={{ "--collapsible-panel-width": `${clampedWidth}px` } as CSSProperties}
|
|
445
|
+
>
|
|
446
|
+
<div data-slot="side-dock-spacer" className={panel.spacerClassName} />
|
|
447
|
+
<aside
|
|
448
|
+
ref={mergedContainerRef}
|
|
449
|
+
data-slot="side-dock-container"
|
|
450
|
+
aria-labelledby={titleId}
|
|
451
|
+
// The collapsed container stays mounted (for the closing transition)
|
|
452
|
+
// but stops being interactive/reachable (fixed contract decision).
|
|
453
|
+
inert={!panel.open}
|
|
454
|
+
className={cn(
|
|
455
|
+
panel.containerClassName,
|
|
456
|
+
"flex-col bg-card",
|
|
457
|
+
side === "right" ? "border-l" : "border-r",
|
|
458
|
+
className,
|
|
459
|
+
)}
|
|
460
|
+
style={style}
|
|
461
|
+
{...props}
|
|
462
|
+
>
|
|
463
|
+
<div
|
|
464
|
+
data-slot="side-dock-header"
|
|
465
|
+
className="flex items-start gap-2 border-b border-border px-4 py-3"
|
|
466
|
+
>
|
|
467
|
+
<div className="min-w-0 flex-1">
|
|
468
|
+
<div id={titleId} data-slot="side-dock-title" className="text-title">
|
|
469
|
+
{title}
|
|
470
|
+
</div>
|
|
471
|
+
{description && <div className="text-body text-muted-foreground">{description}</div>}
|
|
472
|
+
</div>
|
|
473
|
+
{headerActions}
|
|
474
|
+
<button
|
|
475
|
+
type="button"
|
|
476
|
+
data-slot="side-dock-close"
|
|
477
|
+
aria-label={closeLabel}
|
|
478
|
+
onClick={() => panel.setOpen(false)}
|
|
479
|
+
className="shrink-0 rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-ring"
|
|
480
|
+
>
|
|
481
|
+
<X className="size-4" aria-hidden="true" />
|
|
482
|
+
</button>
|
|
483
|
+
</div>
|
|
484
|
+
<div
|
|
485
|
+
data-slot="side-dock-body"
|
|
486
|
+
// Focusable because it scrolls; `focus-ring-inset` because the dock's
|
|
487
|
+
// own container clips anything drawn outside this box (the collapse
|
|
488
|
+
// tween animates its width). Worse here than in the overlay branch:
|
|
489
|
+
// the column is user-resizable, so narrowing it re-wraps every child
|
|
490
|
+
// taller — overflow is one keyboard gesture on the resize handle
|
|
491
|
+
// away, not a hypothetical viewport (WCAG 2.1.1, axe
|
|
492
|
+
// `scrollable-region-focusable`).
|
|
493
|
+
tabIndex={0}
|
|
494
|
+
className="min-h-0 flex-1 overflow-y-auto p-4 focus-ring-inset"
|
|
495
|
+
>
|
|
496
|
+
{children}
|
|
497
|
+
</div>
|
|
498
|
+
{resizeHandle}
|
|
499
|
+
</aside>
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
import { SidebarProvider, useSidebar } from "./sidebar";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Task 9A regression net for `SidebarProvider`'s `frame` prop (ADR 0035 §4).
|
|
8
|
+
* `frame="app"` (default, unset) must stay byte-identical to Task 8's
|
|
9
|
+
* behaviour; `frame="nested"` must suppress every row of the ADR's emission
|
|
10
|
+
* table except the two that survive `display: contents` — the CSS custom
|
|
11
|
+
* properties and the `SidebarContext` value.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function Probe() {
|
|
15
|
+
const { state, setOpen } = useSidebar();
|
|
16
|
+
return (
|
|
17
|
+
<div>
|
|
18
|
+
<span data-testid="state">{state}</span>
|
|
19
|
+
<button type="button" onClick={() => setOpen(true)}>
|
|
20
|
+
open
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clearSidebarCookie() {
|
|
27
|
+
document.cookie = "sidebar_state=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("SidebarProvider frame", () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
clearSidebarCookie();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("default (no frame prop) renders today's full frame surface", () => {
|
|
36
|
+
const { container } = render(<SidebarProvider>content</SidebarProvider>);
|
|
37
|
+
const wrapper = container.querySelector('[data-slot="sidebar-wrapper"]');
|
|
38
|
+
expect(wrapper).not.toBeNull();
|
|
39
|
+
expect(wrapper).toHaveAttribute("data-state", "expanded");
|
|
40
|
+
expect(wrapper?.className).toContain("group/sidebar-wrapper");
|
|
41
|
+
expect(wrapper?.className).toContain("flex");
|
|
42
|
+
expect(wrapper?.className).toContain("min-h-svh");
|
|
43
|
+
expect(wrapper?.className).toContain("w-full");
|
|
44
|
+
expect(wrapper?.className).toContain("text-foreground");
|
|
45
|
+
expect(wrapper?.className).toContain("has-data-[variant=inset]:bg-sidebar");
|
|
46
|
+
const style = (wrapper as HTMLElement).getAttribute("style") ?? "";
|
|
47
|
+
expect(style).toContain("--sidebar-width");
|
|
48
|
+
expect(style).toContain("--sidebar-width-icon");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('frame="app" is byte-identical to the default', () => {
|
|
52
|
+
const { container: defaultContainer } = render(<SidebarProvider>content</SidebarProvider>);
|
|
53
|
+
const { container: appContainer } = render(
|
|
54
|
+
<SidebarProvider frame="app">content</SidebarProvider>,
|
|
55
|
+
);
|
|
56
|
+
const defaultWrapper = defaultContainer.querySelector('[data-slot="sidebar-wrapper"]');
|
|
57
|
+
const appWrapper = appContainer.querySelector('[data-slot="sidebar-wrapper"]');
|
|
58
|
+
expect(appWrapper?.outerHTML).toBe(defaultWrapper?.outerHTML);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('frame="nested" emits none of the frame surface', () => {
|
|
62
|
+
const { container } = render(<SidebarProvider frame="nested">content</SidebarProvider>);
|
|
63
|
+
expect(container.querySelector('[data-slot="sidebar-wrapper"]')).toBeNull();
|
|
64
|
+
|
|
65
|
+
const root = container.firstElementChild as HTMLElement;
|
|
66
|
+
expect(root).not.toBeNull();
|
|
67
|
+
expect(root.className).not.toContain("group/sidebar-wrapper");
|
|
68
|
+
expect(root.className).not.toContain("min-h-svh");
|
|
69
|
+
expect(root.className).not.toContain("has-data-[variant=inset]");
|
|
70
|
+
expect(root.hasAttribute("data-variant")).toBe(false);
|
|
71
|
+
expect(root.hasAttribute("data-state")).toBe(false);
|
|
72
|
+
expect(root.className).toContain("contents");
|
|
73
|
+
const style = root.getAttribute("style") ?? "";
|
|
74
|
+
expect(style).toContain("--sidebar-width");
|
|
75
|
+
expect(style).toContain("--sidebar-width-icon");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('suppresses data-variant/bg-sidebar under frame="nested" even when variant is passed', () => {
|
|
79
|
+
const { container: nestedContainer } = render(
|
|
80
|
+
<SidebarProvider frame="nested" variant="inset">
|
|
81
|
+
content
|
|
82
|
+
</SidebarProvider>,
|
|
83
|
+
);
|
|
84
|
+
const nestedRoot = nestedContainer.firstElementChild as HTMLElement;
|
|
85
|
+
expect(nestedRoot.hasAttribute("data-variant")).toBe(false);
|
|
86
|
+
expect(nestedRoot.className).not.toContain("bg-sidebar");
|
|
87
|
+
|
|
88
|
+
const { container: appContainer } = render(
|
|
89
|
+
<SidebarProvider frame="app" variant="inset">
|
|
90
|
+
content
|
|
91
|
+
</SidebarProvider>,
|
|
92
|
+
);
|
|
93
|
+
const appWrapper = appContainer.querySelector('[data-slot="sidebar-wrapper"]') as HTMLElement;
|
|
94
|
+
expect(appWrapper).toHaveAttribute("data-variant", "inset");
|
|
95
|
+
expect(appWrapper.className).toContain("bg-sidebar");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('useSidebar() still works under frame="nested"', () => {
|
|
99
|
+
render(
|
|
100
|
+
<SidebarProvider frame="nested" defaultOpen={false}>
|
|
101
|
+
<Probe />
|
|
102
|
+
</SidebarProvider>,
|
|
103
|
+
);
|
|
104
|
+
expect(screen.getByTestId("state").textContent).toBe("collapsed");
|
|
105
|
+
fireEvent.click(screen.getByRole("button", { name: "open" }));
|
|
106
|
+
expect(screen.getByTestId("state").textContent).toBe("expanded");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('registers no global keyboard shortcut under frame="nested", but does under frame="app"', () => {
|
|
110
|
+
const nestedSpy = vi.fn();
|
|
111
|
+
render(
|
|
112
|
+
<SidebarProvider frame="nested" open={false} onOpenChange={nestedSpy}>
|
|
113
|
+
content
|
|
114
|
+
</SidebarProvider>,
|
|
115
|
+
);
|
|
116
|
+
fireEvent.keyDown(window, { key: "b", metaKey: true });
|
|
117
|
+
expect(nestedSpy).not.toHaveBeenCalled();
|
|
118
|
+
|
|
119
|
+
const appSpy = vi.fn();
|
|
120
|
+
render(
|
|
121
|
+
<SidebarProvider frame="app" open={false} onOpenChange={appSpy}>
|
|
122
|
+
content
|
|
123
|
+
</SidebarProvider>,
|
|
124
|
+
);
|
|
125
|
+
fireEvent.keyDown(window, { key: "b", metaKey: true });
|
|
126
|
+
expect(appSpy).toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('writes no sidebar_state cookie under frame="nested", but does under frame="app"', () => {
|
|
130
|
+
clearSidebarCookie();
|
|
131
|
+
render(
|
|
132
|
+
<SidebarProvider frame="nested">
|
|
133
|
+
<Probe />
|
|
134
|
+
</SidebarProvider>,
|
|
135
|
+
);
|
|
136
|
+
fireEvent.click(screen.getByRole("button", { name: "open" }));
|
|
137
|
+
expect(document.cookie).not.toContain("sidebar_state");
|
|
138
|
+
|
|
139
|
+
clearSidebarCookie();
|
|
140
|
+
render(
|
|
141
|
+
<SidebarProvider frame="app">
|
|
142
|
+
<Probe />
|
|
143
|
+
</SidebarProvider>,
|
|
144
|
+
);
|
|
145
|
+
const appButtons = screen.getAllByRole("button", { name: "open" });
|
|
146
|
+
fireEvent.click(appButtons[appButtons.length - 1]!);
|
|
147
|
+
expect(document.cookie).toContain("sidebar_state");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("renders exactly one sidebar-wrapper when a nested provider sits inside an app frame", () => {
|
|
151
|
+
const { container } = render(
|
|
152
|
+
<SidebarProvider>
|
|
153
|
+
<div>
|
|
154
|
+
<SidebarProvider frame="nested">
|
|
155
|
+
<span>rail</span>
|
|
156
|
+
</SidebarProvider>
|
|
157
|
+
</div>
|
|
158
|
+
</SidebarProvider>,
|
|
159
|
+
);
|
|
160
|
+
expect(container.querySelectorAll('[data-slot="sidebar-wrapper"]')).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
});
|