@kushagradhawan/kookie-ui 0.1.41 → 0.1.42
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 +257 -60
- package/components.css +386 -79
- package/dist/cjs/components/schemas/base-button.schema.d.ts +319 -0
- package/dist/cjs/components/schemas/base-button.schema.d.ts.map +1 -0
- package/dist/cjs/components/schemas/base-button.schema.js +2 -0
- package/dist/cjs/components/schemas/base-button.schema.js.map +7 -0
- package/dist/cjs/components/schemas/button.schema.d.ts +686 -0
- package/dist/cjs/components/schemas/button.schema.d.ts.map +1 -0
- package/dist/cjs/components/schemas/button.schema.js +2 -0
- package/dist/cjs/components/schemas/button.schema.js.map +7 -0
- package/dist/cjs/components/schemas/icon-button.schema.d.ts +329 -0
- package/dist/cjs/components/schemas/icon-button.schema.d.ts.map +1 -0
- package/dist/cjs/components/schemas/icon-button.schema.js +2 -0
- package/dist/cjs/components/schemas/icon-button.schema.js.map +7 -0
- package/dist/cjs/components/schemas/index.d.ts +52 -0
- package/dist/cjs/components/schemas/index.d.ts.map +1 -0
- package/dist/cjs/components/schemas/index.js +2 -0
- package/dist/cjs/components/schemas/index.js.map +7 -0
- package/dist/cjs/components/schemas/toggle-button.schema.d.ts +1172 -0
- package/dist/cjs/components/schemas/toggle-button.schema.d.ts.map +1 -0
- package/dist/cjs/components/schemas/toggle-button.schema.js +2 -0
- package/dist/cjs/components/schemas/toggle-button.schema.js.map +7 -0
- package/dist/cjs/components/schemas/toggle-icon-button.schema.d.ts +563 -0
- package/dist/cjs/components/schemas/toggle-icon-button.schema.d.ts.map +1 -0
- package/dist/cjs/components/schemas/toggle-icon-button.schema.js +2 -0
- package/dist/cjs/components/schemas/toggle-icon-button.schema.js.map +7 -0
- package/dist/cjs/components/sheet.d.ts +1 -1
- package/dist/cjs/components/sheet.d.ts.map +1 -1
- package/dist/cjs/components/sheet.js +1 -1
- package/dist/cjs/components/sheet.js.map +3 -3
- package/dist/cjs/components/shell.d.ts +125 -164
- package/dist/cjs/components/shell.d.ts.map +1 -1
- package/dist/cjs/components/shell.js +1 -1
- package/dist/cjs/components/shell.js.map +3 -3
- package/dist/cjs/components/sidebar.d.ts +1 -7
- package/dist/cjs/components/sidebar.d.ts.map +1 -1
- package/dist/cjs/components/sidebar.js +1 -1
- package/dist/cjs/components/sidebar.js.map +3 -3
- package/dist/cjs/components/theme.d.ts +3 -0
- package/dist/cjs/components/theme.d.ts.map +1 -1
- package/dist/cjs/components/theme.js +1 -1
- package/dist/cjs/components/theme.js.map +3 -3
- package/dist/cjs/components/theme.props.d.ts +10 -0
- package/dist/cjs/components/theme.props.d.ts.map +1 -1
- package/dist/cjs/components/theme.props.js +1 -1
- package/dist/cjs/components/theme.props.js.map +3 -3
- package/dist/cjs/helpers/font-config.d.ts +96 -0
- package/dist/cjs/helpers/font-config.d.ts.map +1 -0
- package/dist/cjs/helpers/font-config.js +3 -0
- package/dist/cjs/helpers/font-config.js.map +7 -0
- package/dist/cjs/helpers/index.d.ts +1 -0
- package/dist/cjs/helpers/index.d.ts.map +1 -1
- package/dist/cjs/helpers/index.js +1 -1
- package/dist/cjs/helpers/index.js.map +2 -2
- package/dist/esm/components/schemas/base-button.schema.d.ts +319 -0
- package/dist/esm/components/schemas/base-button.schema.d.ts.map +1 -0
- package/dist/esm/components/schemas/base-button.schema.js +2 -0
- package/dist/esm/components/schemas/base-button.schema.js.map +7 -0
- package/dist/esm/components/schemas/button.schema.d.ts +686 -0
- package/dist/esm/components/schemas/button.schema.d.ts.map +1 -0
- package/dist/esm/components/schemas/button.schema.js +2 -0
- package/dist/esm/components/schemas/button.schema.js.map +7 -0
- package/dist/esm/components/schemas/icon-button.schema.d.ts +329 -0
- package/dist/esm/components/schemas/icon-button.schema.d.ts.map +1 -0
- package/dist/esm/components/schemas/icon-button.schema.js +2 -0
- package/dist/esm/components/schemas/icon-button.schema.js.map +7 -0
- package/dist/esm/components/schemas/index.d.ts +52 -0
- package/dist/esm/components/schemas/index.d.ts.map +1 -0
- package/dist/esm/components/schemas/index.js +2 -0
- package/dist/esm/components/schemas/index.js.map +7 -0
- package/dist/esm/components/schemas/toggle-button.schema.d.ts +1172 -0
- package/dist/esm/components/schemas/toggle-button.schema.d.ts.map +1 -0
- package/dist/esm/components/schemas/toggle-button.schema.js +2 -0
- package/dist/esm/components/schemas/toggle-button.schema.js.map +7 -0
- package/dist/esm/components/schemas/toggle-icon-button.schema.d.ts +563 -0
- package/dist/esm/components/schemas/toggle-icon-button.schema.d.ts.map +1 -0
- package/dist/esm/components/schemas/toggle-icon-button.schema.js +2 -0
- package/dist/esm/components/schemas/toggle-icon-button.schema.js.map +7 -0
- package/dist/esm/components/sheet.d.ts +1 -1
- package/dist/esm/components/sheet.d.ts.map +1 -1
- package/dist/esm/components/sheet.js +1 -1
- package/dist/esm/components/sheet.js.map +3 -3
- package/dist/esm/components/shell.d.ts +125 -164
- package/dist/esm/components/shell.d.ts.map +1 -1
- package/dist/esm/components/shell.js +1 -1
- package/dist/esm/components/shell.js.map +3 -3
- package/dist/esm/components/sidebar.d.ts +1 -7
- package/dist/esm/components/sidebar.d.ts.map +1 -1
- package/dist/esm/components/sidebar.js +1 -1
- package/dist/esm/components/sidebar.js.map +3 -3
- package/dist/esm/components/theme.d.ts +3 -0
- package/dist/esm/components/theme.d.ts.map +1 -1
- package/dist/esm/components/theme.js +1 -1
- package/dist/esm/components/theme.js.map +3 -3
- package/dist/esm/components/theme.props.d.ts +10 -0
- package/dist/esm/components/theme.props.d.ts.map +1 -1
- package/dist/esm/components/theme.props.js +1 -1
- package/dist/esm/components/theme.props.js.map +3 -3
- package/dist/esm/helpers/font-config.d.ts +96 -0
- package/dist/esm/helpers/font-config.d.ts.map +1 -0
- package/dist/esm/helpers/font-config.js +3 -0
- package/dist/esm/helpers/font-config.js.map +7 -0
- package/dist/esm/helpers/index.d.ts +1 -0
- package/dist/esm/helpers/index.d.ts.map +1 -1
- package/dist/esm/helpers/index.js +1 -1
- package/dist/esm/helpers/index.js.map +2 -2
- package/package.json +23 -3
- package/schemas/base-button.d.ts +2 -0
- package/schemas/base-button.json +284 -0
- package/schemas/button.d.ts +2 -0
- package/schemas/button.json +535 -0
- package/schemas/icon-button.d.ts +2 -0
- package/schemas/icon-button.json +318 -0
- package/schemas/index.d.ts +2 -0
- package/schemas/index.json +2016 -0
- package/schemas/schemas.d.ts +29 -0
- package/schemas/toggle-button.d.ts +2 -0
- package/schemas/toggle-button.json +543 -0
- package/schemas/toggle-icon-button.d.ts +2 -0
- package/schemas/toggle-icon-button.json +326 -0
- package/schemas-json.d.ts +12 -0
- package/src/components/_internal/base-sidebar.css +1 -2
- package/src/components/schemas/base-button.schema.ts +339 -0
- package/src/components/schemas/button.schema.ts +198 -0
- package/src/components/schemas/icon-button.schema.ts +142 -0
- package/src/components/schemas/index.ts +68 -0
- package/src/components/schemas/toggle-button.schema.ts +122 -0
- package/src/components/schemas/toggle-icon-button.schema.ts +195 -0
- package/src/components/sheet.css +39 -19
- package/src/components/sheet.tsx +62 -3
- package/src/components/shell.css +510 -89
- package/src/components/shell.tsx +2055 -928
- package/src/components/sidebar.tsx +3 -22
- package/src/components/theme.props.tsx +8 -0
- package/src/components/theme.tsx +16 -0
- package/src/helpers/font-config.ts +167 -0
- package/src/helpers/index.ts +1 -0
- package/src/styles/fonts.css +16 -13
- package/src/styles/tokens/typography.css +27 -4
- package/styles.css +398 -79
- package/tokens/base.css +12 -0
- package/tokens.css +12 -0
package/src/components/shell.tsx
CHANGED
|
@@ -1,421 +1,724 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shell
|
|
2
|
+
* Shell Component - Layout Engine + Chrome
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Philosophy:
|
|
5
|
+
* - Shell = layout engine + chrome
|
|
6
|
+
* - Manages layout state: expanded/collapsed, fixed/overlay, sizes
|
|
7
|
+
* - Does not manage content/navigation state
|
|
8
|
+
* - Provides unstyled primitives (slots, triggers)
|
|
9
|
+
* - Enforces composition rules (Rail ↔ Panel dependency, Sidebar exclusivity)
|
|
6
10
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* Core Slots:
|
|
12
|
+
* - Header: global top bar
|
|
13
|
+
* - Rail: slim nav strip
|
|
14
|
+
* - Panel: sidebar next to rail
|
|
15
|
+
* - Sidebar: alternative to Rail+Panel (exclusive)
|
|
16
|
+
* - Content: main work area
|
|
17
|
+
* - Inspector: right-side panel
|
|
18
|
+
* - Bottom: bottom panel
|
|
12
19
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
* - Sticky panel intent in split mode (panel stays requested, visibility depends on rail open state)
|
|
19
|
-
* - Controlled/uncontrolled state for both split (rail value) and single-markup (view)
|
|
20
|
-
* - ARIA relationships and inert for hidden regions
|
|
21
|
-
* - CSS custom properties for sizing with sensible defaults
|
|
22
|
-
*
|
|
23
|
-
* Layout guidance:
|
|
24
|
-
* - Use flex/grid and spacing tokens; avoid margins in layout containers.
|
|
25
|
-
* - Width transitions for rail/panel are CSS-driven; content scroll is confined to Content only.
|
|
20
|
+
* Composition Rules:
|
|
21
|
+
* - Rail + Panel: valid together (Rail collapse → Panel collapse)
|
|
22
|
+
* - Sidebar: cannot coexist with Rail or Panel
|
|
23
|
+
* - Content: always required
|
|
24
|
+
* - Inspector/Bottom: optional, independent
|
|
26
25
|
*/
|
|
27
26
|
'use client';
|
|
28
27
|
|
|
29
28
|
import * as React from 'react';
|
|
30
29
|
import classNames from 'classnames';
|
|
31
|
-
|
|
32
|
-
import { IconButton } from './icon-button.js';
|
|
33
|
-
import { ChevronDownIcon } from './icons.js';
|
|
34
|
-
import { inert } from '../helpers/inert.js';
|
|
35
30
|
import * as Sheet from './sheet.js';
|
|
31
|
+
import { Inset } from './inset.js';
|
|
36
32
|
import { VisuallyHidden } from './visually-hidden.js';
|
|
37
|
-
import { ShellSidebarSectionContext } from './sidebar.js';
|
|
38
|
-
|
|
39
|
-
import type { ShellSidebarSectionContextValue } from './sidebar.js';
|
|
40
33
|
|
|
41
|
-
|
|
42
|
-
type
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
type
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
headerHeight: string;
|
|
62
|
-
zHeader?: number;
|
|
63
|
-
// Split pattern state (rail + panel)
|
|
64
|
-
railBySide: Record<ShellSide, RailValue>;
|
|
65
|
-
setRailBySide: (side: ShellSide, value: RailValue) => void;
|
|
66
|
-
toggleRail: (side: ShellSide) => void;
|
|
67
|
-
panelRequestedBySide: Record<ShellSide, boolean>;
|
|
68
|
-
setPanelRequestedBySide: (side: ShellSide, requested: boolean) => void;
|
|
69
|
-
// Pattern detection per side so triggers behave correctly
|
|
70
|
-
patternBySide: Record<ShellSide, 'single' | 'split'>;
|
|
71
|
-
setPatternForSide: (side: ShellSide, pattern: 'single' | 'split') => void;
|
|
72
|
-
// Single-markup morph state (panel/rail/collapsed)
|
|
73
|
-
singleViewBySide: Record<ShellSide, SingleView>;
|
|
74
|
-
setSingleViewBySide: (side: ShellSide, view: SingleView) => void;
|
|
75
|
-
cycleSingleView: (side: ShellSide) => void;
|
|
76
|
-
// Active tool coordination state
|
|
77
|
-
activeToolBySide: Record<ShellSide, string | null>;
|
|
78
|
-
setActiveTool: (side: ShellSide, tool: string | null) => void;
|
|
79
|
-
onItemSelected: (side: ShellSide, item: string) => void;
|
|
80
|
-
// Context (panel-level) coordination state
|
|
81
|
-
activeContextBySide: Record<ShellSide, string | null>;
|
|
82
|
-
setActiveContext: (side: ShellSide, context: string | null) => void;
|
|
83
|
-
getRegionId: (side: ShellSide) => string;
|
|
84
|
-
getPanelId: (side: ShellSide) => string;
|
|
85
|
-
getRailId: (side: ShellSide) => string;
|
|
34
|
+
// Types
|
|
35
|
+
type PresentationValue = 'fixed' | 'overlay' | 'stacked';
|
|
36
|
+
type ResponsivePresentation =
|
|
37
|
+
| PresentationValue
|
|
38
|
+
| Partial<Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', PresentationValue>>;
|
|
39
|
+
type PaneMode = 'expanded' | 'collapsed';
|
|
40
|
+
type SidebarMode = 'collapsed' | 'thin' | 'expanded';
|
|
41
|
+
type ResponsiveMode =
|
|
42
|
+
| PaneMode
|
|
43
|
+
| Partial<Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', PaneMode>>;
|
|
44
|
+
|
|
45
|
+
// Sidebar responsive mode (includes 'thin')
|
|
46
|
+
type ResponsiveSidebarMode =
|
|
47
|
+
| SidebarMode
|
|
48
|
+
| Partial<Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', SidebarMode>>;
|
|
49
|
+
|
|
50
|
+
// Persistence adapter for pane sizes
|
|
51
|
+
type PaneSizePersistence = {
|
|
52
|
+
load?: () => number | Promise<number | undefined> | undefined;
|
|
53
|
+
save?: (size: number) => void | Promise<void>;
|
|
86
54
|
};
|
|
87
55
|
|
|
56
|
+
// Breakpoint system
|
|
57
|
+
const BREAKPOINTS = {
|
|
58
|
+
xs: '(min-width: 520px)',
|
|
59
|
+
sm: '(min-width: 768px)',
|
|
60
|
+
md: '(min-width: 1024px)',
|
|
61
|
+
lg: '(min-width: 1280px)',
|
|
62
|
+
xl: '(min-width: 1640px)',
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
type Breakpoint = 'initial' | keyof typeof BREAKPOINTS;
|
|
66
|
+
|
|
67
|
+
// Shell context
|
|
68
|
+
interface ShellContextValue {
|
|
69
|
+
// Pane states
|
|
70
|
+
leftMode: PaneMode;
|
|
71
|
+
setLeftMode: (mode: PaneMode) => void;
|
|
72
|
+
panelMode: PaneMode; // Panel state within left container
|
|
73
|
+
setPanelMode: (mode: PaneMode) => void;
|
|
74
|
+
sidebarMode: SidebarMode;
|
|
75
|
+
setSidebarMode: (mode: SidebarMode) => void;
|
|
76
|
+
inspectorMode: PaneMode;
|
|
77
|
+
setInspectorMode: (mode: PaneMode) => void;
|
|
78
|
+
bottomMode: PaneMode;
|
|
79
|
+
setBottomMode: (mode: PaneMode) => void;
|
|
80
|
+
|
|
81
|
+
// Peek state (layout-only, ephemeral)
|
|
82
|
+
peekTarget: PaneTarget | null;
|
|
83
|
+
setPeekTarget: (target: PaneTarget | null) => void;
|
|
84
|
+
peekPane: (target: PaneTarget) => void;
|
|
85
|
+
clearPeek: () => void;
|
|
86
|
+
|
|
87
|
+
// Composition detection
|
|
88
|
+
hasLeft: boolean;
|
|
89
|
+
setHasLeft: (has: boolean) => void;
|
|
90
|
+
hasSidebar: boolean;
|
|
91
|
+
setHasSidebar: (has: boolean) => void;
|
|
92
|
+
|
|
93
|
+
// Presentation resolution
|
|
94
|
+
currentBreakpoint: Breakpoint;
|
|
95
|
+
currentBreakpointReady: boolean;
|
|
96
|
+
leftResolvedPresentation?: PresentationValue;
|
|
97
|
+
|
|
98
|
+
// Actions
|
|
99
|
+
togglePane: (target: PaneTarget) => void;
|
|
100
|
+
expandPane: (target: PaneTarget) => void;
|
|
101
|
+
collapsePane: (target: PaneTarget) => void;
|
|
102
|
+
// Toggle customization
|
|
103
|
+
setSidebarToggleComputer?: (fn: (current: SidebarMode) => SidebarMode) => void;
|
|
104
|
+
// Dev-only hooks for presentation warnings
|
|
105
|
+
onLeftPres?: (p: PresentationValue) => void;
|
|
106
|
+
// Sizing info for overlay grouping
|
|
107
|
+
onLeftDefaults?: (size: number) => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
const ShellContext = React.createContext<ShellContextValue | null>(null);
|
|
89
111
|
|
|
90
|
-
/** Access the shell context (internal wiring for subcomponents). */
|
|
91
112
|
function useShell() {
|
|
92
113
|
const ctx = React.useContext(ShellContext);
|
|
93
|
-
if (!ctx) throw new Error('Shell components must be used within <Shell.Root
|
|
114
|
+
if (!ctx) throw new Error('Shell components must be used within <Shell.Root>');
|
|
94
115
|
return ctx;
|
|
95
116
|
}
|
|
96
117
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
// Pane resize context for composed Handles
|
|
119
|
+
interface PaneResizeContextValue {
|
|
120
|
+
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
121
|
+
cssVarName: string;
|
|
122
|
+
minSize: number;
|
|
123
|
+
maxSize: number;
|
|
124
|
+
defaultSize: number;
|
|
125
|
+
orientation: 'vertical' | 'horizontal';
|
|
126
|
+
edge: 'start' | 'end';
|
|
127
|
+
computeNext: (clientPos: number, startClientPos: number, startSize: number) => number;
|
|
128
|
+
onResize?: (size: number) => void;
|
|
129
|
+
onResizeStart?: (size: number) => void;
|
|
130
|
+
onResizeEnd?: (size: number) => void;
|
|
131
|
+
// new features
|
|
132
|
+
target: PaneTarget;
|
|
133
|
+
collapsible: boolean;
|
|
134
|
+
snapPoints?: number[];
|
|
135
|
+
snapTolerance: number;
|
|
136
|
+
collapseThreshold?: number;
|
|
137
|
+
requestCollapse?: () => void;
|
|
138
|
+
requestToggle?: () => void;
|
|
105
139
|
}
|
|
106
140
|
|
|
107
|
-
|
|
108
|
-
type RailContextValue = {
|
|
109
|
-
onItemSelected: (item: string) => void;
|
|
110
|
-
};
|
|
111
|
-
const RailContext = React.createContext<RailContextValue | null>(null);
|
|
141
|
+
const PaneResizeContext = React.createContext<PaneResizeContextValue | null>(null);
|
|
112
142
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (!ctx) throw new Error('useRailEvents must be used within <Shell.Sidebar.Rail>');
|
|
143
|
+
function usePaneResize() {
|
|
144
|
+
const ctx = React.useContext(PaneResizeContext);
|
|
145
|
+
if (!ctx) throw new Error('Shell.Handle must be used within a resizable pane');
|
|
117
146
|
return ctx;
|
|
118
147
|
}
|
|
119
148
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
const PaneHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
|
|
150
|
+
({ className, children, ...props }, ref) => {
|
|
151
|
+
const {
|
|
152
|
+
containerRef,
|
|
153
|
+
cssVarName,
|
|
154
|
+
minSize,
|
|
155
|
+
maxSize,
|
|
156
|
+
defaultSize,
|
|
157
|
+
orientation,
|
|
158
|
+
edge,
|
|
159
|
+
computeNext,
|
|
160
|
+
onResize,
|
|
161
|
+
onResizeStart,
|
|
162
|
+
onResizeEnd,
|
|
163
|
+
snapPoints,
|
|
164
|
+
snapTolerance,
|
|
165
|
+
collapseThreshold,
|
|
166
|
+
collapsible,
|
|
167
|
+
target,
|
|
168
|
+
requestCollapse,
|
|
169
|
+
requestToggle,
|
|
170
|
+
} = usePaneResize();
|
|
171
|
+
|
|
172
|
+
// Track active drag cleanup to avoid leaking listeners if unmounted mid-drag
|
|
173
|
+
const activeCleanupRef = React.useRef<(() => void) | null>(null);
|
|
174
|
+
React.useEffect(
|
|
175
|
+
() => () => {
|
|
176
|
+
// Cleanup any in-flight drag on unmount
|
|
177
|
+
try {
|
|
178
|
+
activeCleanupRef.current?.();
|
|
179
|
+
} catch {}
|
|
180
|
+
activeCleanupRef.current = null;
|
|
181
|
+
},
|
|
182
|
+
[],
|
|
183
|
+
);
|
|
126
184
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
185
|
+
const ariaOrientation = orientation;
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
{...props}
|
|
190
|
+
ref={ref}
|
|
191
|
+
className={classNames('rt-ShellResizer', className)}
|
|
192
|
+
data-orientation={orientation}
|
|
193
|
+
data-edge={edge}
|
|
194
|
+
role="slider"
|
|
195
|
+
aria-orientation={ariaOrientation}
|
|
196
|
+
aria-valuemin={minSize}
|
|
197
|
+
aria-valuemax={maxSize}
|
|
198
|
+
aria-valuenow={defaultSize}
|
|
199
|
+
tabIndex={0}
|
|
200
|
+
onPointerDown={(e) => {
|
|
201
|
+
if (!containerRef.current) return;
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
const container = containerRef.current;
|
|
204
|
+
const handleEl = e.currentTarget as HTMLElement;
|
|
205
|
+
const pointerId = e.pointerId;
|
|
206
|
+
// If a previous drag didn't finalize properly, force cleanup first
|
|
207
|
+
try {
|
|
208
|
+
activeCleanupRef.current?.();
|
|
209
|
+
} catch {}
|
|
210
|
+
container.setAttribute('data-resizing', '');
|
|
211
|
+
try {
|
|
212
|
+
handleEl.setPointerCapture(pointerId);
|
|
213
|
+
} catch {}
|
|
214
|
+
const startClient = orientation === 'vertical' ? e.clientX : e.clientY;
|
|
215
|
+
const startSize = parseFloat(
|
|
216
|
+
getComputedStyle(container).getPropertyValue(cssVarName) || `${defaultSize}`,
|
|
217
|
+
);
|
|
218
|
+
const clamp = (v: number) => Math.min(Math.max(v, minSize), maxSize);
|
|
219
|
+
const body = document.body;
|
|
220
|
+
const prevCursor = body.style.cursor;
|
|
221
|
+
const prevUserSelect = body.style.userSelect;
|
|
222
|
+
body.style.cursor = orientation === 'vertical' ? 'col-resize' : 'row-resize';
|
|
223
|
+
body.style.userSelect = 'none';
|
|
224
|
+
onResizeStart?.(startSize);
|
|
225
|
+
const handleMove = (ev: PointerEvent) => {
|
|
226
|
+
const client = orientation === 'vertical' ? ev.clientX : ev.clientY;
|
|
227
|
+
const next = clamp(computeNext(client, startClient, startSize));
|
|
228
|
+
container.style.setProperty(cssVarName, `${next}px`);
|
|
229
|
+
handleEl.setAttribute('aria-valuenow', String(next));
|
|
230
|
+
onResize?.(next);
|
|
231
|
+
};
|
|
232
|
+
const cleanup = () => {
|
|
233
|
+
try {
|
|
234
|
+
handleEl.releasePointerCapture(pointerId);
|
|
235
|
+
} catch {}
|
|
236
|
+
window.removeEventListener('pointermove', handleMove as any);
|
|
237
|
+
window.removeEventListener('pointerup', handleUp as any);
|
|
238
|
+
window.removeEventListener('pointercancel', handleUp as any);
|
|
239
|
+
window.removeEventListener('keydown', handleKey as any);
|
|
240
|
+
handleEl.removeEventListener('lostpointercapture', handleUp as any);
|
|
241
|
+
container.removeAttribute('data-resizing');
|
|
242
|
+
body.style.cursor = prevCursor;
|
|
243
|
+
body.style.userSelect = prevUserSelect;
|
|
244
|
+
activeCleanupRef.current = null;
|
|
245
|
+
};
|
|
246
|
+
const handleUp = () => {
|
|
247
|
+
const finalSize = parseFloat(
|
|
248
|
+
getComputedStyle(container).getPropertyValue(cssVarName) || `${defaultSize}`,
|
|
249
|
+
);
|
|
250
|
+
// snap logic
|
|
251
|
+
let snapped = finalSize;
|
|
252
|
+
if (snapPoints && snapPoints.length) {
|
|
253
|
+
const nearest = snapPoints.reduce(
|
|
254
|
+
(acc, p) => (Math.abs(p - finalSize) < Math.abs(acc - finalSize) ? p : acc),
|
|
255
|
+
snapPoints[0],
|
|
256
|
+
);
|
|
257
|
+
if (Math.abs(nearest - finalSize) <= (snapTolerance ?? 8)) {
|
|
258
|
+
snapped = nearest;
|
|
259
|
+
container.style.setProperty(cssVarName, `${snapped}px`);
|
|
260
|
+
handleEl.setAttribute('aria-valuenow', String(snapped));
|
|
261
|
+
onResize?.(snapped);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// collapse threshold
|
|
265
|
+
if (
|
|
266
|
+
collapsible &&
|
|
267
|
+
typeof collapseThreshold === 'number' &&
|
|
268
|
+
finalSize <= collapseThreshold
|
|
269
|
+
) {
|
|
270
|
+
requestCollapse?.();
|
|
271
|
+
}
|
|
272
|
+
onResizeEnd?.(snapped);
|
|
273
|
+
cleanup();
|
|
274
|
+
};
|
|
275
|
+
const handleKey = (kev: KeyboardEvent) => {
|
|
276
|
+
if (kev.key === 'Escape') {
|
|
277
|
+
// cancel to start size
|
|
278
|
+
container.style.setProperty(cssVarName, `${startSize}px`);
|
|
279
|
+
handleEl.setAttribute('aria-valuenow', String(startSize));
|
|
280
|
+
onResizeEnd?.(startSize);
|
|
281
|
+
cleanup();
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
window.addEventListener('pointermove', handleMove as any);
|
|
285
|
+
window.addEventListener('pointerup', handleUp as any);
|
|
286
|
+
window.addEventListener('pointercancel', handleUp as any);
|
|
287
|
+
window.addEventListener('keydown', handleKey as any);
|
|
288
|
+
handleEl.addEventListener('lostpointercapture', handleUp as any);
|
|
289
|
+
// Store cleanup so unmounts or re-entries can clean up properly
|
|
290
|
+
activeCleanupRef.current = cleanup;
|
|
291
|
+
}}
|
|
292
|
+
onDoubleClick={() => {
|
|
293
|
+
if (collapsible) requestToggle?.();
|
|
294
|
+
}}
|
|
295
|
+
onKeyDown={(e) => {
|
|
296
|
+
if (!containerRef.current) return;
|
|
297
|
+
const container = containerRef.current;
|
|
298
|
+
const current = parseFloat(
|
|
299
|
+
getComputedStyle(container).getPropertyValue(cssVarName) || `${defaultSize}`,
|
|
300
|
+
);
|
|
301
|
+
const clamp = (v: number) => Math.min(Math.max(v, minSize), maxSize);
|
|
302
|
+
const step = e.shiftKey ? 32 : 8;
|
|
303
|
+
let delta = 0;
|
|
304
|
+
if (orientation === 'vertical') {
|
|
305
|
+
if (e.key === 'ArrowRight') delta = step;
|
|
306
|
+
else if (e.key === 'ArrowLeft') delta = -step;
|
|
307
|
+
} else {
|
|
308
|
+
if (e.key === 'ArrowDown') delta = step;
|
|
309
|
+
else if (e.key === 'ArrowUp') delta = -step;
|
|
310
|
+
}
|
|
311
|
+
if (e.key === 'Home') {
|
|
312
|
+
e.preventDefault();
|
|
313
|
+
onResizeStart?.(current);
|
|
314
|
+
const next = clamp(minSize);
|
|
315
|
+
container.style.setProperty(cssVarName, `${next}px`);
|
|
316
|
+
(e.currentTarget as HTMLElement).setAttribute('aria-valuenow', String(next));
|
|
317
|
+
onResize?.(next);
|
|
318
|
+
onResizeEnd?.(next);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (e.key === 'End') {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
onResizeStart?.(current);
|
|
324
|
+
const next = clamp(maxSize);
|
|
325
|
+
container.style.setProperty(cssVarName, `${next}px`);
|
|
326
|
+
(e.currentTarget as HTMLElement).setAttribute('aria-valuenow', String(next));
|
|
327
|
+
onResize?.(next);
|
|
328
|
+
onResizeEnd?.(next);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (delta !== 0) {
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
onResizeStart?.(current);
|
|
334
|
+
// approximate computeNext with delta from keyboard
|
|
335
|
+
const next = clamp(
|
|
336
|
+
current + (edge === 'start' && orientation === 'vertical' ? -delta : delta),
|
|
337
|
+
);
|
|
338
|
+
container.style.setProperty(cssVarName, `${next}px`);
|
|
339
|
+
(e.currentTarget as HTMLElement).setAttribute('aria-valuenow', String(next));
|
|
340
|
+
onResize?.(next);
|
|
341
|
+
onResizeEnd?.(next);
|
|
342
|
+
}
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
{children}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
PaneHandle.displayName = 'Shell.Handle';
|
|
351
|
+
|
|
352
|
+
// Composed Handle wrappers per pane
|
|
353
|
+
const PanelHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
|
|
354
|
+
(props, ref) => <PaneHandle {...props} ref={ref} />,
|
|
355
|
+
);
|
|
356
|
+
PanelHandle.displayName = 'Shell.Panel.Handle';
|
|
357
|
+
|
|
358
|
+
const SidebarHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
|
|
359
|
+
(props, ref) => <PaneHandle {...props} ref={ref} />,
|
|
360
|
+
);
|
|
361
|
+
SidebarHandle.displayName = 'Shell.Sidebar.Handle';
|
|
362
|
+
|
|
363
|
+
const InspectorHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
|
|
364
|
+
(props, ref) => <PaneHandle {...props} ref={ref} />,
|
|
365
|
+
);
|
|
366
|
+
InspectorHandle.displayName = 'Shell.Inspector.Handle';
|
|
367
|
+
|
|
368
|
+
const BottomHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
|
|
369
|
+
(props, ref) => <PaneHandle {...props} ref={ref} />,
|
|
370
|
+
);
|
|
371
|
+
BottomHandle.displayName = 'Shell.Bottom.Handle';
|
|
372
|
+
|
|
373
|
+
// Hook to resolve responsive presentation
|
|
374
|
+
function useResponsivePresentation(presentation: ResponsivePresentation): PresentationValue {
|
|
375
|
+
const { currentBreakpoint } = useShell();
|
|
376
|
+
|
|
377
|
+
return React.useMemo(() => {
|
|
378
|
+
if (typeof presentation === 'string') {
|
|
379
|
+
return presentation;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Try current breakpoint first
|
|
383
|
+
if (presentation[currentBreakpoint]) {
|
|
384
|
+
return presentation[currentBreakpoint]!;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Cascade down to smaller breakpoints based on configured BREAKPOINTS
|
|
388
|
+
const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
|
|
389
|
+
const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat(
|
|
390
|
+
'initial' as Breakpoint,
|
|
391
|
+
);
|
|
392
|
+
const startIdx = order.indexOf(currentBreakpoint as Breakpoint);
|
|
393
|
+
|
|
394
|
+
for (let i = startIdx + 1; i < order.length; i++) {
|
|
395
|
+
const bp = order[i];
|
|
396
|
+
if (presentation[bp]) {
|
|
397
|
+
return presentation[bp]!;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return 'fixed'; // Default fallback
|
|
402
|
+
}, [presentation, currentBreakpoint]);
|
|
132
403
|
}
|
|
133
404
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
405
|
+
// Hook to resolve responsive mode defaults
|
|
406
|
+
// Removed: defaultMode responsiveness
|
|
407
|
+
|
|
408
|
+
// Hook to get current breakpoint
|
|
409
|
+
function useBreakpoint(): { bp: Breakpoint; ready: boolean } {
|
|
410
|
+
const [currentBp, setCurrentBp] = React.useState<Breakpoint>('initial');
|
|
411
|
+
const [ready, setReady] = React.useState(false);
|
|
412
|
+
|
|
413
|
+
React.useEffect(() => {
|
|
414
|
+
if (typeof window === 'undefined') return;
|
|
415
|
+
|
|
416
|
+
const queries: [key: keyof typeof BREAKPOINTS, query: string][] = Object.entries(
|
|
417
|
+
BREAKPOINTS,
|
|
418
|
+
) as any;
|
|
419
|
+
const mqls = queries.map(([k, q]) => [k, window.matchMedia(q)] as const);
|
|
420
|
+
|
|
421
|
+
const compute = () => {
|
|
422
|
+
// Highest matched wins
|
|
423
|
+
const matched = mqls.filter(([, m]) => m.matches).map(([k]) => k);
|
|
424
|
+
const next = (matched[matched.length - 1] as Breakpoint | undefined) ?? 'initial';
|
|
425
|
+
setCurrentBp(next);
|
|
426
|
+
setReady(true);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
compute();
|
|
430
|
+
mqls.forEach(([, m]) => m.addEventListener('change', compute));
|
|
431
|
+
|
|
432
|
+
return () => {
|
|
433
|
+
mqls.forEach(([, m]) => m.removeEventListener('change', compute));
|
|
434
|
+
};
|
|
435
|
+
}, []);
|
|
436
|
+
|
|
437
|
+
return { bp: currentBp, ready };
|
|
142
438
|
}
|
|
143
439
|
|
|
144
|
-
// Root
|
|
145
|
-
/**
|
|
146
|
-
* Props for `Shell.Root`.
|
|
147
|
-
*
|
|
148
|
-
* - `minContentWidth`: CSS length to enforce a minimum inline-size for content
|
|
149
|
-
* area. This prevents content from collapsing too far when sidebars are open.
|
|
150
|
-
* - `rtl`: Force RTL/LTR independent of document; otherwise derived from root.
|
|
151
|
-
* - `headerHeight`: Sticky header block-size.
|
|
152
|
-
* - `zHeader`: z-index for sticky header stacking.
|
|
153
|
-
*/
|
|
440
|
+
// Root Component
|
|
154
441
|
interface ShellRootProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
headerHeight?: string;
|
|
158
|
-
zHeader?: number;
|
|
159
|
-
cascadeSide?: ShellSide;
|
|
160
|
-
activeTool?: string | null;
|
|
161
|
-
onToolChange?: (id: string | null) => void;
|
|
162
|
-
activeContext?: string | null;
|
|
163
|
-
onContextChange?: (id: string | null) => void;
|
|
164
|
-
/** Custom cycling order for single-markup sidebars. Defaults to ['panel', 'rail', 'collapsed'] */
|
|
165
|
-
singleViewCycle?: SingleView[];
|
|
442
|
+
children: React.ReactNode;
|
|
443
|
+
height?: 'full' | 'auto' | string | number;
|
|
166
444
|
}
|
|
167
445
|
|
|
168
446
|
const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(
|
|
169
|
-
(
|
|
170
|
-
{
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const computedDir = React.useMemo<ShellDirection>(() => {
|
|
190
|
-
if (typeof rtl === 'boolean') return rtl ? 'rtl' : 'ltr';
|
|
191
|
-
return getDocumentDirection();
|
|
192
|
-
}, [rtl]);
|
|
193
|
-
|
|
194
|
-
// === Split-mode and single-markup state model ===
|
|
195
|
-
// In split-mode, a sidebar has both Rail and Panel slots. We track:
|
|
196
|
-
// - rail open/collapsed state per side
|
|
197
|
-
// - a sticky panel intent per side (requested or not)
|
|
198
|
-
// In single-markup, we track a single `view` morphing between panel/rail/collapsed
|
|
199
|
-
const [railBySide, setRailBySideState] = React.useState<Record<ShellSide, RailValue>>({
|
|
200
|
-
start: 'open',
|
|
201
|
-
end: 'collapsed',
|
|
202
|
-
});
|
|
203
|
-
const setRailBySide = React.useCallback((side: ShellSide, value: RailValue) => {
|
|
204
|
-
setRailBySideState((prev) => (prev[side] === value ? prev : { ...prev, [side]: value }));
|
|
205
|
-
}, []);
|
|
206
|
-
const toggleRail = React.useCallback((side: ShellSide) => {
|
|
207
|
-
setRailBySideState((prev) => {
|
|
208
|
-
const next = prev[side] === 'open' ? 'collapsed' : 'open';
|
|
209
|
-
return { ...prev, [side]: next };
|
|
210
|
-
});
|
|
211
|
-
// Keep panelRequested sticky across rail collapse/expand
|
|
212
|
-
}, []);
|
|
213
|
-
const [panelRequestedBySide, setPanelRequestedBySideState] = React.useState<
|
|
214
|
-
Record<ShellSide, boolean>
|
|
215
|
-
>({ start: false, end: false });
|
|
216
|
-
const setPanelRequestedBySide = React.useCallback((side: ShellSide, requested: boolean) => {
|
|
217
|
-
setPanelRequestedBySideState((prev) =>
|
|
218
|
-
prev[side] === requested ? prev : { ...prev, [side]: requested },
|
|
219
|
-
);
|
|
220
|
-
}, []);
|
|
221
|
-
// Pattern per side and single-markup state
|
|
222
|
-
// We detect pattern per side based on presence of slots (registered by Sidebar)
|
|
223
|
-
const [patternBySide, setPatternBySide] = React.useState<Record<ShellSide, 'single' | 'split'>>(
|
|
224
|
-
{
|
|
225
|
-
start: 'single',
|
|
226
|
-
end: 'single',
|
|
227
|
-
},
|
|
447
|
+
({ className, children, height = 'full', ...props }, ref) => {
|
|
448
|
+
const { bp: currentBreakpoint, ready: currentBreakpointReady } = useBreakpoint();
|
|
449
|
+
|
|
450
|
+
// Pane state management
|
|
451
|
+
const [leftMode, setLeftMode] = React.useState<PaneMode>('collapsed');
|
|
452
|
+
const [panelMode, setPanelMode] = React.useState<PaneMode>('collapsed');
|
|
453
|
+
const [sidebarMode, setSidebarMode] = React.useState<SidebarMode>('expanded');
|
|
454
|
+
const [inspectorMode, setInspectorMode] = React.useState<PaneMode>('collapsed');
|
|
455
|
+
const [bottomMode, setBottomMode] = React.useState<PaneMode>('collapsed');
|
|
456
|
+
|
|
457
|
+
// Removed: defaultMode responsiveness and manual change tracking
|
|
458
|
+
|
|
459
|
+
// Composition detection
|
|
460
|
+
const [hasLeft, setHasLeft] = React.useState(false);
|
|
461
|
+
const [hasSidebar, setHasSidebar] = React.useState(false);
|
|
462
|
+
|
|
463
|
+
// Customizable sidebar toggle sequencing
|
|
464
|
+
const sidebarToggleComputerRef = React.useRef<(current: SidebarMode) => SidebarMode>(
|
|
465
|
+
(current) =>
|
|
466
|
+
current === 'collapsed' ? 'thin' : current === 'thin' ? 'expanded' : 'collapsed',
|
|
228
467
|
);
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const [singleViewBySide, setSingleViewBySideState] = React.useState<
|
|
233
|
-
Record<ShellSide, SingleView>
|
|
234
|
-
>({
|
|
235
|
-
start: 'panel',
|
|
236
|
-
end: 'collapsed',
|
|
237
|
-
});
|
|
238
|
-
const setSingleViewBySide = React.useCallback((side: ShellSide, view: SingleView) => {
|
|
239
|
-
setSingleViewBySideState((prev) => (prev[side] === view ? prev : { ...prev, [side]: view }));
|
|
240
|
-
}, []);
|
|
241
|
-
const cycleSingleView = React.useCallback(
|
|
242
|
-
(side: ShellSide) => {
|
|
243
|
-
const order = singleViewCycle;
|
|
244
|
-
const current = singleViewBySide[side];
|
|
245
|
-
const idx = order.indexOf(current);
|
|
246
|
-
const next = order[(idx + 1) % order.length];
|
|
247
|
-
setSingleViewBySide(side, next);
|
|
468
|
+
const setSidebarToggleComputer = React.useCallback(
|
|
469
|
+
(fn: (current: SidebarMode) => SidebarMode) => {
|
|
470
|
+
sidebarToggleComputerRef.current = fn;
|
|
248
471
|
},
|
|
249
|
-
[
|
|
472
|
+
[],
|
|
250
473
|
);
|
|
251
474
|
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
end: null,
|
|
259
|
-
});
|
|
260
|
-
const setActiveTool = React.useCallback(
|
|
261
|
-
(side: ShellSide, tool: string | null) => {
|
|
262
|
-
setActiveToolBySideState((prev) =>
|
|
263
|
-
prev[side] === tool ? prev : { ...prev, [side]: tool },
|
|
264
|
-
);
|
|
265
|
-
// Auto-hide panel when no tool is active
|
|
266
|
-
if (tool === null) {
|
|
267
|
-
setPanelRequestedBySide(side, false);
|
|
268
|
-
}
|
|
269
|
-
},
|
|
270
|
-
[setPanelRequestedBySide],
|
|
271
|
-
);
|
|
272
|
-
const onItemSelected = React.useCallback(
|
|
273
|
-
(side: ShellSide, item: string) => {
|
|
274
|
-
setActiveTool(side, item);
|
|
275
|
-
// Auto-show panel when item is selected
|
|
276
|
-
setPanelRequestedBySide(side, true);
|
|
277
|
-
},
|
|
278
|
-
[setActiveTool, setPanelRequestedBySide],
|
|
279
|
-
);
|
|
475
|
+
// Left collapse cascades to Panel
|
|
476
|
+
React.useEffect(() => {
|
|
477
|
+
if (leftMode === 'collapsed') {
|
|
478
|
+
setPanelMode('collapsed');
|
|
479
|
+
}
|
|
480
|
+
}, [leftMode]);
|
|
280
481
|
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
(side: ShellSide, context: string | null) => {
|
|
287
|
-
setActiveContextBySideState((prev) =>
|
|
288
|
-
prev[side] === context ? prev : { ...prev, [side]: context },
|
|
482
|
+
// Composition validation
|
|
483
|
+
React.useEffect(() => {
|
|
484
|
+
if (hasSidebar && hasLeft) {
|
|
485
|
+
console.warn(
|
|
486
|
+
'Shell: Sidebar cannot coexist with Rail or Panel. Use either Rail+Panel OR Sidebar.',
|
|
289
487
|
);
|
|
290
|
-
|
|
488
|
+
}
|
|
489
|
+
}, [hasSidebar, hasLeft]);
|
|
490
|
+
|
|
491
|
+
// Left presentation + defaults from children
|
|
492
|
+
const [devLeftPres, setDevLeftPres] = React.useState<PresentationValue | undefined>(undefined);
|
|
493
|
+
const onLeftPres = React.useCallback((p: PresentationValue) => setDevLeftPres(p), []);
|
|
494
|
+
const railDefaultSizeRef = React.useRef<number>(64);
|
|
495
|
+
const panelDefaultSizeRef = React.useRef<number>(288);
|
|
496
|
+
const onRailDefaults = React.useCallback((size: number) => {
|
|
497
|
+
railDefaultSizeRef.current = size;
|
|
498
|
+
}, []);
|
|
499
|
+
const onPanelDefaults = React.useCallback((size: number) => {
|
|
500
|
+
panelDefaultSizeRef.current = size;
|
|
501
|
+
}, []);
|
|
502
|
+
|
|
503
|
+
// Determine children presence for left composition
|
|
504
|
+
const hasLeftChildren = React.useMemo(() => {
|
|
505
|
+
const childArray = React.Children.toArray(children) as React.ReactElement[];
|
|
506
|
+
const isType = (el: React.ReactElement, comp: any) =>
|
|
507
|
+
React.isValidElement(el) &&
|
|
508
|
+
(el.type === comp || (el as any).type?.displayName === comp.displayName);
|
|
509
|
+
return childArray.some((el) => isType(el, Rail) || isType(el, Panel));
|
|
510
|
+
}, [children]);
|
|
511
|
+
|
|
512
|
+
const hasSidebarChildren = React.useMemo(() => {
|
|
513
|
+
const childArray = React.Children.toArray(children) as React.ReactElement[];
|
|
514
|
+
const isType = (el: React.ReactElement, comp: any) =>
|
|
515
|
+
React.isValidElement(el) &&
|
|
516
|
+
(el.type === comp || (el as any).type?.displayName === comp.displayName);
|
|
517
|
+
return childArray.some((el) => isType(el, Sidebar));
|
|
518
|
+
}, [children]);
|
|
519
|
+
|
|
520
|
+
const togglePane = React.useCallback(
|
|
521
|
+
(target: PaneTarget) => {
|
|
522
|
+
switch (target) {
|
|
523
|
+
case 'left':
|
|
524
|
+
case 'rail':
|
|
525
|
+
setLeftMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
|
|
526
|
+
break;
|
|
527
|
+
case 'panel':
|
|
528
|
+
// Panel toggle: expand left if collapsed, then toggle panel
|
|
529
|
+
if (leftMode === 'collapsed') {
|
|
530
|
+
setLeftMode('expanded');
|
|
531
|
+
setPanelMode('expanded');
|
|
532
|
+
} else {
|
|
533
|
+
setPanelMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
|
|
534
|
+
}
|
|
535
|
+
break;
|
|
536
|
+
case 'sidebar':
|
|
537
|
+
setSidebarMode((prev) => sidebarToggleComputerRef.current(prev as SidebarMode));
|
|
538
|
+
break;
|
|
539
|
+
case 'inspector':
|
|
540
|
+
setInspectorMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
|
|
541
|
+
break;
|
|
542
|
+
case 'bottom':
|
|
543
|
+
setBottomMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
291
546
|
},
|
|
292
|
-
[
|
|
547
|
+
[leftMode],
|
|
293
548
|
);
|
|
294
549
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
550
|
+
const expandPane = React.useCallback((target: PaneTarget) => {
|
|
551
|
+
switch (target) {
|
|
552
|
+
case 'left':
|
|
553
|
+
case 'rail':
|
|
554
|
+
setLeftMode('expanded');
|
|
555
|
+
break;
|
|
556
|
+
case 'panel':
|
|
557
|
+
setLeftMode('expanded');
|
|
558
|
+
setPanelMode('expanded');
|
|
559
|
+
break;
|
|
560
|
+
case 'sidebar':
|
|
561
|
+
setSidebarMode('expanded');
|
|
562
|
+
break;
|
|
563
|
+
case 'inspector':
|
|
564
|
+
setInspectorMode('expanded');
|
|
565
|
+
break;
|
|
566
|
+
case 'bottom':
|
|
567
|
+
setBottomMode('expanded');
|
|
568
|
+
break;
|
|
299
569
|
}
|
|
300
|
-
}, [
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
570
|
+
}, []);
|
|
571
|
+
|
|
572
|
+
const collapsePane = React.useCallback((target: PaneTarget) => {
|
|
573
|
+
switch (target) {
|
|
574
|
+
case 'left':
|
|
575
|
+
case 'rail':
|
|
576
|
+
setLeftMode('collapsed');
|
|
577
|
+
break;
|
|
578
|
+
case 'panel':
|
|
579
|
+
setPanelMode('collapsed');
|
|
580
|
+
break;
|
|
581
|
+
case 'sidebar':
|
|
582
|
+
setSidebarMode('collapsed');
|
|
583
|
+
break;
|
|
584
|
+
case 'inspector':
|
|
585
|
+
setInspectorMode('collapsed');
|
|
586
|
+
break;
|
|
587
|
+
case 'bottom':
|
|
588
|
+
setBottomMode('collapsed');
|
|
589
|
+
break;
|
|
307
590
|
}
|
|
308
|
-
}, [
|
|
309
|
-
|
|
310
|
-
// === Stable ids per side ===
|
|
311
|
-
// These IDs are used to wire aria-controls and aria-expanded for triggers,
|
|
312
|
-
// and to scope region/panel/rail DOM nodes for measurement.
|
|
313
|
-
const startRegionId = React.useId();
|
|
314
|
-
const endRegionId = React.useId();
|
|
315
|
-
const startPanelId = React.useId();
|
|
316
|
-
const endPanelId = React.useId();
|
|
317
|
-
const startRailId = React.useId();
|
|
318
|
-
const endRailId = React.useId();
|
|
319
|
-
const getRegionId = React.useCallback(
|
|
320
|
-
(side: ShellSide) =>
|
|
321
|
-
side === 'start' ? `kui-shell-region-${startRegionId}` : `kui-shell-region-${endRegionId}`,
|
|
322
|
-
[startRegionId, endRegionId],
|
|
323
|
-
);
|
|
324
|
-
const getPanelId = React.useCallback(
|
|
325
|
-
(side: ShellSide) =>
|
|
326
|
-
side === 'start' ? `kui-shell-panel-${startPanelId}` : `kui-shell-panel-${endPanelId}`,
|
|
327
|
-
[startPanelId, endPanelId],
|
|
328
|
-
);
|
|
329
|
-
const getRailId = React.useCallback(
|
|
330
|
-
(side: ShellSide) =>
|
|
331
|
-
side === 'start' ? `kui-shell-rail-${startRailId}` : `kui-shell-rail-${endRailId}`,
|
|
332
|
-
[startRailId, endRailId],
|
|
333
|
-
);
|
|
591
|
+
}, []);
|
|
334
592
|
|
|
335
|
-
const
|
|
593
|
+
const baseContextValue = React.useMemo(
|
|
336
594
|
() => ({
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
595
|
+
leftMode,
|
|
596
|
+
setLeftMode,
|
|
597
|
+
panelMode,
|
|
598
|
+
setPanelMode,
|
|
599
|
+
sidebarMode,
|
|
600
|
+
setSidebarMode,
|
|
601
|
+
inspectorMode,
|
|
602
|
+
setInspectorMode,
|
|
603
|
+
bottomMode,
|
|
604
|
+
setBottomMode,
|
|
605
|
+
hasLeft,
|
|
606
|
+
setHasLeft,
|
|
607
|
+
hasSidebar,
|
|
608
|
+
setHasSidebar,
|
|
609
|
+
currentBreakpoint,
|
|
610
|
+
currentBreakpointReady,
|
|
611
|
+
leftResolvedPresentation: devLeftPres,
|
|
612
|
+
togglePane,
|
|
613
|
+
expandPane,
|
|
614
|
+
collapsePane,
|
|
615
|
+
setSidebarToggleComputer,
|
|
616
|
+
onLeftPres,
|
|
617
|
+
onRailDefaults,
|
|
618
|
+
onPanelDefaults,
|
|
358
619
|
}),
|
|
359
620
|
[
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
setActiveContext,
|
|
378
|
-
getRegionId,
|
|
379
|
-
getPanelId,
|
|
380
|
-
getRailId,
|
|
621
|
+
leftMode,
|
|
622
|
+
panelMode,
|
|
623
|
+
sidebarMode,
|
|
624
|
+
inspectorMode,
|
|
625
|
+
bottomMode,
|
|
626
|
+
hasLeft,
|
|
627
|
+
hasSidebar,
|
|
628
|
+
currentBreakpoint,
|
|
629
|
+
currentBreakpointReady,
|
|
630
|
+
devLeftPres,
|
|
631
|
+
togglePane,
|
|
632
|
+
expandPane,
|
|
633
|
+
collapsePane,
|
|
634
|
+
setSidebarToggleComputer,
|
|
635
|
+
onLeftPres,
|
|
636
|
+
onRailDefaults,
|
|
637
|
+
onPanelDefaults,
|
|
381
638
|
],
|
|
382
639
|
);
|
|
383
640
|
|
|
384
|
-
//
|
|
641
|
+
// Organize children by type
|
|
385
642
|
const childArray = React.Children.toArray(children) as React.ReactElement[];
|
|
386
643
|
const isType = (el: React.ReactElement, comp: any) =>
|
|
387
|
-
React.isValidElement(el) &&
|
|
644
|
+
React.isValidElement(el) &&
|
|
645
|
+
(el.type === comp || (el as any).type?.displayName === comp.displayName);
|
|
646
|
+
|
|
388
647
|
const headerEls = childArray.filter((el) => isType(el, Header));
|
|
389
|
-
const
|
|
390
|
-
const
|
|
648
|
+
const railEls = childArray.filter((el) => isType(el, Rail));
|
|
649
|
+
const panelEls = childArray.filter((el) => isType(el, Panel));
|
|
391
650
|
const sidebarEls = childArray.filter((el) => isType(el, Sidebar));
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
651
|
+
const contentEls = childArray.filter((el) => isType(el, Content));
|
|
652
|
+
const inspectorEls = childArray.filter((el) => isType(el, Inspector));
|
|
653
|
+
const bottomEls = childArray.filter((el) => isType(el, Bottom));
|
|
654
|
+
|
|
655
|
+
const heightStyle = React.useMemo(() => {
|
|
656
|
+
if (height === 'full') return { height: '100vh' };
|
|
657
|
+
if (height === 'auto') return { height: 'auto' };
|
|
658
|
+
if (typeof height === 'string') return { height };
|
|
659
|
+
if (typeof height === 'number') return { height: `${height}px` };
|
|
660
|
+
return {};
|
|
661
|
+
}, [height]);
|
|
662
|
+
|
|
663
|
+
// Peek state (layout-only overlay without mode changes)
|
|
664
|
+
const [peekTarget, setPeekTarget] = React.useState<PaneTarget | null>(null);
|
|
665
|
+
const peekPane = React.useCallback((target: PaneTarget) => setPeekTarget(target), []);
|
|
666
|
+
const clearPeek = React.useCallback(() => setPeekTarget(null), []);
|
|
401
667
|
|
|
402
668
|
return (
|
|
403
669
|
<div
|
|
404
670
|
{...props}
|
|
405
671
|
ref={ref}
|
|
406
672
|
className={classNames('rt-ShellRoot', className)}
|
|
407
|
-
style={{
|
|
408
|
-
...style,
|
|
409
|
-
// Internal CSS custom props for sizing
|
|
410
|
-
['--shell-min-content-width' as any]: minContentWidth,
|
|
411
|
-
['--shell-header-height' as any]: headerHeight,
|
|
412
|
-
}}
|
|
413
|
-
dir={computedDir}
|
|
673
|
+
style={{ ...heightStyle, ...props.style }}
|
|
414
674
|
>
|
|
415
|
-
<ShellContext.Provider
|
|
675
|
+
<ShellContext.Provider
|
|
676
|
+
value={{
|
|
677
|
+
...baseContextValue,
|
|
678
|
+
peekTarget,
|
|
679
|
+
setPeekTarget,
|
|
680
|
+
peekPane,
|
|
681
|
+
clearPeek,
|
|
682
|
+
}}
|
|
683
|
+
>
|
|
416
684
|
{headerEls}
|
|
417
|
-
<div
|
|
418
|
-
|
|
685
|
+
<div
|
|
686
|
+
className="rt-ShellBody"
|
|
687
|
+
data-peek-target={peekTarget ?? undefined}
|
|
688
|
+
style={
|
|
689
|
+
peekTarget === 'rail' || peekTarget === 'panel'
|
|
690
|
+
? ({
|
|
691
|
+
['--peek-rail-width' as any]: `${railDefaultSizeRef.current}px`,
|
|
692
|
+
} as React.CSSProperties)
|
|
693
|
+
: undefined
|
|
694
|
+
}
|
|
695
|
+
>
|
|
696
|
+
{hasLeftChildren && !hasSidebarChildren
|
|
697
|
+
? (() => {
|
|
698
|
+
const firstRail = railEls[0] as any;
|
|
699
|
+
const passthroughProps = firstRail
|
|
700
|
+
? {
|
|
701
|
+
mode: firstRail.props?.mode,
|
|
702
|
+
defaultMode: firstRail.props?.defaultMode,
|
|
703
|
+
onModeChange: firstRail.props?.onModeChange,
|
|
704
|
+
presentation: firstRail.props?.presentation,
|
|
705
|
+
collapsible: firstRail.props?.collapsible,
|
|
706
|
+
onExpand: firstRail.props?.onExpand,
|
|
707
|
+
onCollapse: firstRail.props?.onCollapse,
|
|
708
|
+
}
|
|
709
|
+
: {};
|
|
710
|
+
return (
|
|
711
|
+
<Left {...(passthroughProps as any)}>
|
|
712
|
+
{railEls}
|
|
713
|
+
{panelEls}
|
|
714
|
+
</Left>
|
|
715
|
+
);
|
|
716
|
+
})()
|
|
717
|
+
: sidebarEls}
|
|
718
|
+
{contentEls}
|
|
719
|
+
{inspectorEls}
|
|
720
|
+
</div>
|
|
721
|
+
{bottomEls}
|
|
419
722
|
</ShellContext.Provider>
|
|
420
723
|
</div>
|
|
421
724
|
);
|
|
@@ -423,643 +726,1467 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(
|
|
|
423
726
|
);
|
|
424
727
|
Root.displayName = 'Shell.Root';
|
|
425
728
|
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
729
|
+
// Header
|
|
730
|
+
interface ShellHeaderProps extends React.ComponentPropsWithoutRef<'header'> {
|
|
731
|
+
height?: number;
|
|
732
|
+
}
|
|
733
|
+
|
|
429
734
|
const Header = React.forwardRef<HTMLElement, ShellHeaderProps>(
|
|
430
|
-
({ className, style, ...props }, ref) =>
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}}
|
|
442
|
-
/>
|
|
443
|
-
);
|
|
444
|
-
},
|
|
735
|
+
({ className, height = 64, style, ...props }, ref) => (
|
|
736
|
+
<header
|
|
737
|
+
{...props}
|
|
738
|
+
ref={ref}
|
|
739
|
+
className={classNames('rt-ShellHeader', className)}
|
|
740
|
+
style={{
|
|
741
|
+
...style,
|
|
742
|
+
['--shell-header-height' as any]: `${height}px`,
|
|
743
|
+
}}
|
|
744
|
+
/>
|
|
745
|
+
),
|
|
445
746
|
);
|
|
446
747
|
Header.displayName = 'Shell.Header';
|
|
447
748
|
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
(
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
749
|
+
// Pane Props Interface (shared by Panel, Sidebar, Inspector, Bottom)
|
|
750
|
+
interface PaneProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
751
|
+
presentation?: ResponsivePresentation;
|
|
752
|
+
mode?: PaneMode;
|
|
753
|
+
defaultMode?: ResponsiveMode;
|
|
754
|
+
onModeChange?: (mode: PaneMode) => void;
|
|
755
|
+
expandedSize?: number;
|
|
756
|
+
minSize?: number;
|
|
757
|
+
maxSize?: number;
|
|
758
|
+
resizable?: boolean;
|
|
759
|
+
collapsible?: boolean;
|
|
760
|
+
onExpand?: () => void;
|
|
761
|
+
onCollapse?: () => void;
|
|
762
|
+
onResize?: (size: number) => void;
|
|
763
|
+
/** Optional custom content inside the resizer handle (kept unstyled). */
|
|
764
|
+
resizer?: React.ReactNode;
|
|
765
|
+
onResizeStart?: (size: number) => void;
|
|
766
|
+
onResizeEnd?: (size: number) => void;
|
|
767
|
+
snapPoints?: number[];
|
|
768
|
+
snapTolerance?: number;
|
|
769
|
+
collapseThreshold?: number;
|
|
770
|
+
paneId?: string;
|
|
771
|
+
persistence?: PaneSizePersistence;
|
|
772
|
+
}
|
|
470
773
|
|
|
471
|
-
|
|
774
|
+
// Left container (auto-created for Rail+Panel)
|
|
775
|
+
interface LeftProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
776
|
+
presentation?: ResponsivePresentation;
|
|
777
|
+
mode?: PaneMode;
|
|
778
|
+
defaultMode?: ResponsiveMode;
|
|
779
|
+
onModeChange?: (mode: PaneMode) => void;
|
|
780
|
+
collapsible?: boolean;
|
|
781
|
+
onExpand?: () => void;
|
|
782
|
+
onCollapse?: () => void;
|
|
783
|
+
}
|
|
472
784
|
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
interface ShellSidebarProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
484
|
-
side: ShellSide;
|
|
485
|
-
// Overlay: render as a top sheet rather than inline. Responsiveness handled separately.
|
|
486
|
-
overlay?: boolean | Partial<Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', boolean>>;
|
|
487
|
-
overlaySide?: 'start' | 'end' | 'top' | 'bottom';
|
|
488
|
-
// Split: rail control
|
|
489
|
-
value?: RailValue;
|
|
490
|
-
defaultValue?: RailValue;
|
|
491
|
-
onValueChange?: (value: RailValue) => void;
|
|
492
|
-
// Single-markup view control
|
|
493
|
-
view?: SingleView;
|
|
494
|
-
defaultView?: SingleView;
|
|
495
|
-
onViewChange?: (view: SingleView) => void;
|
|
496
|
-
as?: 'nav' | 'aside' | 'div';
|
|
497
|
-
'aria-label'?: string;
|
|
785
|
+
// Rail (special case)
|
|
786
|
+
interface RailProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
787
|
+
presentation?: ResponsivePresentation;
|
|
788
|
+
mode?: PaneMode;
|
|
789
|
+
defaultMode?: ResponsiveMode;
|
|
790
|
+
onModeChange?: (mode: PaneMode) => void;
|
|
791
|
+
expandedSize?: number;
|
|
792
|
+
collapsible?: boolean;
|
|
793
|
+
onExpand?: () => void;
|
|
794
|
+
onCollapse?: () => void;
|
|
498
795
|
}
|
|
499
796
|
|
|
500
|
-
// Rail
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
797
|
+
// Left container - behaves like Inspector but contains Rail+Panel
|
|
798
|
+
const Left = React.forwardRef<HTMLDivElement, LeftProps>(
|
|
799
|
+
(
|
|
800
|
+
{
|
|
801
|
+
className,
|
|
802
|
+
presentation = { initial: 'overlay', sm: 'fixed' },
|
|
803
|
+
mode,
|
|
804
|
+
defaultMode = 'collapsed',
|
|
805
|
+
onModeChange,
|
|
806
|
+
collapsible = true,
|
|
807
|
+
onExpand,
|
|
808
|
+
onCollapse,
|
|
809
|
+
children,
|
|
810
|
+
style,
|
|
811
|
+
...props
|
|
812
|
+
},
|
|
813
|
+
ref,
|
|
814
|
+
) => {
|
|
506
815
|
const shell = useShell();
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
816
|
+
const resolvedPresentation = useResponsivePresentation(presentation);
|
|
817
|
+
const isOverlay = resolvedPresentation === 'overlay';
|
|
818
|
+
const isStacked = resolvedPresentation === 'stacked';
|
|
819
|
+
const localRef = React.useRef<HTMLDivElement | null>(null);
|
|
820
|
+
// Publish resolved presentation so Root can gate peeking in overlay
|
|
821
|
+
React.useEffect(() => {
|
|
822
|
+
(shell as any).onLeftPres?.(resolvedPresentation);
|
|
823
|
+
}, [shell, resolvedPresentation]);
|
|
824
|
+
const setRef = React.useCallback(
|
|
825
|
+
(node: HTMLDivElement | null) => {
|
|
826
|
+
localRef.current = node;
|
|
827
|
+
if (typeof ref === 'function') ref(node);
|
|
828
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
829
|
+
},
|
|
830
|
+
[ref],
|
|
514
831
|
);
|
|
515
832
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
)
|
|
833
|
+
// Register with shell
|
|
834
|
+
React.useEffect(() => {
|
|
835
|
+
shell.setHasLeft(true);
|
|
836
|
+
return () => shell.setHasLeft(false);
|
|
837
|
+
}, [shell]);
|
|
838
|
+
|
|
839
|
+
// Always-follow responsive defaultMode for uncontrolled Left (Rail stack)
|
|
840
|
+
const resolveResponsiveMode = React.useCallback((): PaneMode => {
|
|
841
|
+
if (typeof defaultMode === 'string') return defaultMode as PaneMode;
|
|
842
|
+
const dm = defaultMode as Partial<Record<Breakpoint, PaneMode>> | undefined;
|
|
843
|
+
if (dm && dm[shell.currentBreakpoint as Breakpoint]) {
|
|
844
|
+
return dm[shell.currentBreakpoint as Breakpoint] as PaneMode;
|
|
845
|
+
}
|
|
846
|
+
const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
|
|
847
|
+
const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat(
|
|
848
|
+
'initial' as Breakpoint,
|
|
849
|
+
);
|
|
850
|
+
const startIdx = order.indexOf(shell.currentBreakpoint as Breakpoint);
|
|
851
|
+
for (let i = startIdx + 1; i < order.length; i++) {
|
|
852
|
+
const bp = order[i];
|
|
853
|
+
if (dm && dm[bp]) {
|
|
854
|
+
return dm[bp] as PaneMode;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return 'collapsed';
|
|
858
|
+
}, [defaultMode, shell.currentBreakpoint]);
|
|
523
859
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
860
|
+
const lastBpRef = React.useRef<Breakpoint | null>(null);
|
|
861
|
+
React.useEffect(() => {
|
|
862
|
+
if (mode !== undefined) return; // controlled wins
|
|
863
|
+
if (!shell.currentBreakpointReady) return; // avoid SSR mismatch
|
|
864
|
+
if (lastBpRef.current === shell.currentBreakpoint) return; // only on bp change
|
|
865
|
+
lastBpRef.current = shell.currentBreakpoint as Breakpoint;
|
|
866
|
+
const next = resolveResponsiveMode();
|
|
867
|
+
if (next !== shell.leftMode) {
|
|
868
|
+
shell.setLeftMode(next);
|
|
869
|
+
}
|
|
870
|
+
}, [
|
|
871
|
+
mode,
|
|
872
|
+
shell.currentBreakpoint,
|
|
873
|
+
shell.currentBreakpointReady,
|
|
874
|
+
resolveResponsiveMode,
|
|
875
|
+
shell.leftMode,
|
|
876
|
+
shell.setLeftMode,
|
|
877
|
+
]);
|
|
878
|
+
|
|
879
|
+
// Sync controlled mode
|
|
880
|
+
React.useEffect(() => {
|
|
881
|
+
if (mode !== undefined && shell.leftMode !== mode) {
|
|
882
|
+
shell.setLeftMode(mode);
|
|
883
|
+
}
|
|
884
|
+
}, [mode, shell]);
|
|
885
|
+
|
|
886
|
+
// Emit mode changes
|
|
887
|
+
React.useEffect(() => {
|
|
888
|
+
if (mode === undefined) {
|
|
889
|
+
onModeChange?.(shell.leftMode);
|
|
890
|
+
}
|
|
891
|
+
}, [shell.leftMode, mode, onModeChange]);
|
|
892
|
+
|
|
893
|
+
// Emit expand/collapse events
|
|
894
|
+
React.useEffect(() => {
|
|
895
|
+
if (shell.leftMode === 'expanded') {
|
|
896
|
+
onExpand?.();
|
|
897
|
+
} else {
|
|
898
|
+
onCollapse?.();
|
|
899
|
+
}
|
|
900
|
+
}, [shell.leftMode, onExpand, onCollapse]);
|
|
901
|
+
|
|
902
|
+
const isExpanded = shell.leftMode === 'expanded';
|
|
903
|
+
|
|
904
|
+
// Left is not resizable; width derives from Rail/Panel.
|
|
905
|
+
|
|
906
|
+
if (isOverlay) {
|
|
907
|
+
const open = shell.leftMode === 'expanded';
|
|
908
|
+
// Compute overlay width from child Rail/Panel expanded sizes
|
|
909
|
+
const childArray = React.Children.toArray(children) as React.ReactElement[];
|
|
910
|
+
const isType = (el: React.ReactElement, comp: any) =>
|
|
911
|
+
React.isValidElement(el) && el.type === comp;
|
|
912
|
+
const railEl = childArray.find((el) => isType(el, Rail));
|
|
913
|
+
const panelEl = childArray.find((el) => isType(el, Panel));
|
|
914
|
+
const railSize =
|
|
915
|
+
typeof (railEl as any)?.props?.expandedSize === 'number'
|
|
916
|
+
? (railEl as any).props.expandedSize
|
|
917
|
+
: 64;
|
|
918
|
+
const panelSize =
|
|
919
|
+
typeof (panelEl as any)?.props?.expandedSize === 'number'
|
|
920
|
+
? (panelEl as any).props.expandedSize
|
|
921
|
+
: 288;
|
|
922
|
+
const hasRail = Boolean(railEl);
|
|
923
|
+
const hasPanel = Boolean(panelEl);
|
|
924
|
+
const overlayPx =
|
|
925
|
+
(hasRail ? railSize : 0) + (shell.panelMode === 'expanded' && hasPanel ? panelSize : 0);
|
|
926
|
+
return (
|
|
927
|
+
<Sheet.Root
|
|
928
|
+
open={open}
|
|
929
|
+
onOpenChange={(o) => shell.setLeftMode(o ? 'expanded' : 'collapsed')}
|
|
930
|
+
>
|
|
931
|
+
<Sheet.Content
|
|
932
|
+
side="start"
|
|
933
|
+
style={{ padding: 0 }}
|
|
934
|
+
width={{
|
|
935
|
+
initial: `${overlayPx}px`,
|
|
936
|
+
}}
|
|
937
|
+
>
|
|
938
|
+
<VisuallyHidden>
|
|
939
|
+
<Sheet.Title>Navigation</Sheet.Title>
|
|
940
|
+
</VisuallyHidden>
|
|
941
|
+
<div className="rt-ShellLeft">{children}</div>
|
|
942
|
+
</Sheet.Content>
|
|
943
|
+
</Sheet.Root>
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (isStacked) {
|
|
948
|
+
const open = shell.leftMode === 'expanded';
|
|
949
|
+
// Compute floating width from child Rail/Panel expanded sizes (like overlay)
|
|
950
|
+
const childArray = React.Children.toArray(children) as React.ReactElement[];
|
|
951
|
+
const isType = (el: React.ReactElement, comp: any) =>
|
|
952
|
+
React.isValidElement(el) && el.type === comp;
|
|
953
|
+
const railEl = childArray.find((el) => isType(el, Rail));
|
|
954
|
+
const panelEl = childArray.find((el) => isType(el, Panel));
|
|
955
|
+
const railSize =
|
|
956
|
+
typeof (railEl as any)?.props?.expandedSize === 'number'
|
|
957
|
+
? (railEl as any).props.expandedSize
|
|
958
|
+
: 64;
|
|
959
|
+
const panelSize =
|
|
960
|
+
typeof (panelEl as any)?.props?.expandedSize === 'number'
|
|
961
|
+
? (panelEl as any).props.expandedSize
|
|
962
|
+
: 288;
|
|
963
|
+
const hasRail = Boolean(railEl);
|
|
964
|
+
const hasPanel = Boolean(panelEl);
|
|
965
|
+
const includePanel =
|
|
966
|
+
hasPanel && (shell.panelMode === 'expanded' || shell.peekTarget === 'panel');
|
|
967
|
+
const floatingWidthPx = (hasRail ? railSize : 0) + (includePanel ? panelSize : 0);
|
|
968
|
+
|
|
969
|
+
return (
|
|
970
|
+
<div
|
|
971
|
+
{...props}
|
|
972
|
+
ref={setRef}
|
|
973
|
+
className={classNames('rt-ShellLeft', className)}
|
|
974
|
+
data-mode={shell.leftMode}
|
|
975
|
+
data-peek={
|
|
976
|
+
shell.peekTarget === 'left' ||
|
|
977
|
+
shell.peekTarget === 'rail' ||
|
|
978
|
+
shell.peekTarget === 'panel' ||
|
|
979
|
+
undefined
|
|
980
|
+
}
|
|
981
|
+
data-presentation={resolvedPresentation}
|
|
982
|
+
style={{
|
|
983
|
+
...style,
|
|
984
|
+
}}
|
|
985
|
+
data-open={open || undefined}
|
|
986
|
+
>
|
|
527
987
|
{children}
|
|
528
|
-
</
|
|
529
|
-
|
|
988
|
+
</div>
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return (
|
|
993
|
+
<div
|
|
994
|
+
{...props}
|
|
995
|
+
ref={setRef}
|
|
996
|
+
className={classNames('rt-ShellLeft', className)}
|
|
997
|
+
data-mode={shell.leftMode}
|
|
998
|
+
data-peek={
|
|
999
|
+
shell.peekTarget === 'left' ||
|
|
1000
|
+
shell.peekTarget === 'rail' ||
|
|
1001
|
+
shell.peekTarget === 'panel' ||
|
|
1002
|
+
undefined
|
|
1003
|
+
}
|
|
1004
|
+
data-presentation={resolvedPresentation}
|
|
1005
|
+
style={{
|
|
1006
|
+
...style,
|
|
1007
|
+
}}
|
|
1008
|
+
>
|
|
1009
|
+
{children}
|
|
1010
|
+
</div>
|
|
530
1011
|
);
|
|
531
|
-
}
|
|
532
|
-
{ displayName: 'Shell.Sidebar.Rail', __shellSlot: 'rail' as const },
|
|
1012
|
+
},
|
|
533
1013
|
);
|
|
1014
|
+
Left.displayName = 'Shell.Left';
|
|
534
1015
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1016
|
+
const Rail = React.forwardRef<HTMLDivElement, RailProps>(
|
|
1017
|
+
(
|
|
1018
|
+
{
|
|
1019
|
+
className,
|
|
1020
|
+
presentation,
|
|
1021
|
+
mode,
|
|
1022
|
+
defaultMode,
|
|
1023
|
+
onModeChange,
|
|
1024
|
+
expandedSize = 64,
|
|
1025
|
+
collapsible,
|
|
1026
|
+
onExpand,
|
|
1027
|
+
onCollapse,
|
|
1028
|
+
children,
|
|
1029
|
+
style,
|
|
1030
|
+
...props
|
|
1031
|
+
},
|
|
1032
|
+
ref,
|
|
1033
|
+
) => {
|
|
541
1034
|
const shell = useShell();
|
|
542
|
-
const side = sidebarSection?.side ?? 'start';
|
|
543
1035
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}),
|
|
549
|
-
[shell.activeToolBySide, shell.activeContextBySide, side],
|
|
550
|
-
);
|
|
1036
|
+
// Register expanded size with Left container
|
|
1037
|
+
React.useEffect(() => {
|
|
1038
|
+
(shell as any).onRailDefaults?.(expandedSize);
|
|
1039
|
+
}, [shell, expandedSize]);
|
|
551
1040
|
|
|
552
|
-
const
|
|
553
|
-
() => ({
|
|
554
|
-
side,
|
|
555
|
-
section: 'panel',
|
|
556
|
-
}),
|
|
557
|
-
[side],
|
|
558
|
-
);
|
|
1041
|
+
const isExpanded = shell.leftMode === 'expanded';
|
|
559
1042
|
|
|
560
1043
|
return (
|
|
561
|
-
<
|
|
562
|
-
|
|
1044
|
+
<div
|
|
1045
|
+
{...props}
|
|
1046
|
+
ref={ref}
|
|
1047
|
+
className={classNames('rt-ShellRail', className)}
|
|
1048
|
+
data-mode={shell.leftMode}
|
|
1049
|
+
data-peek={
|
|
1050
|
+
(shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'rail') || undefined
|
|
1051
|
+
}
|
|
1052
|
+
style={{
|
|
1053
|
+
...style,
|
|
1054
|
+
['--rail-size' as any]: `${expandedSize}px`,
|
|
1055
|
+
}}
|
|
1056
|
+
>
|
|
1057
|
+
<div
|
|
1058
|
+
className="rt-ShellRailContent"
|
|
1059
|
+
data-visible={
|
|
1060
|
+
isExpanded ||
|
|
1061
|
+
(shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'rail') ||
|
|
1062
|
+
undefined
|
|
1063
|
+
}
|
|
1064
|
+
>
|
|
563
1065
|
{children}
|
|
564
|
-
</
|
|
565
|
-
</
|
|
1066
|
+
</div>
|
|
1067
|
+
</div>
|
|
566
1068
|
);
|
|
567
|
-
}
|
|
568
|
-
{ displayName: 'Shell.Sidebar.Panel', __shellSlot: 'panel' as const },
|
|
1069
|
+
},
|
|
569
1070
|
);
|
|
1071
|
+
Rail.displayName = 'Shell.Rail';
|
|
570
1072
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
> &
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
const railValue = shell.railBySide[side];
|
|
648
|
-
const panelRequested = shell.panelRequestedBySide[side];
|
|
649
|
-
const singleView = shell.singleViewBySide[side];
|
|
650
|
-
const activeTool = shell.activeToolBySide[side];
|
|
651
|
-
|
|
652
|
-
// Emit changes for uncontrolled
|
|
653
|
-
const prevRailRef = React.useRef<RailValue | null>(null);
|
|
654
|
-
React.useEffect(() => {
|
|
655
|
-
if (!hasSlots) return;
|
|
656
|
-
if (value !== undefined) return;
|
|
657
|
-
if (prevRailRef.current !== railValue) {
|
|
658
|
-
prevRailRef.current = railValue;
|
|
659
|
-
onValueChange?.(railValue);
|
|
660
|
-
}
|
|
661
|
-
}, [hasSlots, railValue, value, onValueChange]);
|
|
662
|
-
const prevViewRef = React.useRef<SingleView | null>(null);
|
|
663
|
-
React.useEffect(() => {
|
|
664
|
-
if (hasSlots) return;
|
|
665
|
-
if (view !== undefined) return;
|
|
666
|
-
if (prevViewRef.current !== singleView) {
|
|
667
|
-
prevViewRef.current = singleView;
|
|
668
|
-
onViewChange?.(singleView);
|
|
669
|
-
}
|
|
670
|
-
}, [hasSlots, singleView, view, onViewChange]);
|
|
671
|
-
|
|
672
|
-
// Derived visibility:
|
|
673
|
-
// - Split (rail+panel): panel shows when rail is open, panel is requested, and a tool is active
|
|
674
|
-
// - Panel-only (no rail): panel shows unconditionally (no tool gating)
|
|
675
|
-
// - Single-markup: shows when view === 'panel' and a tool is active
|
|
676
|
-
const railVisible = hasSlots ? hasRail && railValue === 'open' : singleView === 'rail';
|
|
677
|
-
const panelVisible = hasSlots
|
|
678
|
-
? hasPanel && (hasRail ? railValue === 'open' && panelRequested && activeTool !== null : true)
|
|
679
|
-
: singleView === 'panel';
|
|
680
|
-
|
|
681
|
-
// Overlay behavior (non-inline): mount as a Sheet from the top (default)
|
|
682
|
-
// Resolve overlay responsively: built-in defaults overruled by prop
|
|
683
|
-
// Defaults: overlay on initial/xs/sm; inline on md+
|
|
684
|
-
const defaultOverlay = { initial: true, xs: true, sm: true, md: false, lg: false, xl: false };
|
|
685
|
-
const mergedOverlay = React.useMemo(() => {
|
|
686
|
-
if (typeof overlay === 'boolean') {
|
|
687
|
-
return { initial: overlay } as Partial<
|
|
688
|
-
Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', boolean>
|
|
689
|
-
>;
|
|
690
|
-
}
|
|
691
|
-
return { ...defaultOverlay, ...(overlay || {}) } as Partial<
|
|
692
|
-
Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', boolean>
|
|
693
|
-
>;
|
|
694
|
-
}, [overlay, defaultOverlay]);
|
|
1073
|
+
// Panel
|
|
1074
|
+
type HandleComponent = React.ForwardRefExoticComponent<
|
|
1075
|
+
React.ComponentPropsWithoutRef<'div'> & React.RefAttributes<HTMLDivElement>
|
|
1076
|
+
>;
|
|
1077
|
+
|
|
1078
|
+
type PanelComponent = React.ForwardRefExoticComponent<
|
|
1079
|
+
Omit<PaneProps, 'defaultMode'> & React.RefAttributes<HTMLDivElement>
|
|
1080
|
+
> & { Handle: HandleComponent };
|
|
1081
|
+
|
|
1082
|
+
type SidebarComponent = React.ForwardRefExoticComponent<
|
|
1083
|
+
(Omit<PaneProps, 'mode' | 'defaultMode' | 'onModeChange'> & {
|
|
1084
|
+
mode?: SidebarMode;
|
|
1085
|
+
defaultMode?: ResponsiveSidebarMode;
|
|
1086
|
+
onModeChange?: (mode: SidebarMode) => void;
|
|
1087
|
+
thinSize?: number;
|
|
1088
|
+
toggleModes?: Array<'thin' | 'expanded'>;
|
|
1089
|
+
}) &
|
|
1090
|
+
React.RefAttributes<HTMLDivElement>
|
|
1091
|
+
> & { Handle: HandleComponent };
|
|
1092
|
+
|
|
1093
|
+
type InspectorComponent = React.ForwardRefExoticComponent<
|
|
1094
|
+
PaneProps & React.RefAttributes<HTMLDivElement>
|
|
1095
|
+
> & { Handle: HandleComponent };
|
|
1096
|
+
|
|
1097
|
+
type BottomComponent = React.ForwardRefExoticComponent<
|
|
1098
|
+
PaneProps & React.RefAttributes<HTMLDivElement>
|
|
1099
|
+
> & { Handle: HandleComponent };
|
|
1100
|
+
|
|
1101
|
+
const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' | 'defaultMode'>>(
|
|
1102
|
+
(
|
|
1103
|
+
{
|
|
1104
|
+
className,
|
|
1105
|
+
mode,
|
|
1106
|
+
onModeChange,
|
|
1107
|
+
expandedSize = 288,
|
|
1108
|
+
minSize,
|
|
1109
|
+
maxSize,
|
|
1110
|
+
resizable,
|
|
1111
|
+
collapsible = true,
|
|
1112
|
+
onExpand,
|
|
1113
|
+
onCollapse,
|
|
1114
|
+
onResize,
|
|
1115
|
+
onResizeStart,
|
|
1116
|
+
onResizeEnd,
|
|
1117
|
+
snapPoints,
|
|
1118
|
+
snapTolerance,
|
|
1119
|
+
collapseThreshold,
|
|
1120
|
+
paneId,
|
|
1121
|
+
persistence,
|
|
1122
|
+
children,
|
|
1123
|
+
style,
|
|
1124
|
+
...props
|
|
1125
|
+
},
|
|
1126
|
+
ref,
|
|
1127
|
+
) => {
|
|
1128
|
+
const shell = useShell();
|
|
1129
|
+
React.useEffect(() => {
|
|
1130
|
+
(shell as any).onPanelDefaults?.(expandedSize);
|
|
1131
|
+
}, [shell, expandedSize]);
|
|
1132
|
+
const localRef = React.useRef<HTMLDivElement | null>(null);
|
|
1133
|
+
const setRef = React.useCallback(
|
|
1134
|
+
(node: HTMLDivElement | null) => {
|
|
1135
|
+
localRef.current = node;
|
|
1136
|
+
if (typeof ref === 'function') ref(node);
|
|
1137
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
1138
|
+
},
|
|
1139
|
+
[ref],
|
|
1140
|
+
);
|
|
1141
|
+
const childArray = React.Children.toArray(children) as React.ReactElement[];
|
|
1142
|
+
const handleChildren = childArray.filter(
|
|
1143
|
+
(el: React.ReactElement) => React.isValidElement(el) && el.type === PanelHandle,
|
|
1144
|
+
);
|
|
1145
|
+
const contentChildren = childArray.filter(
|
|
1146
|
+
(el: React.ReactElement) => !(React.isValidElement(el) && el.type === PanelHandle),
|
|
1147
|
+
);
|
|
695
1148
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1149
|
+
const isOverlay = shell.leftResolvedPresentation === 'overlay';
|
|
1150
|
+
|
|
1151
|
+
// Derive a default persistence adapter from paneId if none provided
|
|
1152
|
+
const persistenceAdapter = React.useMemo(() => {
|
|
1153
|
+
if (!paneId || persistence) return persistence;
|
|
1154
|
+
const key = `kookie-ui:shell:panel:${paneId}`;
|
|
1155
|
+
const adapter: PaneSizePersistence = {
|
|
1156
|
+
load: () => {
|
|
1157
|
+
if (typeof window === 'undefined') return undefined;
|
|
1158
|
+
const v = window.localStorage.getItem(key);
|
|
1159
|
+
return v ? Number(v) : undefined;
|
|
1160
|
+
},
|
|
1161
|
+
save: (size: number) => {
|
|
1162
|
+
if (typeof window === 'undefined') return;
|
|
1163
|
+
window.localStorage.setItem(key, String(size));
|
|
1164
|
+
},
|
|
1165
|
+
};
|
|
1166
|
+
return adapter;
|
|
1167
|
+
}, [paneId, persistence]);
|
|
1168
|
+
|
|
1169
|
+
// Load persisted size if configured (only in fixed presentation)
|
|
1170
|
+
React.useEffect(() => {
|
|
1171
|
+
let mounted = true;
|
|
1172
|
+
(async () => {
|
|
1173
|
+
if (!resizable || !persistenceAdapter?.load || isOverlay) return;
|
|
1174
|
+
const loaded = await persistenceAdapter.load();
|
|
1175
|
+
if (mounted && typeof loaded === 'number' && localRef.current) {
|
|
1176
|
+
localRef.current.style.setProperty('--panel-size', `${loaded}px`);
|
|
1177
|
+
onResize?.(loaded);
|
|
1178
|
+
}
|
|
1179
|
+
})();
|
|
1180
|
+
return () => {
|
|
1181
|
+
mounted = false;
|
|
1182
|
+
};
|
|
1183
|
+
}, [resizable, persistenceAdapter, onResize, isOverlay]);
|
|
699
1184
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
['xl', '(min-width: 1640px)'],
|
|
708
|
-
];
|
|
709
|
-
const mqls = queries.map(([k, q]) => [k, window.matchMedia(q)] as const);
|
|
710
|
-
const compute = () => {
|
|
711
|
-
// Highest matched wins
|
|
712
|
-
const matched = mqls.filter(([, m]) => m.matches).map(([k]) => k);
|
|
713
|
-
const next = (matched[matched.length - 1] as typeof currentBp | undefined) ?? 'initial';
|
|
714
|
-
setCurrentBp(next);
|
|
715
|
-
};
|
|
716
|
-
compute();
|
|
717
|
-
mqls.forEach(([, m]) => m.addEventListener('change', compute));
|
|
718
|
-
return () => {
|
|
719
|
-
mqls.forEach(([, m]) => m.removeEventListener('change', compute));
|
|
720
|
-
};
|
|
721
|
-
}, []);
|
|
1185
|
+
// In overlay, ensure panel uses the fixed expandedSize, ignoring any persisted size
|
|
1186
|
+
React.useEffect(() => {
|
|
1187
|
+
if (!localRef.current) return;
|
|
1188
|
+
if (isOverlay) {
|
|
1189
|
+
localRef.current.style.setProperty('--panel-size', `${expandedSize}px`);
|
|
1190
|
+
}
|
|
1191
|
+
}, [isOverlay, expandedSize]);
|
|
722
1192
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
if (isOverlay) {
|
|
737
|
-
const open = hasSlots ? railValue === 'open' || panelRequested : singleView !== 'collapsed';
|
|
738
|
-
const onOpenChange = (next: boolean) => {
|
|
739
|
-
if (hasSlots) {
|
|
740
|
-
if (!next) {
|
|
741
|
-
shell.setRailBySide(side, 'collapsed');
|
|
742
|
-
shell.setPanelRequestedBySide(side, false);
|
|
743
|
-
} else {
|
|
744
|
-
shell.setRailBySide(side, 'open');
|
|
745
|
-
}
|
|
746
|
-
} else {
|
|
747
|
-
if (!next) shell.setSingleViewBySide(side, 'collapsed');
|
|
748
|
-
else shell.setSingleViewBySide(side, 'panel');
|
|
1193
|
+
// Ensure Left container width is auto whenever Panel is expanded in fixed presentation
|
|
1194
|
+
React.useEffect(() => {
|
|
1195
|
+
if (!localRef.current) return;
|
|
1196
|
+
if (
|
|
1197
|
+
shell.leftResolvedPresentation !== 'overlay' &&
|
|
1198
|
+
shell.leftMode === 'expanded' &&
|
|
1199
|
+
shell.panelMode === 'expanded'
|
|
1200
|
+
) {
|
|
1201
|
+
const leftEl = (localRef.current.parentElement as HTMLElement) || null;
|
|
1202
|
+
try {
|
|
1203
|
+
leftEl?.style.removeProperty('width');
|
|
1204
|
+
} catch {}
|
|
749
1205
|
}
|
|
750
|
-
};
|
|
1206
|
+
}, [shell.leftResolvedPresentation, shell.leftMode, shell.panelMode]);
|
|
1207
|
+
|
|
1208
|
+
const isExpanded = shell.leftMode === 'expanded' && shell.panelMode === 'expanded';
|
|
1209
|
+
|
|
1210
|
+
// Provide resizer handle when fixed (not overlay)
|
|
1211
|
+
const handleEl =
|
|
1212
|
+
resizable && shell.leftResolvedPresentation !== 'overlay' && isExpanded ? (
|
|
1213
|
+
<PaneResizeContext.Provider
|
|
1214
|
+
value={{
|
|
1215
|
+
containerRef: localRef,
|
|
1216
|
+
cssVarName: '--panel-size',
|
|
1217
|
+
minSize: typeof minSize === 'number' ? minSize : 100,
|
|
1218
|
+
maxSize: typeof maxSize === 'number' ? maxSize : 800,
|
|
1219
|
+
defaultSize: expandedSize,
|
|
1220
|
+
orientation: 'vertical',
|
|
1221
|
+
edge: 'end',
|
|
1222
|
+
computeNext: (client, startClient, startSize) => {
|
|
1223
|
+
const isRtl = getComputedStyle(localRef.current!).direction === 'rtl';
|
|
1224
|
+
const delta = client - startClient;
|
|
1225
|
+
return startSize + (isRtl ? -delta : delta);
|
|
1226
|
+
},
|
|
1227
|
+
onResize,
|
|
1228
|
+
onResizeStart: (size) => {
|
|
1229
|
+
// Ensure Left container is not stuck with a fixed width in stacked
|
|
1230
|
+
const panelEl = localRef.current as HTMLElement | null;
|
|
1231
|
+
const leftEl = panelEl?.parentElement as HTMLElement | null;
|
|
1232
|
+
try {
|
|
1233
|
+
leftEl?.style.removeProperty('width');
|
|
1234
|
+
} catch {}
|
|
1235
|
+
onResizeStart?.(size);
|
|
1236
|
+
},
|
|
1237
|
+
onResizeEnd: (size) => {
|
|
1238
|
+
onResizeEnd?.(size);
|
|
1239
|
+
persistenceAdapter?.save?.(size);
|
|
1240
|
+
},
|
|
1241
|
+
target: 'panel',
|
|
1242
|
+
collapsible: Boolean(collapsible),
|
|
1243
|
+
snapPoints,
|
|
1244
|
+
snapTolerance: snapTolerance ?? 8,
|
|
1245
|
+
collapseThreshold,
|
|
1246
|
+
requestCollapse: () => shell.setPanelMode('collapsed'),
|
|
1247
|
+
requestToggle: () => shell.togglePane('panel'),
|
|
1248
|
+
}}
|
|
1249
|
+
>
|
|
1250
|
+
{handleChildren.length > 0 ? (
|
|
1251
|
+
handleChildren.map((el, i) => React.cloneElement(el, { key: el.key ?? i }))
|
|
1252
|
+
) : (
|
|
1253
|
+
<PaneHandle />
|
|
1254
|
+
)}
|
|
1255
|
+
</PaneResizeContext.Provider>
|
|
1256
|
+
) : null;
|
|
751
1257
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
className="rt-ShellSidebarPanel"
|
|
777
|
-
data-section="panel"
|
|
778
|
-
data-visible={overlayPanelVisible || undefined}
|
|
779
|
-
aria-hidden={overlayPanelVisible ? undefined : true}
|
|
780
|
-
{...(overlayPanelVisible ? {} : { inert })}
|
|
781
|
-
style={{
|
|
782
|
-
inlineSize: overlayPanelVisible ? 'var(--shell-sidebar-panel-width, 288px)' : '0px',
|
|
783
|
-
overflow: 'hidden',
|
|
784
|
-
}}
|
|
785
|
-
>
|
|
786
|
-
{panelChildren}
|
|
787
|
-
</div>
|
|
788
|
-
) : null}
|
|
1258
|
+
return (
|
|
1259
|
+
<div
|
|
1260
|
+
{...props}
|
|
1261
|
+
ref={setRef}
|
|
1262
|
+
className={classNames('rt-ShellPanel', className)}
|
|
1263
|
+
data-mode={shell.panelMode}
|
|
1264
|
+
data-visible={
|
|
1265
|
+
isExpanded ||
|
|
1266
|
+
(shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'panel') ||
|
|
1267
|
+
undefined
|
|
1268
|
+
}
|
|
1269
|
+
data-peek={
|
|
1270
|
+
(shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'panel') ||
|
|
1271
|
+
undefined
|
|
1272
|
+
}
|
|
1273
|
+
style={{
|
|
1274
|
+
...style,
|
|
1275
|
+
['--panel-size' as any]: `${expandedSize}px`,
|
|
1276
|
+
}}
|
|
1277
|
+
>
|
|
1278
|
+
<div className="rt-ShellPanelContent" data-visible={isExpanded || undefined}>
|
|
1279
|
+
{contentChildren}
|
|
1280
|
+
</div>
|
|
1281
|
+
{handleEl}
|
|
789
1282
|
</div>
|
|
790
|
-
)
|
|
791
|
-
|
|
1283
|
+
);
|
|
1284
|
+
},
|
|
1285
|
+
) as PanelComponent;
|
|
1286
|
+
Panel.displayName = 'Shell.Panel';
|
|
1287
|
+
Panel.Handle = PanelHandle;
|
|
1288
|
+
|
|
1289
|
+
// Sidebar (alternative to Rail+Panel)
|
|
1290
|
+
const Sidebar = React.forwardRef<
|
|
1291
|
+
HTMLDivElement,
|
|
1292
|
+
Omit<PaneProps, 'mode' | 'defaultMode' | 'onModeChange'> & {
|
|
1293
|
+
mode?: SidebarMode;
|
|
1294
|
+
defaultMode?: ResponsiveSidebarMode;
|
|
1295
|
+
onModeChange?: (mode: SidebarMode) => void;
|
|
1296
|
+
thinSize?: number;
|
|
1297
|
+
toggleModes?: Array<'thin' | 'expanded'>;
|
|
1298
|
+
}
|
|
1299
|
+
>(
|
|
1300
|
+
(
|
|
1301
|
+
{
|
|
1302
|
+
className,
|
|
1303
|
+
presentation = { initial: 'overlay', md: 'fixed' },
|
|
1304
|
+
mode,
|
|
1305
|
+
defaultMode = 'expanded',
|
|
1306
|
+
onModeChange,
|
|
1307
|
+
expandedSize = 288,
|
|
1308
|
+
minSize = 200,
|
|
1309
|
+
maxSize = 400,
|
|
1310
|
+
resizable = false,
|
|
1311
|
+
collapsible = true,
|
|
1312
|
+
onExpand,
|
|
1313
|
+
onCollapse,
|
|
1314
|
+
onResize,
|
|
1315
|
+
onResizeStart,
|
|
1316
|
+
onResizeEnd,
|
|
1317
|
+
snapPoints,
|
|
1318
|
+
snapTolerance,
|
|
1319
|
+
collapseThreshold,
|
|
1320
|
+
paneId,
|
|
1321
|
+
persistence,
|
|
1322
|
+
children,
|
|
1323
|
+
style,
|
|
1324
|
+
thinSize = 64,
|
|
1325
|
+
toggleModes,
|
|
1326
|
+
...props
|
|
1327
|
+
},
|
|
1328
|
+
ref,
|
|
1329
|
+
) => {
|
|
1330
|
+
const shell = useShell();
|
|
1331
|
+
const resolvedPresentation = useResponsivePresentation(presentation);
|
|
1332
|
+
const isOverlay = resolvedPresentation === 'overlay';
|
|
1333
|
+
const isStacked = resolvedPresentation === 'stacked';
|
|
1334
|
+
const localRef = React.useRef<HTMLDivElement | null>(null);
|
|
1335
|
+
const setRef = React.useCallback(
|
|
1336
|
+
(node: HTMLDivElement | null) => {
|
|
1337
|
+
localRef.current = node;
|
|
1338
|
+
if (typeof ref === 'function') ref(node);
|
|
1339
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
1340
|
+
},
|
|
1341
|
+
[ref],
|
|
1342
|
+
);
|
|
1343
|
+
const childArray = React.Children.toArray(children) as React.ReactElement[];
|
|
1344
|
+
const handleChildren = childArray.filter(
|
|
1345
|
+
(el: React.ReactElement) => React.isValidElement(el) && el.type === SidebarHandle,
|
|
1346
|
+
);
|
|
1347
|
+
const contentChildren = childArray.filter(
|
|
1348
|
+
(el: React.ReactElement) => !(React.isValidElement(el) && el.type === SidebarHandle),
|
|
792
1349
|
);
|
|
793
1350
|
|
|
794
|
-
//
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1351
|
+
// Register with shell
|
|
1352
|
+
const sidebarId = React.useId();
|
|
1353
|
+
React.useEffect(() => {
|
|
1354
|
+
shell.setHasSidebar(true);
|
|
1355
|
+
return () => {
|
|
1356
|
+
shell.setHasSidebar(false);
|
|
1357
|
+
};
|
|
1358
|
+
}, [shell, sidebarId]);
|
|
1359
|
+
|
|
1360
|
+
// Honor defaultMode on mount when uncontrolled
|
|
1361
|
+
const didInitRef = React.useRef(false);
|
|
1362
|
+
React.useEffect(() => {
|
|
1363
|
+
if (didInitRef.current) return;
|
|
1364
|
+
didInitRef.current = true;
|
|
1365
|
+
if (mode === undefined && shell.sidebarMode !== (defaultMode as SidebarMode)) {
|
|
1366
|
+
shell.setSidebarMode(defaultMode as SidebarMode);
|
|
1367
|
+
}
|
|
1368
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1369
|
+
}, []);
|
|
805
1370
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1371
|
+
// Sync controlled mode
|
|
1372
|
+
React.useEffect(() => {
|
|
1373
|
+
if (mode !== undefined && shell.sidebarMode !== mode) {
|
|
1374
|
+
shell.setSidebarMode(mode);
|
|
1375
|
+
}
|
|
1376
|
+
}, [mode, shell]);
|
|
1377
|
+
|
|
1378
|
+
// Emit mode changes
|
|
1379
|
+
React.useEffect(() => {
|
|
1380
|
+
if (mode === undefined) {
|
|
1381
|
+
onModeChange?.(shell.sidebarMode);
|
|
1382
|
+
}
|
|
1383
|
+
}, [shell.sidebarMode, mode, onModeChange]);
|
|
1384
|
+
|
|
1385
|
+
// Emit expand/collapse events
|
|
1386
|
+
React.useEffect(() => {
|
|
1387
|
+
if (shell.sidebarMode === 'expanded') {
|
|
1388
|
+
onExpand?.();
|
|
1389
|
+
} else {
|
|
1390
|
+
onCollapse?.();
|
|
1391
|
+
}
|
|
1392
|
+
}, [shell.sidebarMode, onExpand, onCollapse]);
|
|
1393
|
+
|
|
1394
|
+
// Option A: thin is width-only; content remains visible whenever not collapsed
|
|
1395
|
+
const isContentVisible = shell.sidebarMode !== 'collapsed';
|
|
1396
|
+
|
|
1397
|
+
// Default persistence if paneId provided and none supplied (fixed only)
|
|
1398
|
+
const persistenceAdapter = React.useMemo(() => {
|
|
1399
|
+
if (!paneId || persistence) return persistence;
|
|
1400
|
+
const key = `kookie-ui:shell:sidebar:${paneId}`;
|
|
1401
|
+
const adapter: PaneSizePersistence = {
|
|
1402
|
+
load: () => {
|
|
1403
|
+
if (typeof window === 'undefined') return undefined;
|
|
1404
|
+
const v = window.localStorage.getItem(key);
|
|
1405
|
+
return v ? Number(v) : undefined;
|
|
1406
|
+
},
|
|
1407
|
+
save: (size: number) => {
|
|
1408
|
+
if (typeof window === 'undefined') return;
|
|
1409
|
+
window.localStorage.setItem(key, String(size));
|
|
1410
|
+
},
|
|
1411
|
+
};
|
|
1412
|
+
return adapter;
|
|
1413
|
+
}, [paneId, persistence]);
|
|
1414
|
+
|
|
1415
|
+
React.useEffect(() => {
|
|
1416
|
+
let mounted = true;
|
|
1417
|
+
(async () => {
|
|
1418
|
+
if (!resizable || !persistenceAdapter?.load || isOverlay) return;
|
|
1419
|
+
const loaded = await persistenceAdapter.load();
|
|
1420
|
+
if (mounted && typeof loaded === 'number' && localRef.current) {
|
|
1421
|
+
localRef.current.style.setProperty('--sidebar-size', `${loaded}px`);
|
|
1422
|
+
onResize?.(loaded);
|
|
1423
|
+
}
|
|
1424
|
+
})();
|
|
1425
|
+
return () => {
|
|
1426
|
+
mounted = false;
|
|
1427
|
+
};
|
|
1428
|
+
}, [resizable, persistenceAdapter, onResize, isOverlay]);
|
|
1429
|
+
|
|
1430
|
+
// Register custom toggle behavior based on toggleModes
|
|
1431
|
+
const shellForToggle = useShell();
|
|
1432
|
+
React.useEffect(() => {
|
|
1433
|
+
if (!shellForToggle.setSidebarToggleComputer) return;
|
|
1434
|
+
// Build cycle from provided modes (defaults to both)
|
|
1435
|
+
const enabled = (
|
|
1436
|
+
toggleModes && toggleModes.length > 0 ? toggleModes : (['thin', 'expanded'] as const)
|
|
1437
|
+
) as Array<'thin' | 'expanded'>;
|
|
1438
|
+
const compute = (current: SidebarMode): SidebarMode => {
|
|
1439
|
+
if (current === 'collapsed') return enabled[0] ?? 'expanded';
|
|
1440
|
+
if (current === 'thin') {
|
|
1441
|
+
// if thin not enabled, go to collapsed; else if expanded enabled go expanded; else collapsed
|
|
1442
|
+
return enabled.includes('thin')
|
|
1443
|
+
? enabled.includes('expanded')
|
|
1444
|
+
? 'expanded'
|
|
1445
|
+
: 'collapsed'
|
|
1446
|
+
: 'collapsed';
|
|
1447
|
+
}
|
|
1448
|
+
// expanded
|
|
1449
|
+
if (enabled.length === 2 && enabled.includes('thin') && enabled.includes('expanded')) {
|
|
1450
|
+
return 'collapsed';
|
|
1451
|
+
}
|
|
1452
|
+
// if only expanded enabled, collapse next; if only thin enabled, go thin next
|
|
1453
|
+
return enabled.includes('thin') && !enabled.includes('expanded') ? 'thin' : 'collapsed';
|
|
1454
|
+
};
|
|
1455
|
+
shellForToggle.setSidebarToggleComputer(compute);
|
|
1456
|
+
return () => {
|
|
1457
|
+
// reset to default sequence when unmounting this Sidebar
|
|
1458
|
+
shellForToggle.setSidebarToggleComputer?.((cur) =>
|
|
1459
|
+
cur === 'collapsed' ? 'thin' : cur === 'thin' ? 'expanded' : 'collapsed',
|
|
1460
|
+
);
|
|
1461
|
+
};
|
|
1462
|
+
}, [shellForToggle, toggleModes]);
|
|
1463
|
+
|
|
1464
|
+
// Preserve last non-collapsed width for smooth overlay close animation
|
|
1465
|
+
const lastOverlayWidthRef = React.useRef<number>(expandedSize);
|
|
1466
|
+
const lastOverlayModeRef = React.useRef<SidebarMode>('expanded');
|
|
1467
|
+
React.useEffect(() => {
|
|
1468
|
+
if (shell.sidebarMode !== 'collapsed') {
|
|
1469
|
+
lastOverlayModeRef.current = shell.sidebarMode as SidebarMode;
|
|
1470
|
+
lastOverlayWidthRef.current = shell.sidebarMode === 'thin' ? thinSize : expandedSize;
|
|
1471
|
+
}
|
|
1472
|
+
}, [shell.sidebarMode, thinSize, expandedSize]);
|
|
1473
|
+
|
|
1474
|
+
// Always-follow responsive defaultMode for uncontrolled Sidebar (on breakpoint change only)
|
|
1475
|
+
const resolveResponsiveMode = React.useCallback((): SidebarMode => {
|
|
1476
|
+
if (typeof defaultMode === 'string') return defaultMode as SidebarMode;
|
|
1477
|
+
const dm = defaultMode as Partial<Record<Breakpoint, SidebarMode>> | undefined;
|
|
1478
|
+
if (dm && dm[shell.currentBreakpoint as Breakpoint]) {
|
|
1479
|
+
return dm[shell.currentBreakpoint as Breakpoint] as SidebarMode;
|
|
1480
|
+
}
|
|
1481
|
+
const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
|
|
1482
|
+
const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat(
|
|
1483
|
+
'initial' as Breakpoint,
|
|
1484
|
+
);
|
|
1485
|
+
const startIdx = order.indexOf(shell.currentBreakpoint as Breakpoint);
|
|
1486
|
+
for (let i = startIdx + 1; i < order.length; i++) {
|
|
1487
|
+
const bp = order[i];
|
|
1488
|
+
if (dm && dm[bp]) return dm[bp] as SidebarMode;
|
|
1489
|
+
}
|
|
1490
|
+
return 'collapsed';
|
|
1491
|
+
}, [defaultMode, shell.currentBreakpoint]);
|
|
1492
|
+
|
|
1493
|
+
const lastSidebarBpRef = React.useRef<Breakpoint | null>(null);
|
|
1494
|
+
React.useEffect(() => {
|
|
1495
|
+
if (mode !== undefined) return; // controlled wins
|
|
1496
|
+
if (!shell.currentBreakpointReady) return; // avoid SSR mismatch
|
|
1497
|
+
if (lastSidebarBpRef.current === shell.currentBreakpoint) return; // only on bp change
|
|
1498
|
+
lastSidebarBpRef.current = shell.currentBreakpoint as Breakpoint;
|
|
1499
|
+
const next = resolveResponsiveMode();
|
|
1500
|
+
if (next !== shell.sidebarMode) shell.setSidebarMode(next);
|
|
1501
|
+
}, [
|
|
1502
|
+
mode,
|
|
1503
|
+
shell.currentBreakpoint,
|
|
1504
|
+
shell.currentBreakpointReady,
|
|
1505
|
+
resolveResponsiveMode,
|
|
1506
|
+
shell.sidebarMode,
|
|
1507
|
+
shell.setSidebarMode,
|
|
1508
|
+
]);
|
|
1509
|
+
|
|
1510
|
+
const handleEl =
|
|
1511
|
+
resizable && !isOverlay && shell.sidebarMode === 'expanded' ? (
|
|
1512
|
+
<PaneResizeContext.Provider
|
|
1513
|
+
value={{
|
|
1514
|
+
containerRef: localRef,
|
|
1515
|
+
cssVarName: '--sidebar-size',
|
|
1516
|
+
minSize,
|
|
1517
|
+
maxSize,
|
|
1518
|
+
defaultSize: expandedSize,
|
|
1519
|
+
orientation: 'vertical',
|
|
1520
|
+
edge: 'end',
|
|
1521
|
+
computeNext: (client, startClient, startSize) => {
|
|
1522
|
+
const isRtl = getComputedStyle(localRef.current!).direction === 'rtl';
|
|
1523
|
+
const delta = client - startClient;
|
|
1524
|
+
return startSize + (isRtl ? -delta : delta);
|
|
1525
|
+
},
|
|
1526
|
+
onResize,
|
|
1527
|
+
onResizeStart,
|
|
1528
|
+
onResizeEnd: (size) => {
|
|
1529
|
+
onResizeEnd?.(size);
|
|
1530
|
+
persistenceAdapter?.save?.(size);
|
|
1531
|
+
},
|
|
1532
|
+
target: 'sidebar',
|
|
1533
|
+
collapsible,
|
|
1534
|
+
snapPoints,
|
|
1535
|
+
snapTolerance: snapTolerance ?? 8,
|
|
1536
|
+
collapseThreshold,
|
|
1537
|
+
requestCollapse: () => shell.setSidebarMode('collapsed'),
|
|
1538
|
+
requestToggle: () => shell.togglePane('sidebar'),
|
|
1539
|
+
}}
|
|
1540
|
+
>
|
|
1541
|
+
{handleChildren.length > 0 ? (
|
|
1542
|
+
handleChildren.map((el, i) => React.cloneElement(el, { key: el.key ?? i }))
|
|
1543
|
+
) : (
|
|
1544
|
+
<PaneHandle />
|
|
1545
|
+
)}
|
|
1546
|
+
</PaneResizeContext.Provider>
|
|
1547
|
+
) : null;
|
|
1548
|
+
|
|
1549
|
+
if (isOverlay) {
|
|
1550
|
+
const open = shell.sidebarMode !== 'collapsed';
|
|
1551
|
+
return (
|
|
1552
|
+
<Sheet.Root
|
|
1553
|
+
open={open}
|
|
1554
|
+
onOpenChange={(o) => shell.setSidebarMode(o ? 'expanded' : 'collapsed')}
|
|
1555
|
+
>
|
|
809
1556
|
<Sheet.Content
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1557
|
+
side="start"
|
|
1558
|
+
style={{ padding: 0 }}
|
|
1559
|
+
width={{
|
|
1560
|
+
initial: `${open ? (shell.sidebarMode === 'thin' ? thinSize : expandedSize) : lastOverlayWidthRef.current}px`,
|
|
1561
|
+
}}
|
|
815
1562
|
>
|
|
816
|
-
<
|
|
817
|
-
<
|
|
818
|
-
</
|
|
819
|
-
{
|
|
1563
|
+
<VisuallyHidden>
|
|
1564
|
+
<Sheet.Title>Sidebar</Sheet.Title>
|
|
1565
|
+
</VisuallyHidden>
|
|
1566
|
+
{children}
|
|
820
1567
|
</Sheet.Content>
|
|
821
1568
|
</Sheet.Root>
|
|
822
|
-
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
return (
|
|
1573
|
+
<div
|
|
1574
|
+
{...props}
|
|
1575
|
+
ref={setRef}
|
|
1576
|
+
className={classNames('rt-ShellSidebar', className)}
|
|
1577
|
+
data-mode={shell.sidebarMode}
|
|
1578
|
+
data-peek={shell.peekTarget === 'sidebar' || undefined}
|
|
1579
|
+
data-presentation={resolvedPresentation}
|
|
1580
|
+
style={{
|
|
1581
|
+
...style,
|
|
1582
|
+
['--sidebar-size' as any]: `${expandedSize}px`,
|
|
1583
|
+
['--sidebar-thin-size' as any]: `${thinSize}px`,
|
|
1584
|
+
['--sidebar-min-size' as any]: `${minSize}px`,
|
|
1585
|
+
['--sidebar-max-size' as any]: `${maxSize}px`,
|
|
1586
|
+
// When peeking in fixed presentation, use the next toggle target's width (thin or expanded)
|
|
1587
|
+
...(shell.peekTarget === 'sidebar' && !isOverlay
|
|
1588
|
+
? (() => {
|
|
1589
|
+
const enabled = (
|
|
1590
|
+
toggleModes && toggleModes.length > 0
|
|
1591
|
+
? toggleModes
|
|
1592
|
+
: (['thin', 'expanded'] as const)
|
|
1593
|
+
) as Array<'thin' | 'expanded'>;
|
|
1594
|
+
const current = shell.sidebarMode as SidebarMode;
|
|
1595
|
+
let next: SidebarMode = 'collapsed';
|
|
1596
|
+
if (current === 'collapsed') {
|
|
1597
|
+
next = (enabled[0] ?? 'expanded') as SidebarMode;
|
|
1598
|
+
} else if (current === 'thin') {
|
|
1599
|
+
next = enabled.includes('expanded') ? 'expanded' : 'collapsed';
|
|
1600
|
+
} else {
|
|
1601
|
+
next = enabled.includes('thin') ? 'thin' : 'collapsed';
|
|
1602
|
+
}
|
|
1603
|
+
const peekWidth = next === 'thin' ? thinSize : expandedSize;
|
|
1604
|
+
return { ['--peek-sidebar-width' as any]: `${peekWidth}px` } as React.CSSProperties;
|
|
1605
|
+
})()
|
|
1606
|
+
: {}),
|
|
1607
|
+
}}
|
|
1608
|
+
>
|
|
1609
|
+
<div className="rt-ShellSidebarContent" data-visible={isContentVisible || undefined}>
|
|
1610
|
+
{contentChildren}
|
|
1611
|
+
</div>
|
|
1612
|
+
{handleEl}
|
|
1613
|
+
</div>
|
|
823
1614
|
);
|
|
824
|
-
}
|
|
1615
|
+
},
|
|
1616
|
+
) as SidebarComponent;
|
|
1617
|
+
Sidebar.displayName = 'Shell.Sidebar';
|
|
1618
|
+
Sidebar.Handle = SidebarHandle;
|
|
825
1619
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
{
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1620
|
+
// Content (always required)
|
|
1621
|
+
interface ShellContentProps extends React.ComponentPropsWithoutRef<'main'> {}
|
|
1622
|
+
|
|
1623
|
+
const Content = React.forwardRef<HTMLElement, ShellContentProps>(({ className, ...props }, ref) => (
|
|
1624
|
+
<main {...props} ref={ref} className={classNames('rt-ShellContent', className)} />
|
|
1625
|
+
));
|
|
1626
|
+
Content.displayName = 'Shell.Content';
|
|
1627
|
+
|
|
1628
|
+
// Inspector
|
|
1629
|
+
const Inspector = React.forwardRef<HTMLDivElement, PaneProps>(
|
|
1630
|
+
(
|
|
1631
|
+
{
|
|
1632
|
+
className,
|
|
1633
|
+
presentation = { initial: 'overlay', lg: 'fixed' },
|
|
1634
|
+
mode,
|
|
1635
|
+
defaultMode = 'collapsed',
|
|
1636
|
+
onModeChange,
|
|
1637
|
+
expandedSize = 320,
|
|
1638
|
+
minSize = 200,
|
|
1639
|
+
maxSize = 500,
|
|
1640
|
+
resizable = false,
|
|
1641
|
+
collapsible = true,
|
|
1642
|
+
onExpand,
|
|
1643
|
+
onCollapse,
|
|
1644
|
+
onResize,
|
|
1645
|
+
onResizeStart,
|
|
1646
|
+
onResizeEnd,
|
|
1647
|
+
snapPoints,
|
|
1648
|
+
snapTolerance,
|
|
1649
|
+
collapseThreshold,
|
|
1650
|
+
paneId,
|
|
1651
|
+
persistence,
|
|
1652
|
+
children,
|
|
1653
|
+
style,
|
|
1654
|
+
...props
|
|
1655
|
+
},
|
|
1656
|
+
ref,
|
|
1657
|
+
) => {
|
|
1658
|
+
const shell = useShell();
|
|
1659
|
+
const resolvedPresentation = useResponsivePresentation(presentation);
|
|
1660
|
+
const isOverlay = resolvedPresentation === 'overlay';
|
|
1661
|
+
const isStacked = resolvedPresentation === 'stacked';
|
|
1662
|
+
const localRef = React.useRef<HTMLDivElement | null>(null);
|
|
1663
|
+
const setRef = React.useCallback(
|
|
1664
|
+
(node: HTMLDivElement | null) => {
|
|
1665
|
+
localRef.current = node;
|
|
1666
|
+
if (typeof ref === 'function') ref(node);
|
|
1667
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
1668
|
+
},
|
|
1669
|
+
[ref],
|
|
1670
|
+
);
|
|
1671
|
+
const childArray = React.Children.toArray(children) as React.ReactElement[];
|
|
1672
|
+
const handleChildren = childArray.filter(
|
|
1673
|
+
(el: React.ReactElement) => React.isValidElement(el) && el.type === InspectorHandle,
|
|
1674
|
+
);
|
|
1675
|
+
const contentChildren = childArray.filter(
|
|
1676
|
+
(el: React.ReactElement) => !(React.isValidElement(el) && el.type === InspectorHandle),
|
|
1677
|
+
);
|
|
1678
|
+
|
|
1679
|
+
// Apply responsive defaultMode only on mount and when breakpoint changes (uncontrolled Inspector)
|
|
1680
|
+
const resolveResponsiveMode = React.useCallback((): PaneMode => {
|
|
1681
|
+
if (typeof defaultMode === 'string') return defaultMode as PaneMode;
|
|
1682
|
+
const dm = defaultMode as Partial<Record<Breakpoint, PaneMode>> | undefined;
|
|
1683
|
+
if (dm && dm[shell.currentBreakpoint as Breakpoint]) {
|
|
1684
|
+
return dm[shell.currentBreakpoint as Breakpoint] as PaneMode;
|
|
1685
|
+
}
|
|
1686
|
+
const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
|
|
1687
|
+
const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat(
|
|
1688
|
+
'initial' as Breakpoint,
|
|
1689
|
+
);
|
|
1690
|
+
const startIdx = order.indexOf(shell.currentBreakpoint as Breakpoint);
|
|
1691
|
+
for (let i = startIdx + 1; i < order.length; i++) {
|
|
1692
|
+
const bp = order[i];
|
|
1693
|
+
if (dm && dm[bp]) {
|
|
1694
|
+
return dm[bp] as PaneMode;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
return 'collapsed';
|
|
1698
|
+
}, [defaultMode, shell.currentBreakpoint]);
|
|
1699
|
+
|
|
1700
|
+
const lastInspectorBpRef = React.useRef<Breakpoint | null>(null);
|
|
1701
|
+
React.useEffect(() => {
|
|
1702
|
+
if (mode !== undefined) return; // controlled wins
|
|
1703
|
+
if (!shell.currentBreakpointReady) return; // avoid SSR mismatch
|
|
1704
|
+
if (lastInspectorBpRef.current === shell.currentBreakpoint) return; // only on bp change
|
|
1705
|
+
lastInspectorBpRef.current = shell.currentBreakpoint as Breakpoint;
|
|
1706
|
+
const next = resolveResponsiveMode();
|
|
1707
|
+
if (next !== shell.inspectorMode) {
|
|
1708
|
+
shell.setInspectorMode(next);
|
|
1709
|
+
}
|
|
1710
|
+
}, [
|
|
1711
|
+
mode,
|
|
1712
|
+
shell.currentBreakpoint,
|
|
1713
|
+
shell.currentBreakpointReady,
|
|
1714
|
+
resolveResponsiveMode,
|
|
1715
|
+
shell.inspectorMode,
|
|
1716
|
+
shell.setInspectorMode,
|
|
1717
|
+
]);
|
|
1718
|
+
|
|
1719
|
+
// Sync controlled mode
|
|
1720
|
+
React.useEffect(() => {
|
|
1721
|
+
if (mode !== undefined && shell.inspectorMode !== mode) {
|
|
1722
|
+
shell.setInspectorMode(mode);
|
|
1723
|
+
}
|
|
1724
|
+
}, [mode, shell]);
|
|
1725
|
+
|
|
1726
|
+
// Emit mode changes
|
|
1727
|
+
React.useEffect(() => {
|
|
1728
|
+
if (mode === undefined) {
|
|
1729
|
+
onModeChange?.(shell.inspectorMode);
|
|
1730
|
+
}
|
|
1731
|
+
}, [shell.inspectorMode, mode, onModeChange]);
|
|
1732
|
+
|
|
1733
|
+
// Emit expand/collapse events
|
|
1734
|
+
React.useEffect(() => {
|
|
1735
|
+
if (shell.inspectorMode === 'expanded') {
|
|
1736
|
+
onExpand?.();
|
|
1737
|
+
} else {
|
|
1738
|
+
onCollapse?.();
|
|
1739
|
+
}
|
|
1740
|
+
}, [shell.inspectorMode, onExpand, onCollapse]);
|
|
1741
|
+
|
|
1742
|
+
const isExpanded = shell.inspectorMode === 'expanded';
|
|
1743
|
+
|
|
1744
|
+
// Default persistence if paneId provided and none supplied (fixed only)
|
|
1745
|
+
const persistenceAdapter = React.useMemo(() => {
|
|
1746
|
+
if (!paneId || persistence) return persistence;
|
|
1747
|
+
const key = `kookie-ui:shell:inspector:${paneId}`;
|
|
1748
|
+
const adapter: PaneSizePersistence = {
|
|
1749
|
+
load: () => {
|
|
1750
|
+
if (typeof window === 'undefined') return undefined;
|
|
1751
|
+
const v = window.localStorage.getItem(key);
|
|
1752
|
+
return v ? Number(v) : undefined;
|
|
1753
|
+
},
|
|
1754
|
+
save: (size: number) => {
|
|
1755
|
+
if (typeof window === 'undefined') return;
|
|
1756
|
+
window.localStorage.setItem(key, String(size));
|
|
1757
|
+
},
|
|
1758
|
+
};
|
|
1759
|
+
return adapter;
|
|
1760
|
+
}, [paneId, persistence]);
|
|
1761
|
+
|
|
1762
|
+
React.useEffect(() => {
|
|
1763
|
+
let mounted = true;
|
|
1764
|
+
(async () => {
|
|
1765
|
+
if (!resizable || !persistenceAdapter?.load || isOverlay) return;
|
|
1766
|
+
const loaded = await persistenceAdapter.load();
|
|
1767
|
+
if (mounted && typeof loaded === 'number' && localRef.current) {
|
|
1768
|
+
localRef.current.style.setProperty('--inspector-size', `${loaded}px`);
|
|
1769
|
+
onResize?.(loaded);
|
|
1770
|
+
}
|
|
1771
|
+
})();
|
|
1772
|
+
return () => {
|
|
1773
|
+
mounted = false;
|
|
1774
|
+
};
|
|
1775
|
+
}, [resizable, persistenceAdapter, onResize, isOverlay]);
|
|
1776
|
+
|
|
1777
|
+
const handleEl =
|
|
1778
|
+
resizable && !isOverlay && isExpanded ? (
|
|
1779
|
+
<PaneResizeContext.Provider
|
|
1780
|
+
value={{
|
|
1781
|
+
containerRef: localRef,
|
|
1782
|
+
cssVarName: '--inspector-size',
|
|
1783
|
+
minSize,
|
|
1784
|
+
maxSize,
|
|
1785
|
+
defaultSize: expandedSize,
|
|
1786
|
+
orientation: 'vertical',
|
|
1787
|
+
edge: 'start',
|
|
1788
|
+
computeNext: (client, startClient, startSize) => {
|
|
1789
|
+
const isRtl = getComputedStyle(localRef.current!).direction === 'rtl';
|
|
1790
|
+
const delta = client - startClient;
|
|
1791
|
+
// start edge; reverse for LTR
|
|
1792
|
+
return startSize + (isRtl ? delta : -delta);
|
|
1793
|
+
},
|
|
1794
|
+
onResize,
|
|
1795
|
+
onResizeStart,
|
|
1796
|
+
onResizeEnd: (size) => {
|
|
1797
|
+
onResizeEnd?.(size);
|
|
1798
|
+
persistenceAdapter?.save?.(size);
|
|
1799
|
+
},
|
|
1800
|
+
target: 'inspector',
|
|
1801
|
+
collapsible,
|
|
1802
|
+
snapPoints,
|
|
1803
|
+
snapTolerance: snapTolerance ?? 8,
|
|
1804
|
+
collapseThreshold,
|
|
1805
|
+
requestCollapse: () => shell.setInspectorMode('collapsed'),
|
|
1806
|
+
requestToggle: () => shell.togglePane('inspector'),
|
|
892
1807
|
}}
|
|
893
1808
|
>
|
|
894
|
-
{
|
|
1809
|
+
{handleChildren.length > 0 ? (
|
|
1810
|
+
handleChildren.map((el, i) => React.cloneElement(el, { key: el.key ?? i }))
|
|
1811
|
+
) : (
|
|
1812
|
+
<PaneHandle />
|
|
1813
|
+
)}
|
|
1814
|
+
</PaneResizeContext.Provider>
|
|
1815
|
+
) : null;
|
|
1816
|
+
|
|
1817
|
+
if (isOverlay) {
|
|
1818
|
+
const open = shell.inspectorMode === 'expanded';
|
|
1819
|
+
return (
|
|
1820
|
+
<Sheet.Root
|
|
1821
|
+
open={open}
|
|
1822
|
+
onOpenChange={(o) => shell.setInspectorMode(o ? 'expanded' : 'collapsed')}
|
|
1823
|
+
>
|
|
1824
|
+
<Sheet.Content side="end" style={{ padding: 0 }} width={{ initial: `${expandedSize}px` }}>
|
|
1825
|
+
<VisuallyHidden>
|
|
1826
|
+
<Sheet.Title>Inspector</Sheet.Title>
|
|
1827
|
+
</VisuallyHidden>
|
|
1828
|
+
{children}
|
|
1829
|
+
</Sheet.Content>
|
|
1830
|
+
</Sheet.Root>
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
return (
|
|
1835
|
+
<div
|
|
1836
|
+
{...props}
|
|
1837
|
+
ref={setRef}
|
|
1838
|
+
className={classNames('rt-ShellInspector', className)}
|
|
1839
|
+
data-mode={shell.inspectorMode}
|
|
1840
|
+
data-peek={shell.peekTarget === 'inspector' || undefined}
|
|
1841
|
+
data-presentation={resolvedPresentation}
|
|
1842
|
+
data-open={(isStacked && isExpanded) || undefined}
|
|
1843
|
+
style={{
|
|
1844
|
+
...style,
|
|
1845
|
+
['--inspector-size' as any]: `${expandedSize}px`,
|
|
1846
|
+
['--inspector-min-size' as any]: `${minSize}px`,
|
|
1847
|
+
['--inspector-max-size' as any]: `${maxSize}px`,
|
|
1848
|
+
}}
|
|
1849
|
+
>
|
|
1850
|
+
<div className="rt-ShellInspectorContent" data-visible={isExpanded || undefined}>
|
|
1851
|
+
{contentChildren}
|
|
895
1852
|
</div>
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
}
|
|
1853
|
+
{handleEl}
|
|
1854
|
+
</div>
|
|
1855
|
+
);
|
|
1856
|
+
},
|
|
1857
|
+
) as InspectorComponent;
|
|
1858
|
+
Inspector.displayName = 'Shell.Inspector';
|
|
1859
|
+
Inspector.Handle = InspectorHandle;
|
|
900
1860
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1861
|
+
// Bottom
|
|
1862
|
+
const Bottom = React.forwardRef<HTMLDivElement, PaneProps>(
|
|
1863
|
+
(
|
|
1864
|
+
{
|
|
1865
|
+
className,
|
|
1866
|
+
presentation = 'fixed', // Bottom is usually fixed
|
|
1867
|
+
mode,
|
|
1868
|
+
defaultMode = 'collapsed',
|
|
1869
|
+
onModeChange,
|
|
1870
|
+
expandedSize = 200,
|
|
1871
|
+
minSize = 100,
|
|
1872
|
+
maxSize = 400,
|
|
1873
|
+
resizable = false,
|
|
1874
|
+
collapsible = true,
|
|
1875
|
+
onExpand,
|
|
1876
|
+
onCollapse,
|
|
1877
|
+
onResize,
|
|
1878
|
+
onResizeStart,
|
|
1879
|
+
onResizeEnd,
|
|
1880
|
+
snapPoints,
|
|
1881
|
+
snapTolerance,
|
|
1882
|
+
collapseThreshold,
|
|
1883
|
+
paneId,
|
|
1884
|
+
persistence,
|
|
1885
|
+
children,
|
|
1886
|
+
style,
|
|
1887
|
+
...props
|
|
1888
|
+
},
|
|
1889
|
+
ref,
|
|
1890
|
+
) => {
|
|
1891
|
+
const shell = useShell();
|
|
1892
|
+
const resolvedPresentation = useResponsivePresentation(presentation);
|
|
1893
|
+
const isOverlay = resolvedPresentation === 'overlay';
|
|
1894
|
+
const isStacked = resolvedPresentation === 'stacked';
|
|
1895
|
+
const localRef = React.useRef<HTMLDivElement | null>(null);
|
|
1896
|
+
const setRef = React.useCallback(
|
|
1897
|
+
(node: HTMLDivElement | null) => {
|
|
1898
|
+
localRef.current = node;
|
|
1899
|
+
if (typeof ref === 'function') ref(node);
|
|
1900
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
1901
|
+
},
|
|
1902
|
+
[ref],
|
|
1903
|
+
);
|
|
1904
|
+
const childArray = React.Children.toArray(children) as React.ReactElement[];
|
|
1905
|
+
const handleChildren = childArray.filter(
|
|
1906
|
+
(el: React.ReactElement) => React.isValidElement(el) && el.type === BottomHandle,
|
|
1907
|
+
);
|
|
1908
|
+
const contentChildren = childArray.filter(
|
|
1909
|
+
(el: React.ReactElement) => !(React.isValidElement(el) && el.type === BottomHandle),
|
|
1910
|
+
);
|
|
905
1911
|
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1912
|
+
// Honor defaultMode on mount when uncontrolled
|
|
1913
|
+
const didInitRef = React.useRef(false);
|
|
1914
|
+
React.useEffect(() => {
|
|
1915
|
+
if (didInitRef.current) return;
|
|
1916
|
+
didInitRef.current = true;
|
|
1917
|
+
if (mode === undefined && shell.bottomMode !== (defaultMode as PaneMode)) {
|
|
1918
|
+
shell.setBottomMode(defaultMode as PaneMode);
|
|
1919
|
+
}
|
|
1920
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1921
|
+
}, []);
|
|
911
1922
|
|
|
912
|
-
|
|
913
|
-
|
|
1923
|
+
// Sync controlled mode
|
|
1924
|
+
React.useEffect(() => {
|
|
1925
|
+
if (mode !== undefined && shell.bottomMode !== mode) {
|
|
1926
|
+
shell.setBottomMode(mode);
|
|
1927
|
+
}
|
|
1928
|
+
}, [mode, shell]);
|
|
1929
|
+
|
|
1930
|
+
// Emit mode changes
|
|
1931
|
+
React.useEffect(() => {
|
|
1932
|
+
if (mode === undefined) {
|
|
1933
|
+
onModeChange?.(shell.bottomMode);
|
|
1934
|
+
}
|
|
1935
|
+
}, [shell.bottomMode, mode, onModeChange]);
|
|
1936
|
+
|
|
1937
|
+
// Emit expand/collapse events
|
|
1938
|
+
React.useEffect(() => {
|
|
1939
|
+
if (shell.bottomMode === 'expanded') {
|
|
1940
|
+
onExpand?.();
|
|
1941
|
+
} else {
|
|
1942
|
+
onCollapse?.();
|
|
1943
|
+
}
|
|
1944
|
+
}, [shell.bottomMode, onExpand, onCollapse]);
|
|
1945
|
+
|
|
1946
|
+
const isExpanded = shell.bottomMode === 'expanded';
|
|
1947
|
+
|
|
1948
|
+
// Default persistence if paneId provided and none supplied (fixed only)
|
|
1949
|
+
const persistenceAdapter = React.useMemo(() => {
|
|
1950
|
+
if (!paneId || persistence) return persistence;
|
|
1951
|
+
const key = `kookie-ui:shell:bottom:${paneId}`;
|
|
1952
|
+
const adapter: PaneSizePersistence = {
|
|
1953
|
+
load: () => {
|
|
1954
|
+
if (typeof window === 'undefined') return undefined;
|
|
1955
|
+
const v = window.localStorage.getItem(key);
|
|
1956
|
+
return v ? Number(v) : undefined;
|
|
1957
|
+
},
|
|
1958
|
+
save: (size: number) => {
|
|
1959
|
+
if (typeof window === 'undefined') return;
|
|
1960
|
+
window.localStorage.setItem(key, String(size));
|
|
1961
|
+
},
|
|
1962
|
+
};
|
|
1963
|
+
return adapter;
|
|
1964
|
+
}, [paneId, persistence]);
|
|
1965
|
+
|
|
1966
|
+
React.useEffect(() => {
|
|
1967
|
+
let mounted = true;
|
|
1968
|
+
(async () => {
|
|
1969
|
+
if (!resizable || !persistenceAdapter?.load || isOverlay) return;
|
|
1970
|
+
const loaded = await persistenceAdapter.load();
|
|
1971
|
+
if (mounted && typeof loaded === 'number' && localRef.current) {
|
|
1972
|
+
localRef.current.style.setProperty('--bottom-size', `${loaded}px`);
|
|
1973
|
+
onResize?.(loaded);
|
|
1974
|
+
}
|
|
1975
|
+
})();
|
|
1976
|
+
return () => {
|
|
1977
|
+
mounted = false;
|
|
1978
|
+
};
|
|
1979
|
+
}, [resizable, persistenceAdapter, onResize, isOverlay]);
|
|
1980
|
+
|
|
1981
|
+
const handleEl =
|
|
1982
|
+
resizable && !isOverlay && isExpanded ? (
|
|
1983
|
+
<PaneResizeContext.Provider
|
|
1984
|
+
value={{
|
|
1985
|
+
containerRef: localRef,
|
|
1986
|
+
cssVarName: '--bottom-size',
|
|
1987
|
+
minSize,
|
|
1988
|
+
maxSize,
|
|
1989
|
+
defaultSize: expandedSize,
|
|
1990
|
+
orientation: 'horizontal',
|
|
1991
|
+
edge: 'start',
|
|
1992
|
+
computeNext: (client, startClient, startSize) => {
|
|
1993
|
+
const delta = client - startClient;
|
|
1994
|
+
return startSize - delta; // drag up reduces size
|
|
1995
|
+
},
|
|
1996
|
+
onResize,
|
|
1997
|
+
onResizeStart,
|
|
1998
|
+
onResizeEnd: (size) => {
|
|
1999
|
+
onResizeEnd?.(size);
|
|
2000
|
+
persistenceAdapter?.save?.(size);
|
|
2001
|
+
},
|
|
2002
|
+
target: 'bottom',
|
|
2003
|
+
collapsible,
|
|
2004
|
+
snapPoints,
|
|
2005
|
+
snapTolerance: snapTolerance ?? 8,
|
|
2006
|
+
collapseThreshold,
|
|
2007
|
+
requestCollapse: () => shell.setBottomMode('collapsed'),
|
|
2008
|
+
requestToggle: () => shell.togglePane('bottom'),
|
|
2009
|
+
}}
|
|
2010
|
+
>
|
|
2011
|
+
{handleChildren.length > 0 ? (
|
|
2012
|
+
handleChildren.map((el, i) => React.cloneElement(el, { key: el.key ?? i }))
|
|
2013
|
+
) : (
|
|
2014
|
+
<PaneHandle />
|
|
2015
|
+
)}
|
|
2016
|
+
</PaneResizeContext.Provider>
|
|
2017
|
+
) : null;
|
|
2018
|
+
|
|
2019
|
+
if (isOverlay) {
|
|
2020
|
+
const open = shell.bottomMode === 'expanded';
|
|
2021
|
+
return (
|
|
2022
|
+
<Sheet.Root
|
|
2023
|
+
open={open}
|
|
2024
|
+
onOpenChange={(o) => shell.setBottomMode(o ? 'expanded' : 'collapsed')}
|
|
2025
|
+
>
|
|
2026
|
+
<Sheet.Content
|
|
2027
|
+
side="bottom"
|
|
2028
|
+
style={{ padding: 0 }}
|
|
2029
|
+
height={{ initial: `${expandedSize}px` }}
|
|
2030
|
+
>
|
|
2031
|
+
<VisuallyHidden>
|
|
2032
|
+
<Sheet.Title>Bottom panel</Sheet.Title>
|
|
2033
|
+
</VisuallyHidden>
|
|
2034
|
+
{children}
|
|
2035
|
+
</Sheet.Content>
|
|
2036
|
+
</Sheet.Root>
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
914
2039
|
|
|
915
|
-
// Local Trigger (inside a sidebar)
|
|
916
|
-
import type { IconButtonProps } from './icon-button.js';
|
|
917
|
-
type LocalTriggerProps = IconButtonProps<'button'>;
|
|
918
|
-
/**
|
|
919
|
-
* `Shell.Sidebar.Trigger` toggles the sidebar where it is rendered.
|
|
920
|
-
* - In split-mode: toggles rail open/collapsed
|
|
921
|
-
* - In single-markup: cycles through panel → rail → collapsed
|
|
922
|
-
*/
|
|
923
|
-
const LocalTrigger = React.forwardRef<React.ElementRef<typeof IconButton>, LocalTriggerProps>(
|
|
924
|
-
({ onClick, children, ...props }, ref) => {
|
|
925
|
-
const section = useSidebarSection();
|
|
926
|
-
const shell = useShell();
|
|
927
|
-
const side = section?.side ?? 'start';
|
|
928
|
-
const controlsId = shell.getRegionId(side);
|
|
929
|
-
const expanded =
|
|
930
|
-
shell.patternBySide[side] === 'split'
|
|
931
|
-
? shell.railBySide[side] === 'open'
|
|
932
|
-
: shell.singleViewBySide[side] !== 'collapsed';
|
|
933
2040
|
return (
|
|
934
|
-
<
|
|
2041
|
+
<div
|
|
935
2042
|
{...props}
|
|
936
|
-
ref={
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
2043
|
+
ref={setRef}
|
|
2044
|
+
className={classNames('rt-ShellBottom', className)}
|
|
2045
|
+
data-mode={shell.bottomMode}
|
|
2046
|
+
data-peek={shell.peekTarget === 'bottom' || undefined}
|
|
2047
|
+
data-presentation={resolvedPresentation}
|
|
2048
|
+
data-open={(isStacked && isExpanded) || undefined}
|
|
2049
|
+
style={{
|
|
2050
|
+
...style,
|
|
2051
|
+
['--bottom-size' as any]: `${expandedSize}px`,
|
|
2052
|
+
['--bottom-min-size' as any]: `${minSize}px`,
|
|
2053
|
+
['--bottom-max-size' as any]: `${maxSize}px`,
|
|
944
2054
|
}}
|
|
945
2055
|
>
|
|
946
|
-
{
|
|
947
|
-
|
|
2056
|
+
<div className="rt-ShellBottomContent" data-visible={isExpanded || undefined}>
|
|
2057
|
+
{contentChildren}
|
|
2058
|
+
</div>
|
|
2059
|
+
{handleEl}
|
|
2060
|
+
</div>
|
|
948
2061
|
);
|
|
949
2062
|
},
|
|
950
|
-
);
|
|
951
|
-
|
|
2063
|
+
) as BottomComponent;
|
|
2064
|
+
Bottom.displayName = 'Shell.Bottom';
|
|
2065
|
+
Bottom.Handle = BottomHandle;
|
|
2066
|
+
|
|
2067
|
+
// Trigger
|
|
2068
|
+
type PaneTarget = 'left' | 'rail' | 'panel' | 'sidebar' | 'inspector' | 'bottom';
|
|
2069
|
+
type TriggerAction = 'toggle' | 'expand' | 'collapse';
|
|
2070
|
+
|
|
2071
|
+
interface TriggerProps extends React.ComponentPropsWithoutRef<'button'> {
|
|
2072
|
+
target: PaneTarget;
|
|
2073
|
+
action?: TriggerAction;
|
|
2074
|
+
/**
|
|
2075
|
+
* If true, peeks the target on hover and clears on leave.
|
|
2076
|
+
* If set to 'collapsed', only peeks when the target is currently collapsed (recommended).
|
|
2077
|
+
*/
|
|
2078
|
+
peekOnHover?: boolean | 'collapsed';
|
|
2079
|
+
}
|
|
952
2080
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
2081
|
+
const Trigger = React.forwardRef<HTMLButtonElement, TriggerProps>(
|
|
2082
|
+
(
|
|
2083
|
+
{
|
|
2084
|
+
target,
|
|
2085
|
+
action = 'toggle',
|
|
2086
|
+
peekOnHover,
|
|
2087
|
+
onClick,
|
|
2088
|
+
onMouseEnter,
|
|
2089
|
+
onMouseLeave,
|
|
2090
|
+
children,
|
|
2091
|
+
...props
|
|
2092
|
+
},
|
|
2093
|
+
ref,
|
|
2094
|
+
) => {
|
|
961
2095
|
const shell = useShell();
|
|
962
|
-
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
2096
|
+
|
|
2097
|
+
const handleClick = React.useCallback(
|
|
2098
|
+
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
2099
|
+
onClick?.(event);
|
|
2100
|
+
|
|
2101
|
+
switch (action) {
|
|
2102
|
+
case 'toggle':
|
|
2103
|
+
shell.togglePane(target);
|
|
2104
|
+
break;
|
|
2105
|
+
case 'expand':
|
|
2106
|
+
shell.expandPane(target);
|
|
2107
|
+
break;
|
|
2108
|
+
case 'collapse':
|
|
2109
|
+
shell.collapsePane(target);
|
|
2110
|
+
break;
|
|
2111
|
+
}
|
|
2112
|
+
},
|
|
2113
|
+
[shell, target, action, onClick],
|
|
2114
|
+
);
|
|
2115
|
+
|
|
2116
|
+
const isCollapsed = (() => {
|
|
2117
|
+
switch (target) {
|
|
2118
|
+
case 'left':
|
|
2119
|
+
case 'rail':
|
|
2120
|
+
return shell.leftMode === 'collapsed';
|
|
2121
|
+
case 'panel':
|
|
2122
|
+
return shell.leftMode === 'collapsed' || shell.panelMode === 'collapsed';
|
|
2123
|
+
case 'sidebar':
|
|
2124
|
+
return shell.sidebarMode === 'collapsed';
|
|
2125
|
+
case 'inspector':
|
|
2126
|
+
return shell.inspectorMode === 'collapsed';
|
|
2127
|
+
case 'bottom':
|
|
2128
|
+
return shell.bottomMode === 'collapsed';
|
|
2129
|
+
}
|
|
2130
|
+
})();
|
|
2131
|
+
|
|
2132
|
+
const handleMouseEnter = React.useCallback(
|
|
2133
|
+
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
2134
|
+
onMouseEnter?.(event);
|
|
2135
|
+
if (!peekOnHover) return;
|
|
2136
|
+
const shouldPeek = peekOnHover === 'collapsed' ? isCollapsed : true;
|
|
2137
|
+
if (shouldPeek) {
|
|
2138
|
+
// Use the actual target for peek behavior (not mapped to left)
|
|
2139
|
+
shell.peekPane(target);
|
|
2140
|
+
}
|
|
2141
|
+
},
|
|
2142
|
+
[onMouseEnter, peekOnHover, isCollapsed, shell, target],
|
|
2143
|
+
);
|
|
2144
|
+
|
|
2145
|
+
const handleMouseLeave = React.useCallback(
|
|
2146
|
+
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
2147
|
+
onMouseLeave?.(event);
|
|
2148
|
+
if (!peekOnHover) return;
|
|
2149
|
+
if ((shell as any).peekTarget === target) {
|
|
2150
|
+
shell.clearPeek();
|
|
2151
|
+
}
|
|
2152
|
+
},
|
|
2153
|
+
[onMouseLeave, peekOnHover, shell, target],
|
|
2154
|
+
);
|
|
2155
|
+
|
|
967
2156
|
return (
|
|
968
|
-
<
|
|
2157
|
+
<button
|
|
969
2158
|
{...props}
|
|
970
2159
|
ref={ref}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
if (shell.patternBySide[side] === 'split') shell.toggleRail(side);
|
|
977
|
-
else shell.cycleSingleView(side);
|
|
978
|
-
}}
|
|
2160
|
+
onClick={handleClick}
|
|
2161
|
+
onMouseEnter={handleMouseEnter}
|
|
2162
|
+
onMouseLeave={handleMouseLeave}
|
|
2163
|
+
data-shell-trigger={target}
|
|
2164
|
+
data-shell-action={action}
|
|
979
2165
|
>
|
|
980
|
-
{children
|
|
981
|
-
</
|
|
2166
|
+
{children}
|
|
2167
|
+
</button>
|
|
982
2168
|
);
|
|
983
2169
|
},
|
|
984
2170
|
);
|
|
985
2171
|
Trigger.displayName = 'Shell.Trigger';
|
|
986
2172
|
|
|
987
|
-
//
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
open: () => void;
|
|
1008
|
-
close: () => void;
|
|
1009
|
-
toggle: () => void;
|
|
1010
|
-
onItemSelected: (item: string) => void;
|
|
1011
|
-
};
|
|
1012
|
-
panel: {
|
|
1013
|
-
isVisible: boolean;
|
|
1014
|
-
show: () => void;
|
|
1015
|
-
hide: () => void;
|
|
1016
|
-
activeTool: string | null;
|
|
1017
|
-
activeContext: string | null;
|
|
1018
|
-
};
|
|
1019
|
-
single: { view: SingleView; setView: (view: SingleView) => void; cycle: () => void };
|
|
1020
|
-
activeTool: string | null;
|
|
1021
|
-
setActiveTool: (tool: string | null) => void;
|
|
1022
|
-
activeContext: string | null;
|
|
1023
|
-
setActiveContext: (context: string | null) => void;
|
|
1024
|
-
} {
|
|
1025
|
-
const shell = useShell();
|
|
1026
|
-
const isSplit = shell.patternBySide[side] === 'split';
|
|
1027
|
-
const railValue = shell.railBySide[side];
|
|
1028
|
-
const activeTool = shell.activeToolBySide[side];
|
|
1029
|
-
|
|
1030
|
-
const panelVisible =
|
|
1031
|
-
shell.panelRequestedBySide[side] && railValue === 'open' && activeTool !== null;
|
|
1032
|
-
return {
|
|
1033
|
-
side,
|
|
1034
|
-
isSplit,
|
|
1035
|
-
rail: {
|
|
1036
|
-
value: railValue,
|
|
1037
|
-
isOpen: railValue === 'open',
|
|
1038
|
-
open: () => shell.setRailBySide(side, 'open'),
|
|
1039
|
-
close: () => {
|
|
1040
|
-
shell.setRailBySide(side, 'collapsed');
|
|
1041
|
-
shell.setPanelRequestedBySide(side, false);
|
|
1042
|
-
},
|
|
1043
|
-
toggle: () => shell.toggleRail(side),
|
|
1044
|
-
onItemSelected: (item: string) => shell.onItemSelected(side, item),
|
|
1045
|
-
},
|
|
1046
|
-
panel: {
|
|
1047
|
-
isVisible: panelVisible,
|
|
1048
|
-
show: () => shell.setPanelRequestedBySide(side, true),
|
|
1049
|
-
hide: () => shell.setPanelRequestedBySide(side, false),
|
|
1050
|
-
activeTool: shell.activeToolBySide[side],
|
|
1051
|
-
activeContext: shell.activeContextBySide[side],
|
|
1052
|
-
},
|
|
1053
|
-
single: {
|
|
1054
|
-
view: shell.singleViewBySide[side],
|
|
1055
|
-
setView: (view: SingleView) => shell.setSingleViewBySide(side, view),
|
|
1056
|
-
cycle: () => shell.cycleSingleView(side),
|
|
1057
|
-
},
|
|
1058
|
-
activeTool: shell.activeToolBySide[side],
|
|
1059
|
-
setActiveTool: (tool: string | null) => shell.setActiveTool(side, tool),
|
|
1060
|
-
activeContext: shell.activeContextBySide[side],
|
|
1061
|
-
setActiveContext: (context: string | null) => shell.setActiveContext(side, context),
|
|
1062
|
-
};
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
export { useSidebar };
|
|
2173
|
+
// Exports
|
|
2174
|
+
export {
|
|
2175
|
+
Root,
|
|
2176
|
+
Header,
|
|
2177
|
+
Left,
|
|
2178
|
+
Rail,
|
|
2179
|
+
Panel,
|
|
2180
|
+
Sidebar,
|
|
2181
|
+
Content,
|
|
2182
|
+
Inspector,
|
|
2183
|
+
Bottom,
|
|
2184
|
+
Trigger,
|
|
2185
|
+
useShell,
|
|
2186
|
+
useResponsivePresentation,
|
|
2187
|
+
type PaneMode,
|
|
2188
|
+
type SidebarMode,
|
|
2189
|
+
type ResponsivePresentation,
|
|
2190
|
+
type PaneTarget,
|
|
2191
|
+
type TriggerAction,
|
|
2192
|
+
};
|