@hyphen/hyphen-components 9.0.0 → 9.1.1

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.
@@ -0,0 +1,222 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect';
3
+
4
+ export interface SidebarResizeProps {
5
+ /** Enable desktop resizing with SidebarRail. Disabled by default. */
6
+ resizable?: boolean;
7
+ /** Initial expanded width in pixels. Defaults to 256 left / 384 right. */
8
+ defaultWidth?: number;
9
+ /** Minimum expanded width in pixels. Defaults to 256. */
10
+ minWidth?: number;
11
+ /** Maximum expanded width in pixels, also capped at 2/3 of the provider. */
12
+ maxWidth?: number;
13
+ /** Optional localStorage key for the preferred expanded width. */
14
+ widthStorageKey?: string;
15
+ }
16
+
17
+ export function useSidebarResize({
18
+ resizable = false,
19
+ defaultWidth,
20
+ minWidth = 256,
21
+ maxWidth = 960,
22
+ widthStorageKey,
23
+ side,
24
+ isMobile,
25
+ expanded,
26
+ rootRef,
27
+ }: SidebarResizeProps & {
28
+ side: 'left' | 'right';
29
+ isMobile: boolean;
30
+ expanded: boolean;
31
+ rootRef: React.RefObject<HTMLDivElement>;
32
+ }) {
33
+ const initialWidth = defaultWidth ?? (side === 'left' ? 256 : 384);
34
+ const [preferredWidth, setPreferredWidth] = useState(initialWidth);
35
+ const [containerWidth, setContainerWidth] = useState<number>(Infinity);
36
+ const [dragging, setDragging] = useState(false);
37
+ const gesture = useRef<{
38
+ id: number;
39
+ x: number;
40
+ width: number;
41
+ preferred: number;
42
+ moved: boolean;
43
+ } | null>(null);
44
+ const suppressClick = useRef(false);
45
+ const currentWidth = useRef(preferredWidth);
46
+ const enabled = resizable && !isMobile && expanded;
47
+ const maximum = Math.max(0, Math.min(maxWidth, containerWidth * (2 / 3)));
48
+ const minimum = Math.min(Math.max(0, minWidth), maximum);
49
+ const clamp = (value: number) => Math.max(minimum, Math.min(maximum, value));
50
+ const width = clamp(preferredWidth);
51
+ const [settledWidth, setSettledWidth] = useState(width);
52
+
53
+ // Restore and measure before paint so the first frame uses the saved width.
54
+ useIsomorphicLayoutEffect(() => {
55
+ if (!resizable) return;
56
+ let savedWidth = initialWidth;
57
+ try {
58
+ const saved = widthStorageKey && localStorage.getItem(widthStorageKey);
59
+ if (saved && Number.isFinite(Number(saved)) && Number(saved) > 0) {
60
+ savedWidth = Number(saved);
61
+ }
62
+ } catch {
63
+ // Resizing still works when browser storage is unavailable.
64
+ }
65
+ setPreferredWidth(savedWidth);
66
+ }, [resizable, initialWidth, widthStorageKey]);
67
+
68
+ useIsomorphicLayoutEffect(() => {
69
+ if (!resizable || isMobile) return;
70
+ const container = rootRef.current?.closest('[data-sidebar-provider]');
71
+ if (!container) return;
72
+ const measure = () =>
73
+ setContainerWidth(container.getBoundingClientRect().width);
74
+ measure();
75
+ if (typeof ResizeObserver === 'undefined') {
76
+ window.addEventListener('resize', measure);
77
+ return () => window.removeEventListener('resize', measure);
78
+ }
79
+ const observer = new ResizeObserver(measure);
80
+ observer.observe(container);
81
+ return () => observer.disconnect();
82
+ }, [resizable, isMobile, rootRef]);
83
+
84
+ // Only expanding and collapsing animate. A width change (restore, container
85
+ // cap, keyboard, drag release) is committed with transitions off; flushing
86
+ // styles at that width lets the next render turn them back on without the
87
+ // browser animating from the old width.
88
+ useIsomorphicLayoutEffect(() => {
89
+ if (dragging || width === settledWidth) return;
90
+ rootRef.current?.getBoundingClientRect();
91
+ setSettledWidth(width);
92
+ }, [dragging, width, settledWidth, rootRef]);
93
+
94
+ useEffect(() => {
95
+ if (!enabled) {
96
+ gesture.current = null;
97
+ setDragging(false);
98
+ }
99
+ }, [enabled]);
100
+
101
+ useEffect(() => {
102
+ if (!dragging) return;
103
+ const { cursor, userSelect } = document.body.style;
104
+ document.body.style.cursor = 'col-resize';
105
+ document.body.style.userSelect = 'none';
106
+ return () => {
107
+ document.body.style.cursor = cursor;
108
+ document.body.style.userSelect = userSelect;
109
+ };
110
+ }, [dragging]);
111
+
112
+ const update = (next: number) => {
113
+ currentWidth.current = clamp(next);
114
+ setPreferredWidth(currentWidth.current);
115
+ };
116
+ const persist = () => {
117
+ if (!widthStorageKey) return;
118
+ try {
119
+ localStorage.setItem(widthStorageKey, String(currentWidth.current));
120
+ } catch {
121
+ // A storage failure must not interrupt the interaction.
122
+ }
123
+ };
124
+ const finish = () => {
125
+ if (!gesture.current) return;
126
+ if (gesture.current.moved) persist();
127
+ gesture.current = null;
128
+ setDragging(false);
129
+ };
130
+ const cancel = () => {
131
+ if (!gesture.current) return;
132
+ setPreferredWidth(gesture.current.preferred);
133
+ gesture.current = null;
134
+ setDragging(false);
135
+ };
136
+
137
+ const railProps: React.ComponentProps<'button'> = {
138
+ onPointerDown: (event) => {
139
+ suppressClick.current = false;
140
+ if (!enabled || event.button !== 0 || gesture.current) return;
141
+ gesture.current = {
142
+ id: event.pointerId,
143
+ x: event.clientX,
144
+ width,
145
+ preferred: preferredWidth,
146
+ moved: false,
147
+ };
148
+ event.currentTarget.setPointerCapture(event.pointerId);
149
+ },
150
+ onPointerMove: (event) => {
151
+ const start = gesture.current;
152
+ if (!start || start.id !== event.pointerId) return;
153
+ const delta = event.clientX - start.x;
154
+ if (!start.moved && Math.abs(delta) < 4) return;
155
+ start.moved = true;
156
+ suppressClick.current = true;
157
+ setDragging(true);
158
+ update(start.width + (side === 'left' ? delta : -delta));
159
+ },
160
+ onPointerUp: (event) => {
161
+ const start = gesture.current;
162
+ if (!start || start.id !== event.pointerId) return;
163
+ if (start.moved) {
164
+ update(
165
+ start.width + (side === 'left' ? 1 : -1) * (event.clientX - start.x)
166
+ );
167
+ }
168
+ finish();
169
+ event.currentTarget.releasePointerCapture(event.pointerId);
170
+ },
171
+ onPointerCancel: cancel,
172
+ // Capture can end before pointerup reaches the rail. Keep the last move
173
+ // instead of treating release as a cancelled drag and restoring its start.
174
+ onLostPointerCapture: (event) => {
175
+ if (gesture.current?.id === event.pointerId) finish();
176
+ },
177
+ onClickCapture: (event) => {
178
+ if (suppressClick.current && event.detail !== 0) {
179
+ event.preventDefault();
180
+ event.stopPropagation();
181
+ suppressClick.current = false;
182
+ }
183
+ },
184
+ onKeyDown: (event) => {
185
+ // Leave modified keys to the browser, e.g. Cmd/Alt+Arrow for history.
186
+ const modified =
187
+ event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
188
+ if (!enabled || modified) return;
189
+ let next: number;
190
+ switch (event.key) {
191
+ case 'ArrowLeft':
192
+ next = width + (side === 'left' ? -10 : 10);
193
+ break;
194
+ case 'ArrowRight':
195
+ next = width + (side === 'left' ? 10 : -10);
196
+ break;
197
+ case 'Home':
198
+ next = minimum;
199
+ break;
200
+ case 'End':
201
+ next = maximum;
202
+ break;
203
+ default:
204
+ return;
205
+ }
206
+ event.preventDefault();
207
+ update(next);
208
+ persist();
209
+ },
210
+ };
211
+
212
+ return {
213
+ width,
214
+ animate: !dragging && width === settledWidth,
215
+ enabled,
216
+ railProps,
217
+ };
218
+ }
219
+
220
+ export const SidebarResizeContext = React.createContext<ReturnType<
221
+ typeof useSidebarResize
222
+ > | null>(null);