@flamingo-stack/openframe-frontend-core 0.0.701 → 0.0.702-snapshot.20261005191338
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/{chunk-DJLZXJYB.js → chunk-2NE3DNXY.js} +1 -1
- package/dist/{chunk-V5QNDOEL.js → chunk-53AC23ZN.js} +4 -4
- package/dist/{chunk-BQ34PIFC.cjs → chunk-5VPIC5MD.cjs} +3 -3
- package/dist/{chunk-BRQY2HKD.cjs → chunk-67CXGPTW.cjs} +24 -24
- package/dist/{chunk-BZYMGI4L.js → chunk-7632TNE7.js} +1 -1
- package/dist/{chunk-QPNR4N7N.cjs → chunk-BMJUKS6S.cjs} +20 -20
- package/dist/{chunk-GD4JALTR.js → chunk-BOUPNYUV.js} +2 -2
- package/dist/{chunk-BPS7JQCI.cjs → chunk-BSEIEWM5.cjs} +2 -2
- package/dist/{chunk-6JG75WVO.js → chunk-DAW6TDZB.js} +4 -4
- package/dist/{chunk-B7M6QEEM.js → chunk-ENGBNKUJ.js} +673 -432
- package/dist/{chunk-N7HMUQRV.js → chunk-G55W76S6.js} +1 -1
- package/dist/{chunk-65YVUYWJ.cjs → chunk-GX3OUTFO.cjs} +32 -32
- package/dist/{chunk-S4ADN25W.js → chunk-GZYLZ4MB.js} +2 -2
- package/dist/{chunk-A5ZGKBN2.js → chunk-HHFPQNP3.js} +1 -1
- package/dist/{chunk-RTGPIHVO.cjs → chunk-HY5YL3U5.cjs} +247 -173
- package/dist/{chunk-DG2I57UM.cjs → chunk-HYLIQMZH.cjs} +21 -21
- package/dist/{chunk-N7DPIH57.js → chunk-J4K5JOLI.js} +1 -1
- package/dist/{chunk-4H5Q6MQG.cjs → chunk-KFDP3DUL.cjs} +44 -44
- package/dist/{chunk-QDIX2ZRL.cjs → chunk-MXHJROXJ.cjs} +754 -513
- package/dist/{chunk-CUPJCGLB.js → chunk-NCTYD7HL.js} +2 -2
- package/dist/{chunk-MFORWVGD.js → chunk-NMGX2ZTU.js} +5 -5
- package/dist/{chunk-QZGFJBG5.cjs → chunk-NU724ZVN.cjs} +70 -70
- package/dist/{chunk-ZSPHOHOW.cjs → chunk-NYSYSKSH.cjs} +9 -9
- package/dist/{chunk-PNWIVND4.js → chunk-PJP5SMOU.js} +3 -3
- package/dist/{chunk-6JPQDYYY.js → chunk-QGTDWFT6.js} +419 -345
- package/dist/{chunk-GFXZMFDX.cjs → chunk-RLDAYA2A.cjs} +9 -9
- package/dist/{chunk-2CSYUMNM.cjs → chunk-RTXTGLGB.cjs} +2 -2
- package/dist/{chunk-YBC346FY.cjs → chunk-S6V7A5FS.cjs} +12 -12
- package/dist/{chunk-7ZDTAABP.cjs → chunk-SFGXIFYO.cjs} +7 -7
- package/dist/{chunk-JPVDJB27.js → chunk-SL2FOFT2.js} +4 -4
- package/dist/{chunk-EONNPRAW.js → chunk-UTW4SQDI.js} +2 -2
- package/dist/{chunk-NIT257DV.cjs → chunk-YBGEPKC2.cjs} +4 -4
- package/dist/components/case-studies/index.cjs +14 -14
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +4 -4
- package/dist/components/faq/index.js +3 -3
- package/dist/components/features/index.cjs +23 -3
- package/dist/components/features/index.js +22 -2
- package/dist/components/features/remote-session/index.d.ts +9 -1
- package/dist/components/features/remote-session/index.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-block.d.ts +4 -2
- package/dist/components/features/remote-session/remote-session-block.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-chat-panel.d.ts +4 -2
- package/dist/components/features/remote-session/remote-session-chat-panel.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-events.d.ts +24 -0
- package/dist/components/features/remote-session/remote-session-events.d.ts.map +1 -0
- package/dist/components/features/remote-session/remote-session-keep-modals.d.ts +41 -0
- package/dist/components/features/remote-session/remote-session-keep-modals.d.ts.map +1 -0
- package/dist/components/features/remote-session/remote-session-parts.d.ts +13 -2
- package/dist/components/features/remote-session/remote-session-parts.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-status.d.ts +34 -0
- package/dist/components/features/remote-session/remote-session-status.d.ts.map +1 -0
- package/dist/components/features/remote-session/remote-session-viewers.d.ts +15 -0
- package/dist/components/features/remote-session/remote-session-viewers.d.ts.map +1 -0
- package/dist/components/features/remote-session/types.d.ts +22 -0
- package/dist/components/features/remote-session/types.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +71 -71
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +61 -41
- package/dist/components/index.js +33 -13
- package/dist/components/meeting-scheduler/index.cjs +25 -25
- package/dist/components/meeting-scheduler/index.js +2 -2
- package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +6 -6
- package/dist/components/navigation/index.js +5 -5
- package/dist/components/onboarding-guides/index.cjs +6 -6
- package/dist/components/onboarding-guides/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/drawer.d.ts +7 -3
- package/dist/components/ui/drawer.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/components/ui/panel-resize-handle.d.ts +25 -0
- package/dist/components/ui/panel-resize-handle.d.ts.map +1 -0
- package/dist/hooks/ui/use-resizable-panel-size.d.ts +46 -0
- package/dist/hooks/ui/use-resizable-panel-size.d.ts.map +1 -0
- package/dist/index.cjs +27 -7
- package/dist/index.js +26 -6
- package/dist/tailwind.config.cjs +7 -1
- package/dist/tailwind.config.js +7 -1
- package/package.json +1 -1
- package/src/components/chat/embeddable-chat.tsx +11 -9
- package/src/components/features/remote-session/index.ts +28 -0
- package/src/components/features/remote-session/remote-session-block.tsx +6 -3
- package/src/components/features/remote-session/remote-session-chat-panel.tsx +11 -7
- package/src/components/features/remote-session/remote-session-events.tsx +93 -0
- package/src/components/features/remote-session/remote-session-keep-modals.tsx +208 -0
- package/src/components/features/remote-session/remote-session-parts.tsx +32 -3
- package/src/components/features/remote-session/remote-session-status.tsx +102 -0
- package/src/components/features/remote-session/remote-session-viewers.tsx +103 -0
- package/src/components/features/remote-session/types.ts +26 -0
- package/src/components/navigation/app-layout-drawer.tsx +14 -237
- package/src/components/ui/drawer.tsx +41 -188
- package/src/components/ui/panel-resize-handle.tsx +184 -0
- package/src/hooks/ui/use-resizable-panel-size.ts +114 -0
- package/tailwind.config.ts +6 -0
|
@@ -8,19 +8,18 @@ import {
|
|
|
8
8
|
type ComponentPropsWithoutRef,
|
|
9
9
|
type ComponentRef,
|
|
10
10
|
type HTMLAttributes,
|
|
11
|
-
type KeyboardEvent,
|
|
12
|
-
type PointerEvent,
|
|
13
11
|
type ReactNode,
|
|
14
12
|
forwardRef,
|
|
15
|
-
useCallback,
|
|
16
13
|
useEffect,
|
|
17
14
|
useRef,
|
|
18
15
|
useState,
|
|
16
|
+
useSyncExternalStore,
|
|
19
17
|
} from 'react';
|
|
20
18
|
|
|
21
19
|
import { useHeaderHeight } from '../../hooks/ui/use-header-height';
|
|
20
|
+
import { type PanelDefaultSize, useResizablePanelSize } from '../../hooks/ui/use-resizable-panel-size';
|
|
22
21
|
import { cn } from '../../utils/cn';
|
|
23
|
-
import {
|
|
22
|
+
import { PanelResizeHandle } from './panel-resize-handle';
|
|
24
23
|
|
|
25
24
|
/** Unified overlay backdrop — dimmed, no blur. Single source of truth for
|
|
26
25
|
* every full-screen backdrop (Drawer, AppLayoutDrawer, MobileBurgerMenu,
|
|
@@ -138,181 +137,21 @@ type DrawerSide = 'right' | 'left' | 'top' | 'bottom';
|
|
|
138
137
|
|
|
139
138
|
const HORIZONTAL_SIDES: ReadonlySet<DrawerSide> = new Set(['left', 'right']);
|
|
140
139
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const clampToViewport = useCallback(
|
|
157
|
-
(value: number) => {
|
|
158
|
-
const vp = viewportSize(isHorizontal);
|
|
159
|
-
const effectiveMax = vp > 0 ? Math.min(maxSize, vp - 80) : maxSize;
|
|
160
|
-
return clamp(value, minSize, Math.max(minSize, effectiveMax));
|
|
161
|
-
},
|
|
162
|
-
[isHorizontal, minSize, maxSize],
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
const readInitial = useCallback(() => {
|
|
166
|
-
if (!enabled) return defaultSize;
|
|
167
|
-
if (typeof window === 'undefined') return defaultSize;
|
|
168
|
-
if (!storageKey) return clampToViewport(defaultSize);
|
|
169
|
-
try {
|
|
170
|
-
const raw = window.localStorage.getItem(storageKey);
|
|
171
|
-
if (!raw) return clampToViewport(defaultSize);
|
|
172
|
-
const parsed = parseFloat(raw);
|
|
173
|
-
if (!Number.isFinite(parsed)) return clampToViewport(defaultSize);
|
|
174
|
-
return clampToViewport(parsed);
|
|
175
|
-
} catch {
|
|
176
|
-
return clampToViewport(defaultSize);
|
|
177
|
-
}
|
|
178
|
-
}, [enabled, storageKey, defaultSize, clampToViewport]);
|
|
179
|
-
|
|
180
|
-
const [size, setSizeRaw] = useState<number>(readInitial);
|
|
181
|
-
|
|
182
|
-
const setSize = useCallback((next: number) => setSizeRaw(clampToViewport(next)), [clampToViewport]);
|
|
183
|
-
|
|
184
|
-
useEffect(() => {
|
|
185
|
-
if (!enabled || !storageKey || typeof window === 'undefined') return;
|
|
186
|
-
try {
|
|
187
|
-
window.localStorage.setItem(storageKey, String(Math.round(size)));
|
|
188
|
-
} catch {
|
|
189
|
-
// ignore quota / disabled-storage
|
|
190
|
-
}
|
|
191
|
-
}, [enabled, size, storageKey]);
|
|
192
|
-
|
|
193
|
-
useEffect(() => {
|
|
194
|
-
if (!enabled || typeof window === 'undefined') return undefined;
|
|
195
|
-
const onResize = () => setSizeRaw(prev => clampToViewport(prev));
|
|
196
|
-
window.addEventListener('resize', onResize);
|
|
197
|
-
return () => window.removeEventListener('resize', onResize);
|
|
198
|
-
}, [enabled, clampToViewport]);
|
|
199
|
-
|
|
200
|
-
return { size, setSize };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
interface DrawerResizeHandleProps {
|
|
204
|
-
side: DrawerSide;
|
|
205
|
-
size: number;
|
|
206
|
-
minSize: number;
|
|
207
|
-
maxSize: number;
|
|
208
|
-
onSize: (next: number) => void;
|
|
209
|
-
ariaLabel?: string;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function DrawerResizeHandle({ side, size, minSize, maxSize, onSize, ariaLabel }: DrawerResizeHandleProps) {
|
|
213
|
-
const isHorizontal = HORIZONTAL_SIDES.has(side);
|
|
214
|
-
const startRef = useRef<{ x: number; y: number; size: number } | null>(null);
|
|
215
|
-
|
|
216
|
-
const direction = side === 'right' || side === 'bottom' ? -1 : 1;
|
|
217
|
-
|
|
218
|
-
const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
|
219
|
-
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
220
|
-
e.preventDefault();
|
|
221
|
-
startRef.current = { x: e.clientX, y: e.clientY, size };
|
|
222
|
-
e.currentTarget.setPointerCapture(e.pointerId);
|
|
223
|
-
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
|
|
224
|
-
document.body.style.userSelect = 'none';
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
|
228
|
-
const start = startRef.current;
|
|
229
|
-
if (!start) return;
|
|
230
|
-
const delta = isHorizontal ? e.clientX - start.x : e.clientY - start.y;
|
|
231
|
-
onSize(start.size + delta * direction);
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
const endDrag = (e: PointerEvent<HTMLDivElement>) => {
|
|
235
|
-
if (!startRef.current) return;
|
|
236
|
-
startRef.current = null;
|
|
237
|
-
try {
|
|
238
|
-
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
239
|
-
} catch {
|
|
240
|
-
// ignore — pointer may already be released
|
|
241
|
-
}
|
|
242
|
-
document.body.style.cursor = '';
|
|
243
|
-
document.body.style.userSelect = '';
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
247
|
-
const step = e.shiftKey ? 40 : 16;
|
|
248
|
-
if (isHorizontal) {
|
|
249
|
-
if (e.key === 'ArrowLeft') {
|
|
250
|
-
e.preventDefault();
|
|
251
|
-
onSize(size + step * (side === 'right' ? 1 : -1));
|
|
252
|
-
} else if (e.key === 'ArrowRight') {
|
|
253
|
-
e.preventDefault();
|
|
254
|
-
onSize(size + step * (side === 'right' ? -1 : 1));
|
|
255
|
-
}
|
|
256
|
-
} else {
|
|
257
|
-
if (e.key === 'ArrowUp') {
|
|
258
|
-
e.preventDefault();
|
|
259
|
-
onSize(size + step * (side === 'bottom' ? 1 : -1));
|
|
260
|
-
} else if (e.key === 'ArrowDown') {
|
|
261
|
-
e.preventDefault();
|
|
262
|
-
onSize(size + step * (side === 'bottom' ? -1 : 1));
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (e.key === 'Home') {
|
|
266
|
-
e.preventDefault();
|
|
267
|
-
onSize(minSize);
|
|
268
|
-
} else if (e.key === 'End') {
|
|
269
|
-
e.preventDefault();
|
|
270
|
-
onSize(maxSize);
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
// Handle is rendered as a SIBLING of the panel (child of DialogPrimitive.Content)
|
|
275
|
-
// so the panel can use `overflow-hidden` without clipping the handle.
|
|
276
|
-
// Wrapper has `py-4 px-4` (or `md:py-4 md:px-4` for flush on desktop)
|
|
277
|
-
// when the handle is visible — handle is desktop-only, where both flush
|
|
278
|
-
// and non-flush wrappers carry that padding — so `top-4 bottom-4` (or
|
|
279
|
-
// `left-4 right-4`) aligns the handle with the panel's bounds.
|
|
280
|
-
const trackPosition =
|
|
281
|
-
side === 'right'
|
|
282
|
-
? 'right-full top-4 bottom-4 w-3 items-center justify-end pr-1'
|
|
283
|
-
: side === 'left'
|
|
284
|
-
? 'left-full top-4 bottom-4 w-3 items-center justify-start pl-1'
|
|
285
|
-
: side === 'bottom'
|
|
286
|
-
? 'bottom-full left-4 right-4 h-3 justify-center items-end pb-1'
|
|
287
|
-
: 'top-full left-4 right-4 h-3 justify-center items-start pt-1';
|
|
288
|
-
|
|
289
|
-
const cursorClass = isHorizontal ? 'cursor-col-resize' : 'cursor-row-resize';
|
|
290
|
-
|
|
291
|
-
const gripClass = isHorizontal ? 'h-10 w-1' : 'w-10 h-1';
|
|
292
|
-
|
|
293
|
-
return (
|
|
294
|
-
<div
|
|
295
|
-
role="separator"
|
|
296
|
-
tabIndex={0}
|
|
297
|
-
aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
|
|
298
|
-
aria-valuenow={Math.round(size)}
|
|
299
|
-
aria-valuemin={minSize}
|
|
300
|
-
aria-valuemax={maxSize}
|
|
301
|
-
aria-label={ariaLabel ?? (isHorizontal ? 'Resize panel width' : 'Resize panel height')}
|
|
302
|
-
onPointerDown={handlePointerDown}
|
|
303
|
-
onPointerMove={handlePointerMove}
|
|
304
|
-
onPointerUp={endDrag}
|
|
305
|
-
onPointerCancel={endDrag}
|
|
306
|
-
onKeyDown={handleKeyDown}
|
|
307
|
-
className={cn(
|
|
308
|
-
'group absolute z-20 flex touch-none select-none',
|
|
309
|
-
'outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0',
|
|
310
|
-
trackPosition,
|
|
311
|
-
cursorClass,
|
|
312
|
-
)}
|
|
313
|
-
>
|
|
314
|
-
<div aria-hidden className={cn('rounded-full bg-ods-bg-surface', gripClass)} />
|
|
315
|
-
</div>
|
|
140
|
+
const subscribeToResize = (onChange: () => void) => {
|
|
141
|
+
window.addEventListener('resize', onChange);
|
|
142
|
+
return () => window.removeEventListener('resize', onChange);
|
|
143
|
+
};
|
|
144
|
+
const subscribeToNothing = () => () => {};
|
|
145
|
+
const noExtent = () => 0;
|
|
146
|
+
|
|
147
|
+
/** The viewport's extent along the resize axis, in px. 0 on the server and
|
|
148
|
+
* while `enabled` is false, when nothing listens for resizes at all. A change
|
|
149
|
+
* on the other axis (a phone's URL bar collapsing) re-renders nothing. */
|
|
150
|
+
function useViewportExtent(enabled: boolean, isHorizontal: boolean): number {
|
|
151
|
+
return useSyncExternalStore(
|
|
152
|
+
enabled ? subscribeToResize : subscribeToNothing,
|
|
153
|
+
enabled ? (isHorizontal ? () => window.innerWidth : () => window.innerHeight) : noExtent,
|
|
154
|
+
noExtent,
|
|
316
155
|
);
|
|
317
156
|
}
|
|
318
157
|
|
|
@@ -326,9 +165,12 @@ interface DrawerContentBaseProps
|
|
|
326
165
|
minSize?: number;
|
|
327
166
|
/** Maximum allowed size (px) when resizable. Also clamped by viewport. */
|
|
328
167
|
maxSize?: number;
|
|
329
|
-
/**
|
|
330
|
-
|
|
331
|
-
|
|
168
|
+
/** Size (px) while the user has not resized the panel. Never stored. Pass a
|
|
169
|
+
* function of the viewport's extent (0 on the server) for a default that
|
|
170
|
+
* tracks the window. */
|
|
171
|
+
defaultSize?: PanelDefaultSize;
|
|
172
|
+
/** localStorage key for the size the user chose with the handle. Only that
|
|
173
|
+
* choice is stored; see `useResizablePanelSize`. */
|
|
332
174
|
storageKey?: string;
|
|
333
175
|
/** Pixel breakpoint below which `resizable` is disabled and inline
|
|
334
176
|
* size is not applied (so consumer CSS can render full-viewport).
|
|
@@ -400,8 +242,6 @@ const DrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Content>, D
|
|
|
400
242
|
) => {
|
|
401
243
|
const resolvedSide: DrawerSide = side ?? 'right';
|
|
402
244
|
const isHorizontal = HORIZONTAL_SIDES.has(resolvedSide);
|
|
403
|
-
const initialSize = defaultSize ?? (isHorizontal ? 560 : 480);
|
|
404
|
-
|
|
405
245
|
const headerHeight = useHeaderHeight();
|
|
406
246
|
|
|
407
247
|
const [isMobile, setIsMobile] = useState(false);
|
|
@@ -414,12 +254,21 @@ const DrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Content>, D
|
|
|
414
254
|
return () => mq.removeEventListener('change', update);
|
|
415
255
|
}, [mobileBreakpoint]);
|
|
416
256
|
|
|
417
|
-
|
|
257
|
+
// The panel keeps 80px of the viewport free so the page behind it, and the
|
|
258
|
+
// resize handle, stay reachable.
|
|
259
|
+
const available = useViewportExtent(resizable, isHorizontal);
|
|
260
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
261
|
+
const {
|
|
262
|
+
size: resizedSize,
|
|
263
|
+
setSize,
|
|
264
|
+
clampSize,
|
|
265
|
+
} = useResizablePanelSize({
|
|
418
266
|
enabled: resizable,
|
|
419
|
-
isHorizontal,
|
|
420
267
|
minSize,
|
|
421
268
|
maxSize,
|
|
422
|
-
defaultSize:
|
|
269
|
+
defaultSize: defaultSize ?? (isHorizontal ? 560 : 480),
|
|
270
|
+
available,
|
|
271
|
+
reserve: 80,
|
|
423
272
|
storageKey,
|
|
424
273
|
});
|
|
425
274
|
|
|
@@ -443,16 +292,20 @@ const DrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Content>, D
|
|
|
443
292
|
`overflow-hidden` so the drag track stays visible while the
|
|
444
293
|
panel cleanly clips children to its rounded corners. */}
|
|
445
294
|
{applyInlineSize ? (
|
|
446
|
-
<
|
|
295
|
+
<PanelResizeHandle
|
|
296
|
+
variant="overlay"
|
|
447
297
|
side={resolvedSide}
|
|
448
298
|
size={resizedSize}
|
|
449
299
|
minSize={minSize}
|
|
450
300
|
maxSize={maxSize}
|
|
451
301
|
onSize={setSize}
|
|
302
|
+
clampSize={clampSize}
|
|
303
|
+
panelRef={panelRef}
|
|
452
304
|
ariaLabel={resizeAriaLabel}
|
|
453
305
|
/>
|
|
454
306
|
) : null}
|
|
455
307
|
<div
|
|
308
|
+
ref={panelRef}
|
|
456
309
|
className={cn(drawerPanelVariants({ side, flush, size }), className, panelClassName)}
|
|
457
310
|
style={{ ...sizeStyle, ...panelStyle }}
|
|
458
311
|
>
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type KeyboardEvent, type PointerEvent, type RefObject, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../utils/cn';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The drag-to-resize handle of a side or edge panel, shared by `Drawer` and
|
|
9
|
+
* `AppLayoutDrawer`. A drag writes the panel's size to the DOM and reports it
|
|
10
|
+
* once, on release; arrow keys, Home and End report at once.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
type PanelSide = 'right' | 'left' | 'top' | 'bottom';
|
|
14
|
+
|
|
15
|
+
const HORIZONTAL_SIDES: ReadonlySet<PanelSide> = new Set(['left', 'right']);
|
|
16
|
+
|
|
17
|
+
/** Ends a drawer resize drag: commits the size it reached and restores the
|
|
18
|
+
* page's cursor and text selection. No-op when no drag is in progress. */
|
|
19
|
+
function finishDrag(
|
|
20
|
+
startRef: RefObject<{ x: number; y: number; size: number } | null>,
|
|
21
|
+
dragSizeRef: RefObject<number | null>,
|
|
22
|
+
commit: (size: number) => void,
|
|
23
|
+
): void {
|
|
24
|
+
if (!startRef.current) return;
|
|
25
|
+
startRef.current = null;
|
|
26
|
+
if (dragSizeRef.current !== null) commit(dragSizeRef.current);
|
|
27
|
+
dragSizeRef.current = null;
|
|
28
|
+
document.body.style.cursor = '';
|
|
29
|
+
document.body.style.userSelect = '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PanelResizeHandleProps {
|
|
33
|
+
side: PanelSide;
|
|
34
|
+
size: number;
|
|
35
|
+
minSize: number;
|
|
36
|
+
maxSize: number;
|
|
37
|
+
onSize: (next: number) => void;
|
|
38
|
+
/** Clamp a candidate size to the min/max and the container. */
|
|
39
|
+
clampSize: (next: number) => number;
|
|
40
|
+
/** The panel the size applies to — written directly while dragging. */
|
|
41
|
+
panelRef: RefObject<HTMLDivElement | null>;
|
|
42
|
+
/** `overlay` is the body-level `Drawer`'s handle, `inLayout` the wider one of
|
|
43
|
+
* `AppLayoutDrawer`. They differ in track width and grip colour only. */
|
|
44
|
+
variant: 'overlay' | 'inLayout';
|
|
45
|
+
ariaLabel?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function PanelResizeHandle({
|
|
49
|
+
side,
|
|
50
|
+
size,
|
|
51
|
+
minSize,
|
|
52
|
+
maxSize,
|
|
53
|
+
onSize,
|
|
54
|
+
clampSize,
|
|
55
|
+
panelRef,
|
|
56
|
+
variant,
|
|
57
|
+
ariaLabel,
|
|
58
|
+
}: PanelResizeHandleProps) {
|
|
59
|
+
const isHorizontal = HORIZONTAL_SIDES.has(side);
|
|
60
|
+
const startRef = useRef<{ x: number; y: number; size: number } | null>(null);
|
|
61
|
+
// Size reached by the drag in progress, committed to React state on release.
|
|
62
|
+
const dragSizeRef = useRef<number | null>(null);
|
|
63
|
+
const onSizeRef = useRef(onSize);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
onSizeRef.current = onSize;
|
|
66
|
+
}, [onSize]);
|
|
67
|
+
// The handle can unmount mid-drag (a persist-mode drawer closing), and then
|
|
68
|
+
// no pointer event ends the drag: end it here, or the panel keeps a width
|
|
69
|
+
// React state never received and the page keeps the resize cursor.
|
|
70
|
+
useEffect(() => () => finishDrag(startRef, dragSizeRef, onSizeRef.current), []);
|
|
71
|
+
|
|
72
|
+
const direction = side === 'right' || side === 'bottom' ? -1 : 1;
|
|
73
|
+
|
|
74
|
+
const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
|
75
|
+
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
startRef.current = { x: e.clientX, y: e.clientY, size };
|
|
78
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
79
|
+
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
|
|
80
|
+
document.body.style.userSelect = 'none';
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// The drag writes the panel's size straight to the DOM and commits it to
|
|
84
|
+
// state once, on release. Committing every pointermove re-rendered the
|
|
85
|
+
// drawer per event — ~16% of a drag's CPU in WebKit on top of the reflow the
|
|
86
|
+
// resize itself costs. ResizeObserver consumers inside the panel still see
|
|
87
|
+
// every change. A re-render of the drawer mid-drag (e.g. a streamed chunk)
|
|
88
|
+
// does not undo the write: React diffs against its previous render, where
|
|
89
|
+
// the size is unchanged, so it leaves the style alone.
|
|
90
|
+
const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
|
91
|
+
const start = startRef.current;
|
|
92
|
+
const panel = panelRef.current;
|
|
93
|
+
if (!start || !panel) return;
|
|
94
|
+
const delta = isHorizontal ? e.clientX - start.x : e.clientY - start.y;
|
|
95
|
+
const next = clampSize(start.size + delta * direction);
|
|
96
|
+
dragSizeRef.current = next;
|
|
97
|
+
panel.style[isHorizontal ? 'width' : 'height'] = `${next}px`;
|
|
98
|
+
e.currentTarget.setAttribute('aria-valuenow', String(Math.round(next)));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const endDrag = (e: PointerEvent<HTMLDivElement>) => {
|
|
102
|
+
if (!startRef.current) return;
|
|
103
|
+
finishDrag(startRef, dragSizeRef, onSize);
|
|
104
|
+
try {
|
|
105
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore — pointer may already be released
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
112
|
+
const step = e.shiftKey ? 40 : 16;
|
|
113
|
+
if (isHorizontal) {
|
|
114
|
+
if (e.key === 'ArrowLeft') {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
onSize(size + step * (side === 'right' ? 1 : -1));
|
|
117
|
+
} else if (e.key === 'ArrowRight') {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
onSize(size + step * (side === 'right' ? -1 : 1));
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
if (e.key === 'ArrowUp') {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
onSize(size + step * (side === 'bottom' ? 1 : -1));
|
|
125
|
+
} else if (e.key === 'ArrowDown') {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
onSize(size + step * (side === 'bottom' ? -1 : 1));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (e.key === 'Home') {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
onSize(minSize);
|
|
133
|
+
} else if (e.key === 'End') {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
onSize(maxSize);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// The handle is a SIBLING of the panel (a child of the dialog content), so the
|
|
140
|
+
// panel can clip its children without clipping the handle. The content
|
|
141
|
+
// wrapper pads the panel by 16px wherever the handle shows, which is what
|
|
142
|
+
// `top-4 bottom-4` (or `left-4 right-4`) lines the track up with.
|
|
143
|
+
const track = variant === 'inLayout' ? { w: 'w-6', h: 'h-6' } : { w: 'w-3', h: 'h-3' };
|
|
144
|
+
const trackPosition =
|
|
145
|
+
side === 'right'
|
|
146
|
+
? `right-full top-4 bottom-4 ${track.w} items-center justify-end pr-1`
|
|
147
|
+
: side === 'left'
|
|
148
|
+
? `left-full top-4 bottom-4 ${track.w} items-center justify-start pl-1`
|
|
149
|
+
: side === 'bottom'
|
|
150
|
+
? `bottom-full left-4 right-4 ${track.h} justify-center items-end pb-1`
|
|
151
|
+
: `top-full left-4 right-4 ${track.h} justify-center items-start pt-1`;
|
|
152
|
+
|
|
153
|
+
const cursorClass = isHorizontal ? 'cursor-col-resize' : 'cursor-row-resize';
|
|
154
|
+
const gripClass = isHorizontal ? 'h-10 w-1' : 'w-10 h-1';
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
role="separator"
|
|
159
|
+
tabIndex={0}
|
|
160
|
+
aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
|
|
161
|
+
aria-valuenow={Math.round(size)}
|
|
162
|
+
aria-valuemin={minSize}
|
|
163
|
+
aria-valuemax={maxSize}
|
|
164
|
+
aria-label={ariaLabel ?? (isHorizontal ? 'Resize panel width' : 'Resize panel height')}
|
|
165
|
+
onPointerDown={handlePointerDown}
|
|
166
|
+
onPointerMove={handlePointerMove}
|
|
167
|
+
onPointerUp={endDrag}
|
|
168
|
+
onPointerCancel={endDrag}
|
|
169
|
+
onLostPointerCapture={endDrag}
|
|
170
|
+
onKeyDown={handleKeyDown}
|
|
171
|
+
className={cn(
|
|
172
|
+
'group absolute z-20 flex touch-none select-none',
|
|
173
|
+
'outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0',
|
|
174
|
+
trackPosition,
|
|
175
|
+
cursorClass,
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
<div
|
|
179
|
+
aria-hidden
|
|
180
|
+
className={cn('rounded-full', variant === 'inLayout' ? 'bg-ods-border' : 'bg-ods-bg-surface', gripClass)}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { clamp } from '../../utils/common';
|
|
6
|
+
import { createLocalStorageAdapter } from '../../utils/local-storage-adapter';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The size of a drag-to-resize panel (`Drawer`, `AppLayoutDrawer`).
|
|
10
|
+
*
|
|
11
|
+
* Two values are kept apart, and only one of them is ever stored:
|
|
12
|
+
*
|
|
13
|
+
* - the size the user CHOSE (a drag or a key press on the handle), stored, and
|
|
14
|
+
* `null` until they choose one;
|
|
15
|
+
* - the size RENDERED, derived on every render from the choice (or, without
|
|
16
|
+
* one, from `defaultSize`) and clamped to the room there is right now.
|
|
17
|
+
*
|
|
18
|
+
* The room changing never writes anything. A narrow window, docked dev tools or
|
|
19
|
+
* a phone-sized emulation shrink the panel for as long as they last and the
|
|
20
|
+
* chosen size comes back with the room. A panel nobody resized follows
|
|
21
|
+
* `defaultSize`: given as a function of the room, it tracks the window, and a
|
|
22
|
+
* changed default reaches everyone who never chose a size.
|
|
23
|
+
*
|
|
24
|
+
* The choice is stored as `{ size }` under `<storageKey>:chosen`, never under
|
|
25
|
+
* `storageKey` itself. The previous implementation wrote a bare number there,
|
|
26
|
+
* defaults and clamped sizes included, so that value cannot be told apart from
|
|
27
|
+
* a real choice and is not read; and a page still running that implementation
|
|
28
|
+
* (a tab left open across a deploy) keeps writing it, which must not replace
|
|
29
|
+
* the choice.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** A fixed size in px, or one computed from the room along the resize axis
|
|
33
|
+
* (0 while the room is not measured, and on the server). */
|
|
34
|
+
export type PanelDefaultSize = number | ((available: number) => number);
|
|
35
|
+
|
|
36
|
+
const CHOSEN_SIZE_KEY_SUFFIX = ':chosen';
|
|
37
|
+
|
|
38
|
+
export interface UseResizablePanelSizeArgs {
|
|
39
|
+
/** False leaves the panel at `defaultSize` and reads or writes nothing. */
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
minSize: number;
|
|
42
|
+
maxSize: number;
|
|
43
|
+
/** Size while the user has chosen none. */
|
|
44
|
+
defaultSize: PanelDefaultSize;
|
|
45
|
+
/** Room along the resize axis, in px. 0 while it is not measured yet. */
|
|
46
|
+
available: number;
|
|
47
|
+
/** Px of `available` the panel never takes (the gap that keeps the handle on screen). */
|
|
48
|
+
reserve: number;
|
|
49
|
+
/** Base localStorage key for the chosen size. Omit to keep it for the mount only. */
|
|
50
|
+
storageKey?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface StoredPanelSize {
|
|
54
|
+
size: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isStoredPanelSize(value: unknown): value is StoredPanelSize {
|
|
58
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
59
|
+
const { size } = value as { size?: unknown };
|
|
60
|
+
return typeof size === 'number' && Number.isFinite(size) && size > 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useResizablePanelSize({
|
|
64
|
+
enabled,
|
|
65
|
+
minSize,
|
|
66
|
+
maxSize,
|
|
67
|
+
defaultSize,
|
|
68
|
+
available,
|
|
69
|
+
reserve,
|
|
70
|
+
storageKey,
|
|
71
|
+
}: UseResizablePanelSizeArgs) {
|
|
72
|
+
const storage = useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
enabled && storageKey
|
|
75
|
+
? createLocalStorageAdapter<StoredPanelSize>({
|
|
76
|
+
key: `${storageKey}${CHOSEN_SIZE_KEY_SUFFIX}`,
|
|
77
|
+
validate: isStoredPanelSize,
|
|
78
|
+
logTag: `[panel-size:${storageKey}]`,
|
|
79
|
+
})
|
|
80
|
+
: null,
|
|
81
|
+
[enabled, storageKey],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// The choice belongs to the storage it was read from. When that changes (a
|
|
85
|
+
// drawer reused under another key, or resizing switched on after mount) it is
|
|
86
|
+
// read again during render, so no frame shows the other key's size.
|
|
87
|
+
const [held, setChosen] = useState(() => ({ storage, size: storage?.load()?.size ?? null }));
|
|
88
|
+
const chosen = held.storage === storage ? held : { storage, size: storage?.load()?.size ?? null };
|
|
89
|
+
if (chosen !== held) setChosen(chosen);
|
|
90
|
+
const chosenSize = chosen.size;
|
|
91
|
+
|
|
92
|
+
const clampSize = useCallback(
|
|
93
|
+
(value: number) => {
|
|
94
|
+
const ceiling = available > 0 ? Math.min(maxSize, available - reserve) : maxSize;
|
|
95
|
+
return clamp(value, minSize, Math.max(minSize, ceiling));
|
|
96
|
+
},
|
|
97
|
+
[available, reserve, minSize, maxSize],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const fallbackSize = typeof defaultSize === 'function' ? defaultSize(available) : defaultSize;
|
|
101
|
+
const size = enabled ? clampSize(chosenSize ?? fallbackSize) : fallbackSize;
|
|
102
|
+
|
|
103
|
+
/** The user's choice. The only path that stores a size. */
|
|
104
|
+
const setSize = useCallback(
|
|
105
|
+
(next: number) => {
|
|
106
|
+
const nextSize = Math.round(clampSize(next));
|
|
107
|
+
setChosen({ storage, size: nextSize });
|
|
108
|
+
storage?.save({ size: nextSize });
|
|
109
|
+
},
|
|
110
|
+
[clampSize, storage],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return { size, setSize, clampSize };
|
|
114
|
+
}
|
package/tailwind.config.ts
CHANGED
|
@@ -413,6 +413,11 @@ const config: Config = {
|
|
|
413
413
|
'0%, 80%, 100%': { transform: 'scale(1)', opacity: '0.7' },
|
|
414
414
|
'40%': { transform: 'scale(1.5)', opacity: '1' },
|
|
415
415
|
},
|
|
416
|
+
// The dot on a LIVE tag: opacity only, so it reads as "on air" without moving.
|
|
417
|
+
'live-pulse': {
|
|
418
|
+
'0%, 100%': { opacity: '1' },
|
|
419
|
+
'50%': { opacity: '0.25' },
|
|
420
|
+
},
|
|
416
421
|
},
|
|
417
422
|
animation: {
|
|
418
423
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
|
@@ -420,6 +425,7 @@ const config: Config = {
|
|
|
420
425
|
'fade-in': 'fade-in 0.3s ease-out',
|
|
421
426
|
'cursor-blink': 'cursor-blink 1s steps(1) infinite',
|
|
422
427
|
'dot-pulse': 'dot-pulse 1.4s ease-in-out infinite',
|
|
428
|
+
'live-pulse': 'live-pulse 1.6s ease-in-out infinite',
|
|
423
429
|
},
|
|
424
430
|
},
|
|
425
431
|
},
|