@adea-ai/ui 0.107.0 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/layout/contextual-sidebar/contextual-sidebar.d.ts +55 -0
- package/dist/components/layout/contextual-sidebar/contextual-sidebar.d.ts.map +1 -0
- package/dist/components/layout/contextual-sidebar/contextual-sidebar.js +199 -0
- package/dist/components/layout/contextual-sidebar/contextual-sidebar.js.map +1 -0
- package/dist/components/layout/contextual-sidebar/index.d.ts +3 -0
- package/dist/components/layout/contextual-sidebar/index.d.ts.map +1 -0
- package/dist/components/layout/contextual-sidebar/index.js +3 -0
- package/dist/components/layout/pixel-resize-handle/index.d.ts +2 -0
- package/dist/components/layout/pixel-resize-handle/index.d.ts.map +1 -0
- package/dist/components/layout/pixel-resize-handle/index.js +2 -0
- package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.d.ts +29 -0
- package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.d.ts.map +1 -0
- package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.js +183 -0
- package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.js.map +1 -0
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.d.ts +3 -19
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.d.ts.map +1 -1
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.js +8 -76
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.js.map +1 -1
- package/dist/components/ui/resizable/resizable.d.ts.map +1 -1
- package/dist/components/ui/resizable/resizable.js +2 -2
- package/dist/components/ui/resizable/resizable.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/lint/index.d.ts.map +1 -1
- package/dist/lint.js +154 -20
- package/dist/lint.js.map +1 -1
- package/dist/r/contextual-sidebar.json +25 -0
- package/dist/r/pixel-resize-handle.json +23 -0
- package/dist/r/registry.json +49 -1
- package/dist/r/sidebar-nav.json +1 -1
- package/dist/r/src/components/layout/contextual-sidebar/contextual-sidebar.tsx +180 -0
- package/dist/r/src/components/layout/contextual-sidebar/index.ts +6 -0
- package/dist/r/src/components/layout/pixel-resize-handle/index.ts +1 -0
- package/dist/r/src/components/layout/pixel-resize-handle/pixel-resize-handle.tsx +177 -0
- package/dist/r/src/components/layout/sidebar-nav/sidebar-nav-resize-handle.tsx +7 -55
- package/dist/r/src/components/ui/resizable/resizable.tsx +4 -2
- package/package.json +3 -1
- package/registry.json +49 -1
- package/src/components/layout/contextual-sidebar/contextual-sidebar.tsx +180 -0
- package/src/components/layout/contextual-sidebar/index.ts +6 -0
- package/src/components/layout/pixel-resize-handle/index.ts +1 -0
- package/src/components/layout/pixel-resize-handle/pixel-resize-handle.tsx +177 -0
- package/src/components/layout/sidebar-nav/sidebar-nav-resize-handle.tsx +7 -55
- package/src/components/ui/resizable/resizable.tsx +4 -2
- package/src/index.ts +1 -0
- package/src/lint/index.ts +234 -22
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../../ui/resizable'
|
|
2
|
+
import { createSignal, onCleanup } from 'solid-js'
|
|
3
|
+
import { cn } from '../../../lib/utils'
|
|
4
|
+
|
|
5
|
+
export type PixelResizeHandleProps = {
|
|
6
|
+
/** Which physical edge of the ruler the resizable surface is attached to. */
|
|
7
|
+
side: 'left' | 'right'
|
|
8
|
+
/** Current width in CSS pixels. */
|
|
9
|
+
value: number
|
|
10
|
+
minimum: number
|
|
11
|
+
maximum: number
|
|
12
|
+
step?: number
|
|
13
|
+
label?: string
|
|
14
|
+
controls?: string
|
|
15
|
+
class?: string
|
|
16
|
+
onChange(value: number): void
|
|
17
|
+
onCommit?(value: number): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* PixelResizeHandle.
|
|
22
|
+
*
|
|
23
|
+
* A keyboard- and pointer-operable edge ruler for a host-owned pane. Keep the
|
|
24
|
+
* pane's actual width and any persistence in its layout owner; this component
|
|
25
|
+
* reports a bounded pixel value and places the shared resizable grip at the
|
|
26
|
+
* matching left or right edge.
|
|
27
|
+
*
|
|
28
|
+
* Corvu 0.2.5 supplies geometry, keyboard resizing, ARIA and the shared grip,
|
|
29
|
+
* but its global drag handler does not end on pointer cancellation. Prevent
|
|
30
|
+
* that handler through its public start hook and use local pointer capture
|
|
31
|
+
* for bounded pixel gestures, including cancellation and lost capture.
|
|
32
|
+
*/
|
|
33
|
+
export function PixelResizeHandle(props: PixelResizeHandleProps) {
|
|
34
|
+
const maximum = () => Math.max(1, props.maximum)
|
|
35
|
+
const minimum = () => Math.min(maximum(), Math.max(0, props.minimum))
|
|
36
|
+
const value = () => Math.min(maximum(), Math.max(minimum(), Math.round(props.value)))
|
|
37
|
+
const ratio = () => value() / maximum()
|
|
38
|
+
const isRight = () => props.side === 'right'
|
|
39
|
+
const rulerSizes = () => (isRight() ? [1 - ratio(), ratio()] : [ratio(), 1 - ratio()])
|
|
40
|
+
const commit = () => props.onCommit?.(value())
|
|
41
|
+
let interacting = false
|
|
42
|
+
const [drag, setDrag] = createSignal<{
|
|
43
|
+
pointerId: number
|
|
44
|
+
startX: number
|
|
45
|
+
startValue: number
|
|
46
|
+
handle: HTMLElement
|
|
47
|
+
}>()
|
|
48
|
+
const endPointer = (shouldCommit: boolean) => {
|
|
49
|
+
const current = drag()
|
|
50
|
+
if (!current) return
|
|
51
|
+
setDrag(undefined)
|
|
52
|
+
if (current.handle.hasPointerCapture(current.pointerId))
|
|
53
|
+
current.handle.releasePointerCapture(current.pointerId)
|
|
54
|
+
if (shouldCommit) commit()
|
|
55
|
+
}
|
|
56
|
+
const endKeyboard = () => {
|
|
57
|
+
const wasInteracting = interacting
|
|
58
|
+
interacting = false
|
|
59
|
+
if (wasInteracting) commit()
|
|
60
|
+
}
|
|
61
|
+
onCleanup(() => {
|
|
62
|
+
endKeyboard()
|
|
63
|
+
endPointer(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ResizablePanelGroup
|
|
68
|
+
orientation="horizontal"
|
|
69
|
+
dir="ltr"
|
|
70
|
+
sizes={rulerSizes()}
|
|
71
|
+
keyboardDelta={`${props.step ?? 16}px`}
|
|
72
|
+
style={{ width: `${maximum()}px` }}
|
|
73
|
+
class={cn(
|
|
74
|
+
'pointer-events-none absolute inset-y-0 flex',
|
|
75
|
+
{
|
|
76
|
+
'left-0': props.side === 'left',
|
|
77
|
+
'right-0': props.side === 'right',
|
|
78
|
+
},
|
|
79
|
+
props.class
|
|
80
|
+
)}
|
|
81
|
+
onSizesChange={(sizes) => {
|
|
82
|
+
// Panel registration and teardown also emit sizes. Only user resizing
|
|
83
|
+
// may update the host preference; collapsing must preserve its width.
|
|
84
|
+
if (!interacting || sizes.length !== 2) return
|
|
85
|
+
const next = Math.round((sizes[isRight() ? 1 : 0] ?? ratio()) * maximum())
|
|
86
|
+
const bounded = Math.min(maximum(), Math.max(minimum(), next))
|
|
87
|
+
if (bounded !== value()) props.onChange(bounded)
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<ResizablePanel
|
|
91
|
+
minSize={isRight() ? 0 : minimum() / maximum()}
|
|
92
|
+
maxSize={1}
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
/>
|
|
95
|
+
<ResizableHandle
|
|
96
|
+
withHandle
|
|
97
|
+
label={props.label ?? `Resize ${props.side} pane`}
|
|
98
|
+
aria-controls={props.controls}
|
|
99
|
+
aria-valuemin={minimum()}
|
|
100
|
+
aria-valuemax={maximum()}
|
|
101
|
+
aria-valuenow={value()}
|
|
102
|
+
aria-valuetext={`${value()} pixels`}
|
|
103
|
+
data-dragging={drag() ? '' : undefined}
|
|
104
|
+
onHandleDragStart={(event) => {
|
|
105
|
+
// Corvu checks defaultPrevented before starting its global handler.
|
|
106
|
+
event.preventDefault()
|
|
107
|
+
if (event.button !== 0 || !event.isPrimary) return
|
|
108
|
+
const handle = event.currentTarget
|
|
109
|
+
if (!(handle instanceof HTMLElement)) return
|
|
110
|
+
endPointer(false)
|
|
111
|
+
interacting = false
|
|
112
|
+
handle.focus()
|
|
113
|
+
handle.setPointerCapture(event.pointerId)
|
|
114
|
+
setDrag({
|
|
115
|
+
pointerId: event.pointerId,
|
|
116
|
+
startX: event.clientX,
|
|
117
|
+
startValue: value(),
|
|
118
|
+
handle,
|
|
119
|
+
})
|
|
120
|
+
}}
|
|
121
|
+
onPointerMove={(event: PointerEvent) => {
|
|
122
|
+
const current = drag()
|
|
123
|
+
if (!current || event.pointerId !== current.pointerId) return
|
|
124
|
+
if (!current.handle.hasPointerCapture(current.pointerId)) {
|
|
125
|
+
endPointer(false)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
const delta = (event.clientX - current.startX) * (isRight() ? -1 : 1)
|
|
129
|
+
const next = Math.min(
|
|
130
|
+
maximum(),
|
|
131
|
+
Math.max(minimum(), Math.round(current.startValue + delta))
|
|
132
|
+
)
|
|
133
|
+
if (next !== value()) props.onChange(next)
|
|
134
|
+
}}
|
|
135
|
+
onPointerUp={(event: PointerEvent) => {
|
|
136
|
+
if (event.pointerId === drag()?.pointerId) endPointer(true)
|
|
137
|
+
}}
|
|
138
|
+
onPointerCancel={(event: PointerEvent) => {
|
|
139
|
+
if (event.pointerId === drag()?.pointerId) endPointer(false)
|
|
140
|
+
}}
|
|
141
|
+
onLostPointerCapture={(event: PointerEvent) => {
|
|
142
|
+
if (event.pointerId === drag()?.pointerId) endPointer(false)
|
|
143
|
+
}}
|
|
144
|
+
onBlur={() => {
|
|
145
|
+
endKeyboard()
|
|
146
|
+
endPointer(false)
|
|
147
|
+
}}
|
|
148
|
+
onKeyDown={(event) => {
|
|
149
|
+
const current = drag()
|
|
150
|
+
if (current && !current.handle.hasPointerCapture(current.pointerId)) endPointer(false)
|
|
151
|
+
if (drag()) {
|
|
152
|
+
event.preventDefault()
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) interacting = true
|
|
156
|
+
// Corvu operates on physical panel order. Home/End here describe
|
|
157
|
+
// the controlled pane width, including a pane after the separator.
|
|
158
|
+
if (event.key === 'Home' || event.key === 'End') {
|
|
159
|
+
event.preventDefault()
|
|
160
|
+
props.onChange(event.key === 'Home' ? minimum() : maximum())
|
|
161
|
+
}
|
|
162
|
+
}}
|
|
163
|
+
onKeyUp={(event) => {
|
|
164
|
+
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
|
165
|
+
endKeyboard()
|
|
166
|
+
}
|
|
167
|
+
}}
|
|
168
|
+
class="pointer-events-auto"
|
|
169
|
+
/>
|
|
170
|
+
<ResizablePanel
|
|
171
|
+
minSize={isRight() ? minimum() / maximum() : 0}
|
|
172
|
+
maxSize={1}
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
/>
|
|
175
|
+
</ResizablePanelGroup>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -1,59 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
PixelResizeHandle,
|
|
3
|
+
type PixelResizeHandleProps,
|
|
4
|
+
} from '../pixel-resize-handle/pixel-resize-handle'
|
|
3
5
|
|
|
4
|
-
export type SidebarNavResizeHandleProps =
|
|
5
|
-
value: number
|
|
6
|
-
minimum: number
|
|
7
|
-
maximum: number
|
|
8
|
-
step?: number
|
|
9
|
-
label?: string
|
|
10
|
-
controls?: string
|
|
11
|
-
class?: string
|
|
12
|
-
onChange(value: number): void
|
|
13
|
-
onCommit?(value: number): void
|
|
14
|
-
}
|
|
6
|
+
export type SidebarNavResizeHandleProps = Omit<PixelResizeHandleProps, 'side'>
|
|
15
7
|
|
|
16
|
-
/**
|
|
17
|
-
* Resize a contextual sidebar without making its host recreate a separator
|
|
18
|
-
* widget. Corvu owns pointer capture, cancellation, constraints, keyboard
|
|
19
|
-
* movement and cleanup. Its two invisible panels provide a bounded pixel
|
|
20
|
-
* ruler; the host retains the real pane layout and preference persistence.
|
|
21
|
-
* Place this inside the positioned sidebar. The ruler can extend over the
|
|
22
|
-
* adjacent content, but only its handle participates in pointer hit testing.
|
|
23
|
-
*/
|
|
8
|
+
/** Keep the established left-sidebar API as a thin adapter over the shared edge ruler. */
|
|
24
9
|
export function SidebarNavResizeHandle(props: SidebarNavResizeHandleProps) {
|
|
25
|
-
|
|
26
|
-
const minimum = () => Math.min(maximum(), Math.max(0, props.minimum))
|
|
27
|
-
const value = () => Math.min(maximum(), Math.max(minimum(), props.value))
|
|
28
|
-
const commit = () => props.onCommit?.(value())
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
<Resizable
|
|
32
|
-
orientation="horizontal"
|
|
33
|
-
sizes={[value() / maximum(), 1 - value() / maximum()]}
|
|
34
|
-
keyboardDelta={`${props.step ?? 16}px`}
|
|
35
|
-
style={{ width: `${maximum()}px` }}
|
|
36
|
-
class={cn('pointer-events-none absolute inset-y-0 start-0 flex', props.class)}
|
|
37
|
-
onSizesChange={(sizes) => {
|
|
38
|
-
const next = Math.round((sizes[0] ?? 0) * maximum())
|
|
39
|
-
if (next !== value()) props.onChange(Math.min(maximum(), Math.max(minimum(), next)))
|
|
40
|
-
}}
|
|
41
|
-
>
|
|
42
|
-
<Resizable.Panel minSize={minimum() / maximum()} maxSize={1} aria-hidden="true" />
|
|
43
|
-
<Resizable.Handle
|
|
44
|
-
aria-label={props.label ?? 'Resize navigation'}
|
|
45
|
-
aria-controls={props.controls}
|
|
46
|
-
aria-orientation="vertical"
|
|
47
|
-
aria-valuemin={minimum()}
|
|
48
|
-
aria-valuemax={maximum()}
|
|
49
|
-
aria-valuenow={value()}
|
|
50
|
-
onHandleDragEnd={commit}
|
|
51
|
-
onKeyUp={(event) => {
|
|
52
|
-
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) commit()
|
|
53
|
-
}}
|
|
54
|
-
class="pointer-events-auto relative z-(--z-docked) w-px -translate-x-full shrink-0 bg-transparent after:absolute after:inset-y-0 after:-inset-x-1 after:w-3 hover:bg-primary focus-visible:bg-primary focus-visible:ring-3 focus-visible:ring-primary-subtle focus-visible:outline-none"
|
|
55
|
-
/>
|
|
56
|
-
<Resizable.Panel minSize={0} aria-hidden="true" />
|
|
57
|
-
</Resizable>
|
|
58
|
-
)
|
|
10
|
+
return <PixelResizeHandle {...props} side="left" label={props.label ?? 'Resize navigation'} />
|
|
59
11
|
}
|
|
@@ -17,7 +17,7 @@ import { cn } from '../../../lib/utils'
|
|
|
17
17
|
* adjusted from the keyboard; a bare `cursor-col-resize` div cannot.
|
|
18
18
|
*/
|
|
19
19
|
/**
|
|
20
|
-
* Whether the group was given `initialSizes`. Those are corvu's to apply, and a
|
|
20
|
+
* Whether the group was given `initialSizes` or controlled `sizes`. Those are corvu's to apply, and a
|
|
21
21
|
* panel's own `initialSize` overrides them — so an unsized panel must not invent
|
|
22
22
|
* one when the group already has an answer.
|
|
23
23
|
*/
|
|
@@ -27,7 +27,9 @@ export function ResizablePanelGroup(props: ComponentProps<typeof Resizable> & {
|
|
|
27
27
|
const [local, rest] = splitProps(props, ['class'])
|
|
28
28
|
|
|
29
29
|
return (
|
|
30
|
-
<GroupSizesContext.Provider
|
|
30
|
+
<GroupSizesContext.Provider
|
|
31
|
+
value={() => rest.initialSizes !== undefined || rest.sizes !== undefined}
|
|
32
|
+
>
|
|
31
33
|
<Resizable
|
|
32
34
|
class={cn('flex size-full data-[orientation=vertical]:flex-col', local.class)}
|
|
33
35
|
{...rest}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adea-ai/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.108.0",
|
|
4
4
|
"description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"adea",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"src",
|
|
25
25
|
"!src/**/*.stories.tsx",
|
|
26
26
|
"!src/**/*.stories.ts",
|
|
27
|
+
"!src/**/*.stories.css",
|
|
27
28
|
"!src/**/*.mdx",
|
|
28
29
|
"!src/**/*.test.ts",
|
|
29
30
|
"!src/**/*.test.tsx",
|
|
@@ -112,6 +113,7 @@
|
|
|
112
113
|
"theme:check": "bun scripts/build-default-theme.ts --check",
|
|
113
114
|
"design-system:build": "bun scripts/design-system/build.ts",
|
|
114
115
|
"check:packed-layout": "node --test scripts/check-packed-layout-process.test.mjs && node scripts/check-packed-layout.mjs && node scripts/check-packed-layout-tree.mjs",
|
|
116
|
+
"check:packed-lint": "node scripts/check-packed-lint.mjs",
|
|
115
117
|
"check:packed-consumer": "bun scripts/check-packed-consumer.ts",
|
|
116
118
|
"check:packed-list-row": "bun scripts/check-packed-list-row.ts",
|
|
117
119
|
"check:packed-scene-controls": "bun scripts/check-packed-scene-controls.ts",
|
package/registry.json
CHANGED
|
@@ -1555,6 +1555,31 @@
|
|
|
1555
1555
|
}
|
|
1556
1556
|
]
|
|
1557
1557
|
},
|
|
1558
|
+
{
|
|
1559
|
+
"name": "contextual-sidebar",
|
|
1560
|
+
"type": "registry:ui",
|
|
1561
|
+
"title": "Contextual Sidebar",
|
|
1562
|
+
"description": "",
|
|
1563
|
+
"dependencies": [],
|
|
1564
|
+
"registryDependencies": [
|
|
1565
|
+
"@adea-ai/ui/pixel-resize-handle",
|
|
1566
|
+
"@adea-ai/ui/sheet",
|
|
1567
|
+
"@adea-ai/ui/sidebar-nav",
|
|
1568
|
+
"lib"
|
|
1569
|
+
],
|
|
1570
|
+
"files": [
|
|
1571
|
+
{
|
|
1572
|
+
"path": "src/components/layout/contextual-sidebar/contextual-sidebar.tsx",
|
|
1573
|
+
"type": "registry:ui",
|
|
1574
|
+
"target": "components/layout/contextual-sidebar/contextual-sidebar.tsx"
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
"path": "src/components/layout/contextual-sidebar/index.ts",
|
|
1578
|
+
"type": "registry:ui",
|
|
1579
|
+
"target": "components/layout/contextual-sidebar/index.ts"
|
|
1580
|
+
}
|
|
1581
|
+
]
|
|
1582
|
+
},
|
|
1558
1583
|
{
|
|
1559
1584
|
"name": "floating-preview",
|
|
1560
1585
|
"type": "registry:ui",
|
|
@@ -1652,6 +1677,29 @@
|
|
|
1652
1677
|
}
|
|
1653
1678
|
]
|
|
1654
1679
|
},
|
|
1680
|
+
{
|
|
1681
|
+
"name": "pixel-resize-handle",
|
|
1682
|
+
"type": "registry:ui",
|
|
1683
|
+
"title": "Pixel Resize Handle",
|
|
1684
|
+
"description": "",
|
|
1685
|
+
"dependencies": [],
|
|
1686
|
+
"registryDependencies": [
|
|
1687
|
+
"@adea-ai/ui/resizable",
|
|
1688
|
+
"lib"
|
|
1689
|
+
],
|
|
1690
|
+
"files": [
|
|
1691
|
+
{
|
|
1692
|
+
"path": "src/components/layout/pixel-resize-handle/index.ts",
|
|
1693
|
+
"type": "registry:ui",
|
|
1694
|
+
"target": "components/layout/pixel-resize-handle/index.ts"
|
|
1695
|
+
},
|
|
1696
|
+
{
|
|
1697
|
+
"path": "src/components/layout/pixel-resize-handle/pixel-resize-handle.tsx",
|
|
1698
|
+
"type": "registry:ui",
|
|
1699
|
+
"target": "components/layout/pixel-resize-handle/pixel-resize-handle.tsx"
|
|
1700
|
+
}
|
|
1701
|
+
]
|
|
1702
|
+
},
|
|
1655
1703
|
{
|
|
1656
1704
|
"name": "side-rail",
|
|
1657
1705
|
"type": "registry:ui",
|
|
@@ -1687,11 +1735,11 @@
|
|
|
1687
1735
|
"title": "Sidebar Nav",
|
|
1688
1736
|
"description": "",
|
|
1689
1737
|
"dependencies": [
|
|
1690
|
-
"@corvu/resizable@^0.2.5",
|
|
1691
1738
|
"@kobalte/core@^0.13.14",
|
|
1692
1739
|
"lucide-solid@^1.51.0"
|
|
1693
1740
|
],
|
|
1694
1741
|
"registryDependencies": [
|
|
1742
|
+
"@adea-ai/ui/pixel-resize-handle",
|
|
1695
1743
|
"lib"
|
|
1696
1744
|
],
|
|
1697
1745
|
"files": [
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { Accessor, JSX } from 'solid-js'
|
|
2
|
+
import { Show, createSignal, createUniqueId, onCleanup, onMount } from 'solid-js'
|
|
3
|
+
import { cn } from '#lib/utils'
|
|
4
|
+
import { Sheet, SheetContent, SheetTitle } from '../../ui/sheet'
|
|
5
|
+
import {
|
|
6
|
+
SidebarNav,
|
|
7
|
+
SidebarNavContent,
|
|
8
|
+
SidebarNavFooter,
|
|
9
|
+
SidebarNavHeader,
|
|
10
|
+
SidebarNavTitle,
|
|
11
|
+
} from '../sidebar-nav/sidebar-nav'
|
|
12
|
+
import { PixelResizeHandle } from '../pixel-resize-handle/pixel-resize-handle'
|
|
13
|
+
|
|
14
|
+
export type ContextualSidebarRenderContext = {
|
|
15
|
+
/** Whether the slot is being rendered inside the modal mobile sheet. */
|
|
16
|
+
mobile: boolean
|
|
17
|
+
/** Mount nested popovers and menus inside the mobile dialog's accessibility tree. */
|
|
18
|
+
portalMount(): HTMLElement | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ContextualSidebarProps = {
|
|
22
|
+
/** Stable host id prefix; generated when omitted. */
|
|
23
|
+
id?: string
|
|
24
|
+
/** Landmark name shared by the desktop and mobile navigation. */
|
|
25
|
+
label: string
|
|
26
|
+
/** Visible navigation title and mobile dialog title. */
|
|
27
|
+
title: string
|
|
28
|
+
/** Host-controlled expanded state for the desktop panel and mobile dialog. */
|
|
29
|
+
open: boolean
|
|
30
|
+
onOpenChange(open: boolean): void
|
|
31
|
+
/** Current host-owned pixel width, reflected by the resize handle. */
|
|
32
|
+
width: number
|
|
33
|
+
minimum: number
|
|
34
|
+
maximum: number
|
|
35
|
+
step?: number
|
|
36
|
+
/** Pass the host's initial viewport seed to distinguish stale desktop state. */
|
|
37
|
+
wideViewportAtLoad?: boolean
|
|
38
|
+
resizeLabel?: string
|
|
39
|
+
/** Stable external opener used when the modal mobile sheet closes. */
|
|
40
|
+
restoreFocusRef?: Accessor<HTMLElement | undefined>
|
|
41
|
+
/** Called with the nav node so a host can project width onto its layout root. */
|
|
42
|
+
onSidebarElement?: (element: HTMLElement | undefined, mobile: boolean) => void
|
|
43
|
+
/** Host-owned layout classes applied to both nav instances. */
|
|
44
|
+
sidebarClass?: string
|
|
45
|
+
/** Host-owned responsive constraints for the modal sheet. */
|
|
46
|
+
sheetClass?: string
|
|
47
|
+
headerClass?: string
|
|
48
|
+
contentClass?: string
|
|
49
|
+
footerClass?: string
|
|
50
|
+
headingAs?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
|
51
|
+
/** Optional action/content slot alongside the shared heading. */
|
|
52
|
+
header?(context: ContextualSidebarRenderContext): JSX.Element
|
|
53
|
+
/** View-specific navigation rows, projects, rooms, files, or sessions. */
|
|
54
|
+
content(context: ContextualSidebarRenderContext): JSX.Element
|
|
55
|
+
/** Optional view-specific footer, such as an archive affordance. */
|
|
56
|
+
footer?(context: ContextualSidebarRenderContext): JSX.Element
|
|
57
|
+
onWidthChange(width: number): void
|
|
58
|
+
onWidthCommit?(width: number): void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* ContextualSidebar.
|
|
63
|
+
*
|
|
64
|
+
* Shared navigation geometry and interaction policy for an application's
|
|
65
|
+
* contextual sidebar. The host keeps ownership of the actual grid width,
|
|
66
|
+
* persisted preference, open state, and view-specific navigation content.
|
|
67
|
+
*/
|
|
68
|
+
export function ContextualSidebar(props: ContextualSidebarProps) {
|
|
69
|
+
const [isNarrowViewport, setIsNarrowViewport] = createSignal(false)
|
|
70
|
+
const [mobileMount, setMobileMount] = createSignal<
|
|
71
|
+
{ element: HTMLElement; connected: boolean } | undefined
|
|
72
|
+
>()
|
|
73
|
+
const id = props.id ?? `contextual-sidebar-${createUniqueId()}`
|
|
74
|
+
const resizeLabel = () => props.resizeLabel ?? `Resize ${props.label}`
|
|
75
|
+
const portalMount = () => {
|
|
76
|
+
const mount = mobileMount()
|
|
77
|
+
return mount?.connected && mount.element.isConnected ? mount.element : undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onMount(() => {
|
|
81
|
+
const media = window.matchMedia('(max-width: 48rem)')
|
|
82
|
+
const updateViewport = () => {
|
|
83
|
+
const wasNarrow = isNarrowViewport()
|
|
84
|
+
// Close before mounting a modal. A desktop-expanded host state is not
|
|
85
|
+
// mobile intent, and opening the Sheet for one frame can steal focus.
|
|
86
|
+
if (media.matches && !wasNarrow) props.onOpenChange(false)
|
|
87
|
+
setIsNarrowViewport(media.matches)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A route can mount after a wide-loaded host crosses into mobile. Clear
|
|
91
|
+
// that stale seed, but preserve a sidebar intentionally opened at a
|
|
92
|
+
// narrow boot viewport.
|
|
93
|
+
if (media.matches && props.wideViewportAtLoad && props.open) props.onOpenChange(false)
|
|
94
|
+
setIsNarrowViewport(media.matches)
|
|
95
|
+
media.addEventListener('change', updateViewport)
|
|
96
|
+
onCleanup(() => media.removeEventListener('change', updateViewport))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const renderSidebar = (mobile: boolean) => {
|
|
100
|
+
let element: HTMLElement | undefined
|
|
101
|
+
const context: ContextualSidebarRenderContext = {
|
|
102
|
+
mobile,
|
|
103
|
+
portalMount: () => (mobile ? portalMount() : undefined),
|
|
104
|
+
}
|
|
105
|
+
const navigationId = `${id}-${mobile ? 'mobile' : 'desktop'}`
|
|
106
|
+
|
|
107
|
+
onCleanup(() => {
|
|
108
|
+
if (mobile && mobileMount()?.element === element) setMobileMount(undefined)
|
|
109
|
+
element = undefined
|
|
110
|
+
props.onSidebarElement?.(undefined, mobile)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<SidebarNav
|
|
115
|
+
as="aside"
|
|
116
|
+
id={navigationId}
|
|
117
|
+
aria-label={props.label}
|
|
118
|
+
aria-hidden={!mobile && !props.open ? 'true' : undefined}
|
|
119
|
+
inert={!mobile && !props.open}
|
|
120
|
+
data-contextual-sidebar={mobile ? 'mobile' : 'desktop'}
|
|
121
|
+
data-open={mobile ? undefined : String(props.open)}
|
|
122
|
+
ref={(node) => {
|
|
123
|
+
element = node
|
|
124
|
+
if (mobile) {
|
|
125
|
+
setMobileMount({ element: node, connected: node.isConnected })
|
|
126
|
+
queueMicrotask(() => {
|
|
127
|
+
if (node.isConnected && mobileMount()?.element === node)
|
|
128
|
+
setMobileMount({ element: node, connected: true })
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
props.onSidebarElement?.(node, mobile)
|
|
132
|
+
}}
|
|
133
|
+
class={cn('relative h-full w-full max-w-full', props.sidebarClass)}
|
|
134
|
+
>
|
|
135
|
+
<Show when={!mobile && props.open}>
|
|
136
|
+
<PixelResizeHandle
|
|
137
|
+
side="left"
|
|
138
|
+
value={props.width}
|
|
139
|
+
minimum={props.minimum}
|
|
140
|
+
maximum={props.maximum}
|
|
141
|
+
step={props.step}
|
|
142
|
+
label={resizeLabel()}
|
|
143
|
+
controls={navigationId}
|
|
144
|
+
onChange={props.onWidthChange}
|
|
145
|
+
onCommit={props.onWidthCommit}
|
|
146
|
+
/>
|
|
147
|
+
</Show>
|
|
148
|
+
<Show when={!mobile || mobileMount()?.connected}>
|
|
149
|
+
<SidebarNavHeader class={cn(mobile && 'pe-12', props.headerClass)}>
|
|
150
|
+
<SidebarNavTitle as={props.headingAs ?? 'h2'}>{props.title}</SidebarNavTitle>
|
|
151
|
+
{props.header?.(context)}
|
|
152
|
+
</SidebarNavHeader>
|
|
153
|
+
<SidebarNavContent class={props.contentClass}>{props.content(context)}</SidebarNavContent>
|
|
154
|
+
{props.footer ? (
|
|
155
|
+
<SidebarNavFooter class={props.footerClass}>{props.footer(context)}</SidebarNavFooter>
|
|
156
|
+
) : null}
|
|
157
|
+
</Show>
|
|
158
|
+
</SidebarNav>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Sheet
|
|
164
|
+
open={isNarrowViewport() && props.open}
|
|
165
|
+
onOpenChange={(open) => props.onOpenChange(open)}
|
|
166
|
+
>
|
|
167
|
+
<Show when={!isNarrowViewport()}>{(_wide) => renderSidebar(false)}</Show>
|
|
168
|
+
<Show when={isNarrowViewport()}>
|
|
169
|
+
<SheetContent
|
|
170
|
+
side="start"
|
|
171
|
+
class={cn('gap-0 p-0', props.sheetClass)}
|
|
172
|
+
restoreFocusRef={props.restoreFocusRef}
|
|
173
|
+
>
|
|
174
|
+
<SheetTitle class="sr-only">{props.title} navigation</SheetTitle>
|
|
175
|
+
{renderSidebar(true)}
|
|
176
|
+
</SheetContent>
|
|
177
|
+
</Show>
|
|
178
|
+
</Sheet>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PixelResizeHandle, type PixelResizeHandleProps } from './pixel-resize-handle'
|