@octanejs/resizable-panels 0.0.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.
- package/LICENSE.upstream +21 -0
- package/README.md +22 -0
- package/UPSTREAM.md +64 -0
- package/package.json +61 -0
- package/src/components/group/Group.tsrx +244 -0
- package/src/components/group/GroupContext.ts +25 -0
- package/src/components/group/auto-save/getStorageKey.ts +3 -0
- package/src/components/group/auto-save/readLegacyLayout.ts +37 -0
- package/src/components/group/sortByElementOffset.ts +20 -0
- package/src/components/group/types.ts +50 -0
- package/src/components/group/useDefaultLayout.ts +152 -0
- package/src/components/group/useGroupCallbackRef.ts +6 -0
- package/src/components/group/useGroupContext.ts +12 -0
- package/src/components/group/useGroupImperativeHandle.ts +46 -0
- package/src/components/group/useGroupRef.ts +6 -0
- package/src/components/panel/Panel.tsrx +130 -0
- package/src/components/panel/types.ts +66 -0
- package/src/components/panel/usePanelCallbackRef.ts +6 -0
- package/src/components/panel/usePanelImperativeHandle.ts +42 -0
- package/src/components/panel/usePanelRef.ts +6 -0
- package/src/components/separator/Separator.tsrx +126 -0
- package/src/components/separator/types.ts +22 -0
- package/src/constants.ts +25 -0
- package/src/global/cursor/getCursorStyle.ts +102 -0
- package/src/global/cursor/supportsAdvancedCursorStyles.ts +25 -0
- package/src/global/cursor/updateCursorStyle.ts +77 -0
- package/src/global/dom/calculateAvailableGroupSize.ts +11 -0
- package/src/global/dom/calculateHitRegions.ts +200 -0
- package/src/global/dom/calculatePanelConstraints.ts +84 -0
- package/src/global/event-handlers/onDocumentContextMenu.ts +9 -0
- package/src/global/event-handlers/onDocumentDoubleClick.ts +31 -0
- package/src/global/event-handlers/onDocumentKeyDown.ts +133 -0
- package/src/global/event-handlers/onDocumentPointerDown.ts +56 -0
- package/src/global/event-handlers/onDocumentPointerLeave.ts +21 -0
- package/src/global/event-handlers/onDocumentPointerMove.ts +89 -0
- package/src/global/event-handlers/onDocumentPointerOut.ts +19 -0
- package/src/global/event-handlers/onDocumentPointerUp.ts +14 -0
- package/src/global/mountGroup.ts +213 -0
- package/src/global/mutable-state/groups.ts +104 -0
- package/src/global/mutable-state/interactions.ts +49 -0
- package/src/global/mutable-state/types.ts +31 -0
- package/src/global/styles/convertEmToPixels.ts +6 -0
- package/src/global/styles/convertRemToPixels.ts +6 -0
- package/src/global/styles/convertVhToPixels.ts +3 -0
- package/src/global/styles/convertVwToPixels.ts +3 -0
- package/src/global/styles/parseSizeAndUnit.ts +28 -0
- package/src/global/styles/sizeStyleToPixels.ts +48 -0
- package/src/global/utils/adjustLayoutByDelta.ts +339 -0
- package/src/global/utils/adjustLayoutForSeparator.ts +54 -0
- package/src/global/utils/calculateDefaultLayout.ts +37 -0
- package/src/global/utils/calculateSeparatorAriaValues.ts +67 -0
- package/src/global/utils/compareLayoutNumbers.ts +9 -0
- package/src/global/utils/completeActivePointerResize.ts +37 -0
- package/src/global/utils/doRectsIntersect.ts +5 -0
- package/src/global/utils/findClosestHitRegion.ts +43 -0
- package/src/global/utils/findClosestRect.ts +36 -0
- package/src/global/utils/findMatchingHitRegions.ts +41 -0
- package/src/global/utils/findSeparatorGroup.ts +13 -0
- package/src/global/utils/formatLayoutNumber.ts +3 -0
- package/src/global/utils/getDistanceBetweenPointAndRect.ts +14 -0
- package/src/global/utils/getImperativeGroupMethods.ts +68 -0
- package/src/global/utils/getImperativePanelMethods.ts +228 -0
- package/src/global/utils/isCoarsePointer.ts +8 -0
- package/src/global/utils/isViableHitTarget.ts +53 -0
- package/src/global/utils/layoutNumbersEqual.ts +5 -0
- package/src/global/utils/layoutsEqual.ts +17 -0
- package/src/global/utils/notifyPanelOnResize.ts +33 -0
- package/src/global/utils/objectsEqual.ts +14 -0
- package/src/global/utils/preserveFixedPanelSizes.ts +74 -0
- package/src/global/utils/updateActiveHitRegion.ts +132 -0
- package/src/global/utils/validateLayoutKeys.ts +19 -0
- package/src/global/utils/validatePanelGroupLayout.ts +92 -0
- package/src/global/utils/validatePanelSize.ts +41 -0
- package/src/hooks/useForceUpdate.ts +8 -0
- package/src/hooks/useId.ts +6 -0
- package/src/hooks/useIsomorphicLayoutEffect.ts +4 -0
- package/src/hooks/useMergedRefs.ts +18 -0
- package/src/hooks/useStableArray.ts +15 -0
- package/src/hooks/useStableCallback.ts +18 -0
- package/src/hooks/useStableObject.ts +15 -0
- package/src/index.tsrx +12 -0
- package/src/internal.ts +23 -0
- package/src/types.ts +18 -0
- package/src/utils/EventEmitter.ts +72 -0
- package/src/utils/assert.ts +8 -0
- package/src/utils/isArrayEqual.ts +12 -0
- package/src/utils/isHTMLElement.ts +9 -0
- package/src/utils/isShadowRoot.ts +9 -0
- package/src/utils/parseNumericStyleValue.ts +15 -0
- package/src/vendor/stacking-order.ts +133 -0
- package/status.json +11 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'octane';
|
|
2
|
+
import { splitSlot, subSlot } from '../../internal';
|
|
3
|
+
import { getStorageKey } from './auto-save/getStorageKey';
|
|
4
|
+
import { readLegacyLayout } from './auto-save/readLegacyLayout';
|
|
5
|
+
import type { Layout, LayoutChangedMeta, LayoutStorage } from './types';
|
|
6
|
+
|
|
7
|
+
type UseDefaultLayoutOptions = {
|
|
8
|
+
debounceSaveMs?: number;
|
|
9
|
+
onlySaveAfterUserInteractions?: boolean;
|
|
10
|
+
panelIds?: string[];
|
|
11
|
+
storage?: LayoutStorage;
|
|
12
|
+
} & ({ groupId: string } | { id: string });
|
|
13
|
+
|
|
14
|
+
export function useDefaultLayout(options: UseDefaultLayoutOptions, ...rest: [symbol?]) {
|
|
15
|
+
const [args, slot] = splitSlot(rest);
|
|
16
|
+
if (args.length !== 0) throw new TypeError('useDefaultLayout() accepts one options argument.');
|
|
17
|
+
const {
|
|
18
|
+
debounceSaveMs = 100,
|
|
19
|
+
onlySaveAfterUserInteractions,
|
|
20
|
+
panelIds,
|
|
21
|
+
storage: storageProp,
|
|
22
|
+
...identity
|
|
23
|
+
} = options;
|
|
24
|
+
const id = 'id' in identity ? identity.id : identity.groupId;
|
|
25
|
+
const hasPanelIds = panelIds !== undefined;
|
|
26
|
+
const storage = resolveStorage(storageProp);
|
|
27
|
+
const readStorageKey = getStorageKey(id, panelIds ?? []);
|
|
28
|
+
// Match upstream: the same storage read runs on server and client so
|
|
29
|
+
// cookie/DB-backed LayoutStorage can restore during SSR. Client-only APIs
|
|
30
|
+
// (implicit localStorage) resolve to undefined on the server via
|
|
31
|
+
// resolveStorage, so the snapshot stays null without a custom storage prop.
|
|
32
|
+
const defaultLayoutString = useSyncExternalStore(
|
|
33
|
+
subscribe,
|
|
34
|
+
() => readDefaultLayoutString(storage, readStorageKey, id, panelIds),
|
|
35
|
+
() => readDefaultLayoutString(storage, readStorageKey, id, panelIds),
|
|
36
|
+
subSlot(slot, 'stored-layout'),
|
|
37
|
+
);
|
|
38
|
+
const defaultLayout = useMemo(
|
|
39
|
+
() => readModernLayout(defaultLayoutString),
|
|
40
|
+
[defaultLayoutString],
|
|
41
|
+
subSlot(slot, 'default-layout'),
|
|
42
|
+
);
|
|
43
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null, subSlot(slot, 'timeout'));
|
|
44
|
+
const storageRef = useRef<LayoutStorage | undefined>(storage, subSlot(slot, 'storage'));
|
|
45
|
+
storageRef.current = storage;
|
|
46
|
+
|
|
47
|
+
const clearPendingTimeout = useCallback(
|
|
48
|
+
() => {
|
|
49
|
+
if (timeoutRef.current !== null) {
|
|
50
|
+
clearTimeout(timeoutRef.current);
|
|
51
|
+
timeoutRef.current = null;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[],
|
|
55
|
+
subSlot(slot, 'clear-timeout'),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
useEffect(() => clearPendingTimeout, [clearPendingTimeout], subSlot(slot, 'cleanup-effect'));
|
|
59
|
+
|
|
60
|
+
const onLayoutChanged = useCallback<(layout: Layout, meta: LayoutChangedMeta) => void>(
|
|
61
|
+
(layout: Layout, meta: LayoutChangedMeta) => {
|
|
62
|
+
if (onlySaveAfterUserInteractions && !meta.isUserInteraction) return;
|
|
63
|
+
clearPendingTimeout();
|
|
64
|
+
|
|
65
|
+
const storage = storageRef.current;
|
|
66
|
+
if (!storage) return;
|
|
67
|
+
const key = getStorageKey(id, hasPanelIds ? Object.keys(layout) : []);
|
|
68
|
+
try {
|
|
69
|
+
storage.setItem(key, JSON.stringify(layout));
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error(error);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[clearPendingTimeout, hasPanelIds, id, onlySaveAfterUserInteractions],
|
|
75
|
+
subSlot(slot, 'layout-changed'),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const onLayoutChange = useCallback<(layout: Layout) => void>(
|
|
79
|
+
(layout: Layout) => {
|
|
80
|
+
clearPendingTimeout();
|
|
81
|
+
if (debounceSaveMs === 0) {
|
|
82
|
+
onLayoutChanged(layout, { isUserInteraction: false });
|
|
83
|
+
} else {
|
|
84
|
+
timeoutRef.current = setTimeout(
|
|
85
|
+
() => onLayoutChanged(layout, { isUserInteraction: false }),
|
|
86
|
+
debounceSaveMs,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[clearPendingTimeout, debounceSaveMs, onLayoutChanged],
|
|
91
|
+
subSlot(slot, 'layout-change'),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { defaultLayout, onLayoutChange, onLayoutChanged };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function subscribe() {
|
|
98
|
+
return () => {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveStorage(storage: LayoutStorage | undefined): LayoutStorage | undefined {
|
|
102
|
+
if (storage) return storage;
|
|
103
|
+
try {
|
|
104
|
+
return globalThis.localStorage;
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readStorageItem(storage: LayoutStorage | undefined, key: string): string | null {
|
|
111
|
+
if (!storage) return null;
|
|
112
|
+
try {
|
|
113
|
+
return storage.getItem(key);
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readDefaultLayoutString(
|
|
120
|
+
storage: LayoutStorage | undefined,
|
|
121
|
+
modernKey: string,
|
|
122
|
+
id: string,
|
|
123
|
+
panelIds: readonly string[] | undefined,
|
|
124
|
+
): string | null {
|
|
125
|
+
const modernString = readStorageItem(storage, modernKey);
|
|
126
|
+
if (readModernLayout(modernString)) return modernString;
|
|
127
|
+
if (!storage) return null;
|
|
128
|
+
try {
|
|
129
|
+
const legacy = readLegacyLayout({ id, panelIds, storage });
|
|
130
|
+
return legacy ? JSON.stringify(legacy) : null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readModernLayout(value: string | null): Layout | undefined {
|
|
137
|
+
if (!value) return undefined;
|
|
138
|
+
try {
|
|
139
|
+
const parsed: unknown = JSON.parse(value);
|
|
140
|
+
if (
|
|
141
|
+
parsed !== null &&
|
|
142
|
+
typeof parsed === 'object' &&
|
|
143
|
+
!Array.isArray(parsed) &&
|
|
144
|
+
Object.values(parsed).every((item) => typeof item === 'number')
|
|
145
|
+
) {
|
|
146
|
+
return parsed as Layout;
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// Malformed modern records fall through to the legacy reader.
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useContext } from 'octane';
|
|
2
|
+
import { GroupContext } from './GroupContext';
|
|
3
|
+
|
|
4
|
+
export function useGroupContext(_slot?: symbol) {
|
|
5
|
+
const value = useContext(GroupContext);
|
|
6
|
+
if (value === null) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
'Group Context not found; did you render a Panel or Separator outside of a Group?',
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useRef } from 'octane';
|
|
2
|
+
import { IDENTITY_FUNCTION } from '../../constants';
|
|
3
|
+
import { getImperativeGroupMethods } from '../../global/utils/getImperativeGroupMethods';
|
|
4
|
+
import { subSlot } from '../../internal';
|
|
5
|
+
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
|
|
6
|
+
import type { GroupImperativeHandle, GroupProps } from './types';
|
|
7
|
+
|
|
8
|
+
export function useGroupImperativeHandle(
|
|
9
|
+
groupId: string,
|
|
10
|
+
groupRef: GroupProps['groupRef'],
|
|
11
|
+
slot?: symbol,
|
|
12
|
+
) {
|
|
13
|
+
const imperativeGroupRef = useRef<GroupImperativeHandle>(
|
|
14
|
+
{
|
|
15
|
+
getLayout: () => ({}),
|
|
16
|
+
setLayout: IDENTITY_FUNCTION,
|
|
17
|
+
},
|
|
18
|
+
subSlot(slot, 'ref'),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
useIsomorphicLayoutEffect(
|
|
22
|
+
() => {
|
|
23
|
+
Object.assign(imperativeGroupRef.current, getImperativeGroupMethods({ groupId }));
|
|
24
|
+
assignRef(groupRef, imperativeGroupRef.current);
|
|
25
|
+
return () => assignRef(groupRef, null);
|
|
26
|
+
},
|
|
27
|
+
[groupId, groupRef],
|
|
28
|
+
subSlot(slot, 'effect'),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assignRef(ref: GroupProps['groupRef'], value: GroupImperativeHandle | null): void {
|
|
33
|
+
if (isRefArray(ref)) {
|
|
34
|
+
for (const nested of ref) assignRef(nested, value);
|
|
35
|
+
} else if (typeof ref === 'function') {
|
|
36
|
+
(ref as (next: GroupImperativeHandle | null) => void)(value);
|
|
37
|
+
} else if (ref) {
|
|
38
|
+
(ref as { current: GroupImperativeHandle | null }).current = value;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRefArray(
|
|
43
|
+
ref: GroupProps['groupRef'],
|
|
44
|
+
): ref is readonly NonNullable<GroupProps['groupRef']>[] {
|
|
45
|
+
return Array.isArray(ref);
|
|
46
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useEffect, useRef, useSyncExternalStore } from 'octane';
|
|
2
|
+
import { subscribeToMountedGroup } from '../../global/mutable-state/groups';
|
|
3
|
+
import { useId } from '../../hooks/useId';
|
|
4
|
+
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
|
|
5
|
+
import { useMergedRefs } from '../../hooks/useMergedRefs';
|
|
6
|
+
import { useStableCallback } from '../../hooks/useStableCallback';
|
|
7
|
+
import { useStableObject } from '../../hooks/useStableObject';
|
|
8
|
+
import { useGroupContext } from '../group/useGroupContext';
|
|
9
|
+
import type { PanelProps, PanelSize, RegisteredPanel } from './types';
|
|
10
|
+
import { usePanelImperativeHandle } from './usePanelImperativeHandle';
|
|
11
|
+
|
|
12
|
+
export function Panel(props: PanelProps) @{
|
|
13
|
+
const {
|
|
14
|
+
children,
|
|
15
|
+
className,
|
|
16
|
+
collapsedSize = '0%',
|
|
17
|
+
collapsible = false,
|
|
18
|
+
defaultSize,
|
|
19
|
+
disabled,
|
|
20
|
+
elementRef: elementRefProp,
|
|
21
|
+
groupResizeBehavior = 'preserve-relative-size',
|
|
22
|
+
id: idProp,
|
|
23
|
+
maxSize = '100%',
|
|
24
|
+
minSize = '0%',
|
|
25
|
+
onResize: onResizeUnstable,
|
|
26
|
+
panelRef,
|
|
27
|
+
style,
|
|
28
|
+
...rest
|
|
29
|
+
} = props;
|
|
30
|
+
const idIsStable = !!idProp;
|
|
31
|
+
const id = useId(idProp);
|
|
32
|
+
const stableProps = useStableObject({ disabled });
|
|
33
|
+
const elementRef = useRef<HTMLDivElement | null>(null);
|
|
34
|
+
const mergedRef = useMergedRefs(elementRef, elementRefProp);
|
|
35
|
+
const { getPanelStyles, id: groupId, orientation, registerPanel, updatePanelProps } =
|
|
36
|
+
useGroupContext();
|
|
37
|
+
const hasOnResize = onResizeUnstable != null;
|
|
38
|
+
const onResizeStable = useStableCallback((
|
|
39
|
+
panelSize: PanelSize,
|
|
40
|
+
_id: string | number | undefined,
|
|
41
|
+
prevPanelSize: PanelSize | undefined,
|
|
42
|
+
) => {
|
|
43
|
+
onResizeUnstable?.(panelSize, idProp, prevPanelSize);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
useIsomorphicLayoutEffect(() => {
|
|
47
|
+
const element = elementRef.current;
|
|
48
|
+
if (element === null) return;
|
|
49
|
+
const registeredPanel: RegisteredPanel = {
|
|
50
|
+
element,
|
|
51
|
+
id,
|
|
52
|
+
idIsStable,
|
|
53
|
+
mutableValues: { expandToSize: undefined, prevSize: undefined },
|
|
54
|
+
onResize: hasOnResize ? onResizeStable : undefined,
|
|
55
|
+
panelConstraints: {
|
|
56
|
+
groupResizeBehavior,
|
|
57
|
+
collapsedSize,
|
|
58
|
+
collapsible,
|
|
59
|
+
defaultSize,
|
|
60
|
+
disabled: stableProps.disabled,
|
|
61
|
+
maxSize,
|
|
62
|
+
minSize,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
return registerPanel(registeredPanel);
|
|
66
|
+
}, [groupResizeBehavior, collapsedSize, collapsible, defaultSize, hasOnResize, id, idIsStable, maxSize, minSize, onResizeStable, registerPanel, stableProps]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => updatePanelProps(id, { disabled }), [disabled, id, updatePanelProps]);
|
|
69
|
+
usePanelImperativeHandle(id, panelRef);
|
|
70
|
+
|
|
71
|
+
const read = () => {
|
|
72
|
+
const panelStyles = getPanelStyles(groupId, id);
|
|
73
|
+
return panelStyles === undefined ? undefined : JSON.stringify(panelStyles);
|
|
74
|
+
};
|
|
75
|
+
const panelStylesString = useSyncExternalStore(
|
|
76
|
+
(subscribe) => subscribeToMountedGroup(groupId, () => subscribe()),
|
|
77
|
+
read,
|
|
78
|
+
read,
|
|
79
|
+
);
|
|
80
|
+
const panelStyles =
|
|
81
|
+
panelStylesString
|
|
82
|
+
? JSON.parse(panelStylesString) as Record<string, unknown>
|
|
83
|
+
: defaultSize !== undefined
|
|
84
|
+
? { flexGrow: undefined, flexShrink: undefined, flexBasis: defaultSize }
|
|
85
|
+
: { flexGrow: 1 };
|
|
86
|
+
|
|
87
|
+
return <div
|
|
88
|
+
{...rest}
|
|
89
|
+
data-disabled={disabled || undefined}
|
|
90
|
+
data-panel
|
|
91
|
+
data-testid={id}
|
|
92
|
+
id={id}
|
|
93
|
+
ref={mergedRef}
|
|
94
|
+
style={{
|
|
95
|
+
...PROHIBITED_CSS_PROPERTIES,
|
|
96
|
+
display: 'flex',
|
|
97
|
+
flexBasis: 0,
|
|
98
|
+
flexShrink: 1,
|
|
99
|
+
overflow: 'visible',
|
|
100
|
+
...panelStyles,
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<div
|
|
104
|
+
className={className}
|
|
105
|
+
style={{
|
|
106
|
+
maxHeight: '100%',
|
|
107
|
+
maxWidth: '100%',
|
|
108
|
+
flexGrow: 1,
|
|
109
|
+
overflow: 'auto',
|
|
110
|
+
...style,
|
|
111
|
+
touchAction: orientation === 'horizontal' ? 'pan-y' : 'pan-x',
|
|
112
|
+
}}
|
|
113
|
+
>{children}</div>
|
|
114
|
+
</div>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
(Panel as { displayName?: string }).displayName = 'Panel';
|
|
118
|
+
|
|
119
|
+
const PROHIBITED_CSS_PROPERTIES = {
|
|
120
|
+
minHeight: 0,
|
|
121
|
+
maxHeight: '100%',
|
|
122
|
+
height: 'auto',
|
|
123
|
+
minWidth: 0,
|
|
124
|
+
maxWidth: '100%',
|
|
125
|
+
width: 'auto',
|
|
126
|
+
border: 'none',
|
|
127
|
+
borderWidth: 0,
|
|
128
|
+
padding: 0,
|
|
129
|
+
margin: 0,
|
|
130
|
+
} as const;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { OctaneNode } from 'octane';
|
|
2
|
+
import type { Octane } from 'octane/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
export type PanelSize = { asPercentage: number; inPixels: number };
|
|
5
|
+
export type GroupResizeBehavior = 'preserve-relative-size' | 'preserve-pixel-size';
|
|
6
|
+
export type SizeUnit = 'px' | '%' | 'em' | 'rem' | 'vh' | 'vw';
|
|
7
|
+
export type PanelConstraintProps = {
|
|
8
|
+
collapsedSize?: number | string;
|
|
9
|
+
collapsible?: boolean;
|
|
10
|
+
defaultSize?: number | string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
groupResizeBehavior?: GroupResizeBehavior;
|
|
13
|
+
maxSize?: number | string;
|
|
14
|
+
minSize?: number | string;
|
|
15
|
+
};
|
|
16
|
+
export type PanelConstraints = {
|
|
17
|
+
collapsedSize: number;
|
|
18
|
+
collapsible: boolean;
|
|
19
|
+
defaultSize: number | undefined;
|
|
20
|
+
disabled: boolean | undefined;
|
|
21
|
+
groupResizeBehavior?: GroupResizeBehavior;
|
|
22
|
+
maxSize: number;
|
|
23
|
+
minSize: number;
|
|
24
|
+
panelId: string;
|
|
25
|
+
};
|
|
26
|
+
type PanelResizeCallback = (
|
|
27
|
+
size: PanelSize,
|
|
28
|
+
id: string | number | undefined,
|
|
29
|
+
previousSize: PanelSize | undefined,
|
|
30
|
+
) => void;
|
|
31
|
+
export type RegisteredPanel = {
|
|
32
|
+
id: string;
|
|
33
|
+
idIsStable: boolean;
|
|
34
|
+
element: HTMLDivElement;
|
|
35
|
+
mutableValues: { expandToSize: number | undefined; prevSize: PanelSize | undefined };
|
|
36
|
+
onResize: PanelResizeCallback | undefined;
|
|
37
|
+
panelConstraints: PanelConstraintProps;
|
|
38
|
+
};
|
|
39
|
+
export interface PanelImperativeHandle {
|
|
40
|
+
collapse(): void;
|
|
41
|
+
expand(): void;
|
|
42
|
+
getSize(): PanelSize;
|
|
43
|
+
isCollapsed(): boolean;
|
|
44
|
+
resize(size: number | string): void;
|
|
45
|
+
}
|
|
46
|
+
type DivStyle = Exclude<Octane.JSX.IntrinsicElements['div']['style'], string | undefined>;
|
|
47
|
+
type DivProps = Omit<
|
|
48
|
+
Octane.JSX.IntrinsicElements['div'],
|
|
49
|
+
'children' | 'id' | 'ref' | 'onResize' | 'style'
|
|
50
|
+
>;
|
|
51
|
+
export type PanelProps = DivProps & {
|
|
52
|
+
children?: OctaneNode;
|
|
53
|
+
collapsedSize?: number | string;
|
|
54
|
+
collapsible?: boolean;
|
|
55
|
+
defaultSize?: number | string;
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
elementRef?: Octane.Ref<HTMLDivElement | null>;
|
|
58
|
+
groupResizeBehavior?: GroupResizeBehavior;
|
|
59
|
+
id?: string | number;
|
|
60
|
+
maxSize?: number | string;
|
|
61
|
+
minSize?: number | string;
|
|
62
|
+
onResize?: PanelResizeCallback;
|
|
63
|
+
panelRef?: Octane.Ref<PanelImperativeHandle | null>;
|
|
64
|
+
style?: DivStyle;
|
|
65
|
+
};
|
|
66
|
+
export type OnPanelResize = PanelProps['onResize'];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useImperativeHandle, useRef } from 'octane';
|
|
2
|
+
import type { Octane } from 'octane/jsx-runtime';
|
|
3
|
+
import { subSlot } from '../../internal';
|
|
4
|
+
import { getImperativePanelMethods } from '../../global/utils/getImperativePanelMethods';
|
|
5
|
+
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
|
|
6
|
+
import { useMergedRefs } from '../../hooks/useMergedRefs';
|
|
7
|
+
import { useGroupContext } from '../group/useGroupContext';
|
|
8
|
+
import type { PanelImperativeHandle } from './types';
|
|
9
|
+
|
|
10
|
+
const noop = () => {};
|
|
11
|
+
|
|
12
|
+
export function usePanelImperativeHandle(
|
|
13
|
+
panelId: string,
|
|
14
|
+
panelRef: Octane.Ref<PanelImperativeHandle | null> | undefined,
|
|
15
|
+
slot?: symbol,
|
|
16
|
+
) {
|
|
17
|
+
const { id: groupId } = useGroupContext();
|
|
18
|
+
const imperativePanelRef = useRef<PanelImperativeHandle>(
|
|
19
|
+
{
|
|
20
|
+
collapse: noop,
|
|
21
|
+
expand: noop,
|
|
22
|
+
getSize: () => ({ asPercentage: 0, inPixels: 0 }),
|
|
23
|
+
isCollapsed: () => false,
|
|
24
|
+
resize: noop,
|
|
25
|
+
},
|
|
26
|
+
subSlot(slot, 'ref'),
|
|
27
|
+
);
|
|
28
|
+
const mergedPanelRef = useMergedRefs(panelRef, subSlot(slot, 'merged-ref'));
|
|
29
|
+
useImperativeHandle(
|
|
30
|
+
mergedPanelRef,
|
|
31
|
+
() => imperativePanelRef.current,
|
|
32
|
+
[],
|
|
33
|
+
subSlot(slot, 'handle'),
|
|
34
|
+
);
|
|
35
|
+
useIsomorphicLayoutEffect(
|
|
36
|
+
() => {
|
|
37
|
+
Object.assign(imperativePanelRef.current, getImperativePanelMethods({ groupId, panelId }));
|
|
38
|
+
},
|
|
39
|
+
[groupId, panelId],
|
|
40
|
+
subSlot(slot, 'effect'),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'octane';
|
|
2
|
+
import { subscribeToMountedGroup } from '../../global/mutable-state/groups';
|
|
3
|
+
import { subscribeToInteractionState } from '../../global/mutable-state/interactions';
|
|
4
|
+
import type { InteractionState } from '../../global/mutable-state/types';
|
|
5
|
+
import { calculateSeparatorAriaValues } from '../../global/utils/calculateSeparatorAriaValues';
|
|
6
|
+
import { useId } from '../../hooks/useId';
|
|
7
|
+
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
|
|
8
|
+
import { useMergedRefs } from '../../hooks/useMergedRefs';
|
|
9
|
+
import { useStableObject } from '../../hooks/useStableObject';
|
|
10
|
+
import { useGroupContext } from '../group/useGroupContext';
|
|
11
|
+
import type { RegisteredSeparator, SeparatorProps } from './types';
|
|
12
|
+
|
|
13
|
+
type SeparatorAria = {
|
|
14
|
+
valueControls?: string;
|
|
15
|
+
valueMin?: number;
|
|
16
|
+
valueMax?: number;
|
|
17
|
+
valueNow?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function Separator(props: SeparatorProps) @{
|
|
21
|
+
const {
|
|
22
|
+
children,
|
|
23
|
+
className,
|
|
24
|
+
disabled,
|
|
25
|
+
disableDoubleClick,
|
|
26
|
+
elementRef: elementRefProp,
|
|
27
|
+
id: idProp,
|
|
28
|
+
style,
|
|
29
|
+
...rest
|
|
30
|
+
} = props;
|
|
31
|
+
const id = useId(idProp);
|
|
32
|
+
const stableProps = useStableObject({ disabled, disableDoubleClick });
|
|
33
|
+
const [aria, setAria] = useState<SeparatorAria>({});
|
|
34
|
+
const [dragState, setDragState] = useState<InteractionState['state']>('inactive');
|
|
35
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
36
|
+
const elementRef = useRef<HTMLDivElement | null>(null);
|
|
37
|
+
const mergedRef = useMergedRefs(elementRef, elementRefProp);
|
|
38
|
+
const {
|
|
39
|
+
disableCursor,
|
|
40
|
+
id: groupId,
|
|
41
|
+
orientation: groupOrientation,
|
|
42
|
+
registerSeparator,
|
|
43
|
+
updateSeparatorProps,
|
|
44
|
+
} = useGroupContext();
|
|
45
|
+
const orientation =
|
|
46
|
+
groupOrientation === 'horizontal' ? 'vertical' : 'horizontal';
|
|
47
|
+
|
|
48
|
+
useIsomorphicLayoutEffect(() => {
|
|
49
|
+
const element = elementRef.current;
|
|
50
|
+
if (element === null) return;
|
|
51
|
+
const separator: RegisteredSeparator = {
|
|
52
|
+
disabled: stableProps.disabled,
|
|
53
|
+
disableDoubleClick: stableProps.disableDoubleClick,
|
|
54
|
+
element,
|
|
55
|
+
id,
|
|
56
|
+
};
|
|
57
|
+
const unregisterSeparator = registerSeparator(separator);
|
|
58
|
+
const removeInteractionListener = subscribeToInteractionState((event) => {
|
|
59
|
+
setDragState(
|
|
60
|
+
event.next.state !== 'inactive' &&
|
|
61
|
+
event.next.hitRegions.some((region) => region.separator === separator)
|
|
62
|
+
? event.next.state
|
|
63
|
+
: 'inactive',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
const removeGroupListener = subscribeToMountedGroup(groupId, (event) => {
|
|
67
|
+
const { derivedPanelConstraints, layout, separatorToPanels } = event.next;
|
|
68
|
+
const panels = separatorToPanels.get(separator);
|
|
69
|
+
if (!panels) return;
|
|
70
|
+
const primaryPanel = panels[0];
|
|
71
|
+
if (!primaryPanel) return;
|
|
72
|
+
setAria(
|
|
73
|
+
calculateSeparatorAriaValues({
|
|
74
|
+
layout,
|
|
75
|
+
panelConstraints: derivedPanelConstraints,
|
|
76
|
+
panelId: primaryPanel.id,
|
|
77
|
+
panelIndex: panels.indexOf(primaryPanel),
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
return () => {
|
|
82
|
+
removeInteractionListener();
|
|
83
|
+
removeGroupListener();
|
|
84
|
+
unregisterSeparator();
|
|
85
|
+
};
|
|
86
|
+
}, [groupId, id, registerSeparator, stableProps]);
|
|
87
|
+
|
|
88
|
+
useEffect(() => updateSeparatorProps(id, { disabled, disableDoubleClick }), [
|
|
89
|
+
disabled,
|
|
90
|
+
disableDoubleClick,
|
|
91
|
+
id,
|
|
92
|
+
updateSeparatorProps,
|
|
93
|
+
]);
|
|
94
|
+
const cursor =
|
|
95
|
+
disabled && !disableCursor ? 'not-allowed' : undefined;
|
|
96
|
+
const dataSeparator =
|
|
97
|
+
disabled
|
|
98
|
+
? 'disabled'
|
|
99
|
+
: dragState === 'active'
|
|
100
|
+
? 'active'
|
|
101
|
+
: isFocused
|
|
102
|
+
? 'focus'
|
|
103
|
+
: dragState;
|
|
104
|
+
|
|
105
|
+
return <div
|
|
106
|
+
{...rest}
|
|
107
|
+
aria-controls={aria.valueControls}
|
|
108
|
+
aria-disabled={disabled || undefined}
|
|
109
|
+
aria-orientation={orientation}
|
|
110
|
+
aria-valuemax={aria.valueMax}
|
|
111
|
+
aria-valuemin={aria.valueMin}
|
|
112
|
+
aria-valuenow={aria.valueNow}
|
|
113
|
+
className={className}
|
|
114
|
+
data-separator={dataSeparator}
|
|
115
|
+
data-testid={id}
|
|
116
|
+
id={id}
|
|
117
|
+
onBlur={() => setIsFocused(false)}
|
|
118
|
+
onFocus={() => setIsFocused(true)}
|
|
119
|
+
ref={mergedRef}
|
|
120
|
+
role="separator"
|
|
121
|
+
style={{ flexBasis: 'auto', cursor, ...style, flexGrow: 0, flexShrink: 0, touchAction: 'none' }}
|
|
122
|
+
tabIndex={disabled ? undefined : 0}
|
|
123
|
+
>{children}</div>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
(Separator as { displayName?: string }).displayName = 'Separator';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { OctaneNode } from 'octane';
|
|
2
|
+
import type { Octane } from 'octane/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
type DivStyle = Exclude<Octane.JSX.IntrinsicElements['div']['style'], string | undefined>;
|
|
5
|
+
type DivProps = Omit<
|
|
6
|
+
Octane.JSX.IntrinsicElements['div'],
|
|
7
|
+
'children' | 'id' | 'ref' | 'role' | 'tabIndex' | 'style'
|
|
8
|
+
>;
|
|
9
|
+
export type SeparatorProps = DivProps & {
|
|
10
|
+
children?: OctaneNode;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
disableDoubleClick?: boolean;
|
|
13
|
+
elementRef?: Octane.Ref<HTMLDivElement | null>;
|
|
14
|
+
id?: string | number;
|
|
15
|
+
style?: DivStyle;
|
|
16
|
+
};
|
|
17
|
+
export type RegisteredSeparator = {
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
disableDoubleClick?: boolean;
|
|
20
|
+
element: HTMLDivElement;
|
|
21
|
+
id: string;
|
|
22
|
+
};
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Constants used for memoization
|
|
2
|
+
export const EMPTY_ARRAY: unknown[] = [];
|
|
3
|
+
export const EMPTY_DOM_RECT: DOMRectReadOnly = {
|
|
4
|
+
bottom: 0,
|
|
5
|
+
height: 0,
|
|
6
|
+
left: 0,
|
|
7
|
+
right: 0,
|
|
8
|
+
toJSON: () => {},
|
|
9
|
+
top: 0,
|
|
10
|
+
width: 0,
|
|
11
|
+
x: 0,
|
|
12
|
+
y: 0,
|
|
13
|
+
};
|
|
14
|
+
export const EMPTY_OBJECT = {};
|
|
15
|
+
export const EMPTY_POINT = { x: 0, y: 0 };
|
|
16
|
+
export const IDENTITY_FUNCTION = <Value>(value: Value) => value;
|
|
17
|
+
export const NOOP_FUNCTION = () => {};
|
|
18
|
+
|
|
19
|
+
// Cursor flags
|
|
20
|
+
export const CURSOR_FLAG_HORIZONTAL_MIN = 0b0001;
|
|
21
|
+
export const CURSOR_FLAG_HORIZONTAL_MAX = 0b0010;
|
|
22
|
+
export const CURSOR_FLAG_VERTICAL_MIN = 0b0100;
|
|
23
|
+
export const CURSOR_FLAG_VERTICAL_MAX = 0b1000;
|
|
24
|
+
export const CURSOR_FLAGS_HORIZONTAL = 0b0011;
|
|
25
|
+
export const CURSOR_FLAGS_VERTICAL = 0b1100;
|