main repo

This commit is contained in:
Basilosaurusrex
2025-11-24 18:09:40 +01:00
parent b636ee5e70
commit f027651f9b
34146 changed files with 4436636 additions and 0 deletions

1084
node_modules/react-resizable-panels/src/Panel.test.tsx generated vendored Normal file

File diff suppressed because it is too large Load Diff

259
node_modules/react-resizable-panels/src/Panel.ts generated vendored Normal file
View File

@@ -0,0 +1,259 @@
import { isBrowser } from "#is-browser";
import { isDevelopment } from "#is-development";
import { PanelGroupContext } from "./PanelGroupContext";
import useIsomorphicLayoutEffect from "./hooks/useIsomorphicEffect";
import useUniqueId from "./hooks/useUniqueId";
import {
ForwardedRef,
HTMLAttributes,
PropsWithChildren,
ReactElement,
createElement,
forwardRef,
useContext,
useImperativeHandle,
useRef,
} from "./vendor/react";
export type PanelOnCollapse = () => void;
export type PanelOnExpand = () => void;
export type PanelOnResize = (
size: number,
prevSize: number | undefined
) => void;
export type PanelCallbacks = {
onCollapse?: PanelOnCollapse;
onExpand?: PanelOnExpand;
onResize?: PanelOnResize;
};
export type PanelConstraints = {
collapsedSize?: number | undefined;
collapsible?: boolean | undefined;
defaultSize?: number | undefined;
maxSize?: number | undefined;
minSize?: number | undefined;
};
export type PanelData = {
callbacks: PanelCallbacks;
constraints: PanelConstraints;
id: string;
idIsFromProps: boolean;
order: number | undefined;
};
export type ImperativePanelHandle = {
collapse: () => void;
expand: (minSize?: number) => void;
getId(): string;
getSize(): number;
isCollapsed: () => boolean;
isExpanded: () => boolean;
resize: (size: number) => void;
};
export type PanelProps<
T extends keyof HTMLElementTagNameMap = keyof HTMLElementTagNameMap,
> = Omit<HTMLAttributes<HTMLElementTagNameMap[T]>, "id" | "onResize"> &
PropsWithChildren<{
className?: string;
collapsedSize?: number | undefined;
collapsible?: boolean | undefined;
defaultSize?: number | undefined;
id?: string;
maxSize?: number | undefined;
minSize?: number | undefined;
onCollapse?: PanelOnCollapse;
onExpand?: PanelOnExpand;
onResize?: PanelOnResize;
order?: number;
style?: object;
tagName?: T;
}>;
export function PanelWithForwardedRef({
children,
className: classNameFromProps = "",
collapsedSize,
collapsible,
defaultSize,
forwardedRef,
id: idFromProps,
maxSize,
minSize,
onCollapse,
onExpand,
onResize,
order,
style: styleFromProps,
tagName: Type = "div",
...rest
}: PanelProps & {
forwardedRef: ForwardedRef<ImperativePanelHandle>;
}): ReactElement {
const context = useContext(PanelGroupContext);
if (context === null) {
throw Error(
`Panel components must be rendered within a PanelGroup container`
);
}
const {
collapsePanel,
expandPanel,
getPanelSize,
getPanelStyle,
groupId,
isPanelCollapsed,
reevaluatePanelConstraints,
registerPanel,
resizePanel,
unregisterPanel,
} = context;
const panelId = useUniqueId(idFromProps);
const panelDataRef = useRef<PanelData>({
callbacks: {
onCollapse,
onExpand,
onResize,
},
constraints: {
collapsedSize,
collapsible,
defaultSize,
maxSize,
minSize,
},
id: panelId,
idIsFromProps: idFromProps !== undefined,
order,
});
const devWarningsRef = useRef<{
didLogMissingDefaultSizeWarning: boolean;
}>({
didLogMissingDefaultSizeWarning: false,
});
// Normally we wouldn't log a warning during render,
// but effects don't run on the server, so we can't do it there
if (isDevelopment) {
if (!devWarningsRef.current.didLogMissingDefaultSizeWarning) {
if (!isBrowser && defaultSize == null) {
devWarningsRef.current.didLogMissingDefaultSizeWarning = true;
console.warn(
`WARNING: Panel defaultSize prop recommended to avoid layout shift after server rendering`
);
}
}
}
useIsomorphicLayoutEffect(() => {
const { callbacks, constraints } = panelDataRef.current;
const prevConstraints = { ...constraints };
panelDataRef.current.id = panelId;
panelDataRef.current.idIsFromProps = idFromProps !== undefined;
panelDataRef.current.order = order;
callbacks.onCollapse = onCollapse;
callbacks.onExpand = onExpand;
callbacks.onResize = onResize;
constraints.collapsedSize = collapsedSize;
constraints.collapsible = collapsible;
constraints.defaultSize = defaultSize;
constraints.maxSize = maxSize;
constraints.minSize = minSize;
// If constraints have changed, we should revisit panel sizes.
// This is uncommon but may happen if people are trying to implement pixel based constraints.
if (
prevConstraints.collapsedSize !== constraints.collapsedSize ||
prevConstraints.collapsible !== constraints.collapsible ||
prevConstraints.maxSize !== constraints.maxSize ||
prevConstraints.minSize !== constraints.minSize
) {
reevaluatePanelConstraints(panelDataRef.current, prevConstraints);
}
});
useIsomorphicLayoutEffect(() => {
const panelData = panelDataRef.current;
registerPanel(panelData);
return () => {
unregisterPanel(panelData);
};
}, [order, panelId, registerPanel, unregisterPanel]);
useImperativeHandle(
forwardedRef,
() => ({
collapse: () => {
collapsePanel(panelDataRef.current);
},
expand: (minSize?: number) => {
expandPanel(panelDataRef.current, minSize);
},
getId() {
return panelId;
},
getSize() {
return getPanelSize(panelDataRef.current);
},
isCollapsed() {
return isPanelCollapsed(panelDataRef.current);
},
isExpanded() {
return !isPanelCollapsed(panelDataRef.current);
},
resize: (size: number) => {
resizePanel(panelDataRef.current, size);
},
}),
[
collapsePanel,
expandPanel,
getPanelSize,
isPanelCollapsed,
panelId,
resizePanel,
]
);
const style = getPanelStyle(panelDataRef.current, defaultSize);
return createElement(Type, {
...rest,
children,
className: classNameFromProps,
id: idFromProps,
style: {
...style,
...styleFromProps,
},
// CSS selectors
"data-panel": "",
"data-panel-collapsible": collapsible || undefined,
"data-panel-group-id": groupId,
"data-panel-id": panelId,
"data-panel-size": parseFloat("" + style.flexGrow).toFixed(1),
});
}
export const Panel = forwardRef<ImperativePanelHandle, PanelProps>(
(props: PanelProps, ref: ForwardedRef<ImperativePanelHandle>) =>
createElement(PanelWithForwardedRef, { ...props, forwardedRef: ref })
);
PanelWithForwardedRef.displayName = "Panel";
Panel.displayName = "forwardRef(Panel)";

View File

@@ -0,0 +1,443 @@
// @ts-expect-error This is an experimental API
// eslint-disable-next-line no-restricted-imports
import { unstable_Activity as Activity, Fragment } from "react";
import { Root, createRoot } from "react-dom/client";
import { act } from "react-dom/test-utils";
import {
ImperativePanelGroupHandle,
ImperativePanelHandle,
Panel,
PanelGroup,
PanelResizeHandle,
getPanelElement,
} from ".";
import { assert } from "./utils/assert";
import { getPanelGroupElement } from "./utils/dom/getPanelGroupElement";
import {
mockPanelGroupOffsetWidthAndHeight,
verifyAttribute,
} from "./utils/test-utils";
import { createRef } from "./vendor/react";
describe("PanelGroup", () => {
let expectedWarnings: string[] = [];
let root: Root;
let container: HTMLElement;
let uninstallMockOffsetWidthAndHeight: () => void;
function expectWarning(expectedMessage: string) {
expectedWarnings.push(expectedMessage);
}
beforeEach(() => {
// @ts-expect-error
global.IS_REACT_ACT_ENVIRONMENT = true;
// JSDom doesn't support element sizes
uninstallMockOffsetWidthAndHeight = mockPanelGroupOffsetWidthAndHeight();
container = document.createElement("div");
document.body.appendChild(container);
expectedWarnings = [];
root = createRoot(container);
jest.spyOn(console, "warn").mockImplementation((actualMessage: string) => {
const match = expectedWarnings.findIndex((expectedMessage) => {
return actualMessage.includes(expectedMessage);
});
if (match >= 0) {
expectedWarnings.splice(match, 1);
return;
}
throw Error(`Unexpected warning: ${actualMessage}`);
});
});
afterEach(() => {
uninstallMockOffsetWidthAndHeight();
jest.clearAllMocks();
jest.resetModules();
act(() => {
root.unmount();
});
expect(expectedWarnings).toHaveLength(0);
});
it("should recalculate layout after being hidden by Activity", () => {
const panelRef = createRef<ImperativePanelHandle>();
let mostRecentLayout: number[] | null = null;
const onLayout = (layout: number[]) => {
mostRecentLayout = layout;
};
act(() => {
root.render(
<Activity mode="visible">
<PanelGroup direction="horizontal" onLayout={onLayout}>
<Panel id="left" ref={panelRef} />
<PanelResizeHandle />
<Panel defaultSize={40} id="right" />
</PanelGroup>
</Activity>
);
});
expect(mostRecentLayout).toEqual([60, 40]);
expect(panelRef.current?.getSize()).toEqual(60);
const leftPanelElement = getPanelElement("left");
const rightPanelElement = getPanelElement("right");
expect(leftPanelElement?.getAttribute("data-panel-size")).toBe("60.0");
expect(rightPanelElement?.getAttribute("data-panel-size")).toBe("40.0");
act(() => {
root.render(
<Activity mode="hidden">
<PanelGroup direction="horizontal" onLayout={onLayout}>
<Panel id="left" ref={panelRef} />
<PanelResizeHandle />
<Panel defaultSize={40} id="right" />
</PanelGroup>
</Activity>
);
});
act(() => {
root.render(
<Activity mode="visible">
<PanelGroup direction="horizontal" onLayout={onLayout}>
<Panel id="left" ref={panelRef} />
<PanelResizeHandle />
<Panel defaultSize={40} id="right" />
</PanelGroup>
</Activity>
);
});
expect(mostRecentLayout).toEqual([60, 40]);
expect(panelRef.current?.getSize()).toEqual(60);
// This bug is only observable in the DOM; callbacks will not re-fire
expect(leftPanelElement?.getAttribute("data-panel-size")).toBe("60.0");
expect(rightPanelElement?.getAttribute("data-panel-size")).toBe("40.0");
});
// github.com/bvaughn/react-resizable-panels/issues/303
it("should recalculate layout after panels are changed", () => {
let mostRecentLayout: number[] | null = null;
const onLayout = (layout: number[]) => {
mostRecentLayout = layout;
};
act(() => {
root.render(
<PanelGroup direction="vertical" onLayout={onLayout}>
<Panel id="foo" minSize={30} order={0} />
<PanelResizeHandle />
<Panel id="bar" minSize={70} order={1} />
</PanelGroup>
);
});
expect(mostRecentLayout).toEqual([30, 70]);
act(() => {
root.render(
<PanelGroup direction="vertical" onLayout={onLayout}>
<Panel id="bar" minSize={70} order={0} />
</PanelGroup>
);
});
expect(mostRecentLayout).toEqual([100]);
});
describe("imperative handle API", () => {
it("should report the most recently rendered group id", () => {
const ref = createRef<ImperativePanelGroupHandle>();
act(() => {
root.render(<PanelGroup direction="horizontal" id="one" ref={ref} />);
});
expect(ref.current?.getId()).toBe("one");
act(() => {
root.render(<PanelGroup direction="horizontal" id="two" ref={ref} />);
});
expect(ref.current?.getId()).toBe("two");
});
it("should get and set layouts", () => {
const ref = createRef<ImperativePanelGroupHandle>();
let mostRecentLayout: number[] | null = null;
const onLayout = (layout: number[]) => {
mostRecentLayout = layout;
};
act(() => {
root.render(
<PanelGroup direction="horizontal" onLayout={onLayout} ref={ref}>
<Panel defaultSize={50} id="a" />
<PanelResizeHandle />
<Panel defaultSize={50} id="b" />
</PanelGroup>
);
});
expect(mostRecentLayout).toEqual([50, 50]);
act(() => {
ref.current?.setLayout([25, 75]);
});
expect(mostRecentLayout).toEqual([25, 75]);
});
});
it("should support ...rest attributes", () => {
act(() => {
root.render(
<PanelGroup
data-test-name="foo"
direction="horizontal"
id="group"
tabIndex={123}
title="bar"
>
<Panel />
<PanelResizeHandle />
<Panel />
</PanelGroup>
);
});
const element = getPanelGroupElement("group", container);
assert(element, "");
expect(element.tabIndex).toBe(123);
expect(element.getAttribute("data-test-name")).toBe("foo");
expect(element.title).toBe("bar");
});
describe("callbacks", () => {
describe("onLayout", () => {
it("should be called with the initial group layout on mount", () => {
let onLayout = jest.fn();
act(() => {
root.render(
<PanelGroup direction="horizontal" onLayout={onLayout}>
<Panel defaultSize={35} />
<PanelResizeHandle />
<Panel defaultSize={65} />
</PanelGroup>
);
});
expect(onLayout).toHaveBeenCalledTimes(1);
expect(onLayout).toHaveBeenCalledWith([35, 65]);
});
it("should be called any time the group layout changes", () => {
let onLayout = jest.fn();
let panelGroupRef = createRef<ImperativePanelGroupHandle>();
let panelRef = createRef<ImperativePanelHandle>();
act(() => {
root.render(
<PanelGroup
direction="horizontal"
onLayout={onLayout}
ref={panelGroupRef}
>
<Panel defaultSize={35} ref={panelRef} />
<PanelResizeHandle />
<Panel defaultSize={65} />
</PanelGroup>
);
});
onLayout.mockReset();
act(() => {
panelGroupRef.current?.setLayout([25, 75]);
});
expect(onLayout).toHaveBeenCalledTimes(1);
expect(onLayout).toHaveBeenCalledWith([25, 75]);
onLayout.mockReset();
act(() => {
panelRef.current?.resize(50);
});
expect(onLayout).toHaveBeenCalledTimes(1);
expect(onLayout).toHaveBeenCalledWith([50, 50]);
});
});
});
describe("data attributes", () => {
it("should initialize with the correct props based attributes", () => {
act(() => {
root.render(
<PanelGroup direction="horizontal" id="test-group"></PanelGroup>
);
});
const element = getPanelGroupElement("test-group", container);
assert(element, "");
verifyAttribute(element, "data-panel-group", "");
verifyAttribute(element, "data-panel-group-direction", "horizontal");
verifyAttribute(element, "data-panel-group-id", "test-group");
});
});
describe("a11y", () => {
it("should pass explicit id prop to DOM", () => {
act(() => {
root.render(
<PanelGroup direction="horizontal" id="explicit-id">
<Panel />
<PanelResizeHandle />
<Panel />
</PanelGroup>
);
});
const element = container.querySelector("[data-panel-group]");
expect(element).not.toBeNull();
expect(element?.getAttribute("id")).toBe("explicit-id");
});
it("should not pass auto-generated id prop to DOM", () => {
act(() => {
root.render(
<PanelGroup direction="horizontal">
<Panel />
<PanelResizeHandle />
<Panel />
</PanelGroup>
);
});
const element = container.querySelector("[data-panel-group]");
expect(element).not.toBeNull();
expect(element?.getAttribute("id")).toBeNull();
});
});
describe("DEV warnings", () => {
it("should warn about unstable layouts without id and order props", () => {
act(() => {
root.render(
<PanelGroup direction="horizontal">
<Panel defaultSize={100} id="a" />
</PanelGroup>
);
});
expectWarning(
"Panel id and order props recommended when panels are dynamically rendered"
);
act(() => {
root.render(
<PanelGroup direction="horizontal">
<Panel defaultSize={50} id="a" />
<PanelResizeHandle />
<Panel defaultSize={50} id="b" />
</PanelGroup>
);
});
});
it("should warn about missing resize handles", () => {
expectWarning(
'Missing resize handle for PanelGroup "group-without-handle"'
);
act(() => {
root.render(
<PanelGroup direction="horizontal" id="group-without-handle">
<Panel />
<Panel />
</PanelGroup>
);
});
});
it("should warn about an invalid declarative layout", () => {
expectWarning("Invalid layout total size: 60%, 80%");
act(() => {
root.render(
<PanelGroup direction="horizontal" id="group-without-handle">
<Panel defaultSize={60} />
<PanelResizeHandle />
<Panel defaultSize={80} />
</PanelGroup>
);
});
});
it("should warn about an invalid layout set via the imperative api", () => {
const ref = createRef<ImperativePanelGroupHandle>();
act(() => {
root.render(
<PanelGroup
direction="horizontal"
id="group-without-handle"
ref={ref}
>
<Panel defaultSize={30} />
<PanelResizeHandle />
<Panel defaultSize={70} />
</PanelGroup>
);
});
expectWarning("Invalid layout total size: 60%, 80%");
act(() => {
ref.current?.setLayout([60, 80]);
});
});
it("should warn about an empty layout", () => {
act(() => {
root.render(
<PanelGroup direction="horizontal" id="group-without-handle">
<Panel />
</PanelGroup>
);
});
// Since the layout is empty, no warning is expected (even though the sizes won't total 100%)
act(() => {
root.render(
<PanelGroup
direction="horizontal"
id="group-without-handle"
></PanelGroup>
);
});
});
});
});

985
node_modules/react-resizable-panels/src/PanelGroup.ts generated vendored Normal file
View File

@@ -0,0 +1,985 @@
import { isDevelopment } from "#is-development";
import { PanelConstraints, PanelData } from "./Panel";
import {
DragState,
PanelGroupContext,
ResizeEvent,
TPanelGroupContext,
} from "./PanelGroupContext";
import {
EXCEEDED_HORIZONTAL_MAX,
EXCEEDED_HORIZONTAL_MIN,
EXCEEDED_VERTICAL_MAX,
EXCEEDED_VERTICAL_MIN,
reportConstraintsViolation,
} from "./PanelResizeHandleRegistry";
import { useForceUpdate } from "./hooks/useForceUpdate";
import useIsomorphicLayoutEffect from "./hooks/useIsomorphicEffect";
import useUniqueId from "./hooks/useUniqueId";
import { useWindowSplitterPanelGroupBehavior } from "./hooks/useWindowSplitterPanelGroupBehavior";
import { Direction } from "./types";
import { adjustLayoutByDelta } from "./utils/adjustLayoutByDelta";
import { areEqual } from "./utils/arrays";
import { assert } from "./utils/assert";
import { calculateDeltaPercentage } from "./utils/calculateDeltaPercentage";
import { calculateUnsafeDefaultLayout } from "./utils/calculateUnsafeDefaultLayout";
import { callPanelCallbacks } from "./utils/callPanelCallbacks";
import { compareLayouts } from "./utils/compareLayouts";
import { computePanelFlexBoxStyle } from "./utils/computePanelFlexBoxStyle";
import debounce from "./utils/debounce";
import { determinePivotIndices } from "./utils/determinePivotIndices";
import { getResizeHandleElement } from "./utils/dom/getResizeHandleElement";
import { isKeyDown, isMouseEvent, isPointerEvent } from "./utils/events";
import { getResizeEventCursorPosition } from "./utils/events/getResizeEventCursorPosition";
import { initializeDefaultStorage } from "./utils/initializeDefaultStorage";
import {
fuzzyCompareNumbers,
fuzzyNumbersEqual,
} from "./utils/numbers/fuzzyCompareNumbers";
import {
loadPanelGroupState,
savePanelGroupState,
} from "./utils/serialization";
import { validatePanelConstraints } from "./utils/validatePanelConstraints";
import { validatePanelGroupLayout } from "./utils/validatePanelGroupLayout";
import {
CSSProperties,
ForwardedRef,
HTMLAttributes,
PropsWithChildren,
ReactElement,
createElement,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "./vendor/react";
const LOCAL_STORAGE_DEBOUNCE_INTERVAL = 100;
export type ImperativePanelGroupHandle = {
getId: () => string;
getLayout: () => number[];
setLayout: (layout: number[]) => void;
};
export type PanelGroupStorage = {
getItem(name: string): string | null;
setItem(name: string, value: string): void;
};
export type PanelGroupOnLayout = (layout: number[]) => void;
const defaultStorage: PanelGroupStorage = {
getItem: (name: string) => {
initializeDefaultStorage(defaultStorage);
return defaultStorage.getItem(name);
},
setItem: (name: string, value: string) => {
initializeDefaultStorage(defaultStorage);
defaultStorage.setItem(name, value);
},
};
export type PanelGroupProps = Omit<
HTMLAttributes<keyof HTMLElementTagNameMap>,
"id"
> &
PropsWithChildren<{
autoSaveId?: string | null;
className?: string;
direction: Direction;
id?: string | null;
keyboardResizeBy?: number | null;
onLayout?: PanelGroupOnLayout | null;
storage?: PanelGroupStorage;
style?: CSSProperties;
tagName?: keyof HTMLElementTagNameMap;
}>;
const debounceMap: {
[key: string]: typeof savePanelGroupState;
} = {};
function PanelGroupWithForwardedRef({
autoSaveId = null,
children,
className: classNameFromProps = "",
direction,
forwardedRef,
id: idFromProps = null,
onLayout = null,
keyboardResizeBy = null,
storage = defaultStorage,
style: styleFromProps,
tagName: Type = "div",
...rest
}: PanelGroupProps & {
forwardedRef: ForwardedRef<ImperativePanelGroupHandle>;
}): ReactElement {
const groupId = useUniqueId(idFromProps);
const panelGroupElementRef = useRef<HTMLDivElement | null>(null);
const [dragState, setDragState] = useState<DragState | null>(null);
const [layout, setLayout] = useState<number[]>([]);
const forceUpdate = useForceUpdate();
const panelIdToLastNotifiedSizeMapRef = useRef<Record<string, number>>({});
const panelSizeBeforeCollapseRef = useRef<Map<string, number>>(new Map());
const prevDeltaRef = useRef<number>(0);
const committedValuesRef = useRef<{
autoSaveId: string | null;
direction: Direction;
dragState: DragState | null;
id: string;
keyboardResizeBy: number | null;
onLayout: PanelGroupOnLayout | null;
storage: PanelGroupStorage;
}>({
autoSaveId,
direction,
dragState,
id: groupId,
keyboardResizeBy,
onLayout,
storage,
});
const eagerValuesRef = useRef<{
layout: number[];
panelDataArray: PanelData[];
panelDataArrayChanged: boolean;
}>({
layout,
panelDataArray: [],
panelDataArrayChanged: false,
});
const devWarningsRef = useRef<{
didLogIdAndOrderWarning: boolean;
didLogPanelConstraintsWarning: boolean;
prevPanelIds: string[];
}>({
didLogIdAndOrderWarning: false,
didLogPanelConstraintsWarning: false,
prevPanelIds: [],
});
useImperativeHandle(
forwardedRef,
() => ({
getId: () => committedValuesRef.current.id,
getLayout: () => {
const { layout } = eagerValuesRef.current;
return layout;
},
setLayout: (unsafeLayout: number[]) => {
const { onLayout } = committedValuesRef.current;
const { layout: prevLayout, panelDataArray } = eagerValuesRef.current;
const safeLayout = validatePanelGroupLayout({
layout: unsafeLayout,
panelConstraints: panelDataArray.map(
(panelData) => panelData.constraints
),
});
if (!areEqual(prevLayout, safeLayout)) {
setLayout(safeLayout);
eagerValuesRef.current.layout = safeLayout;
if (onLayout) {
onLayout(safeLayout);
}
callPanelCallbacks(
panelDataArray,
safeLayout,
panelIdToLastNotifiedSizeMapRef.current
);
}
},
}),
[]
);
useIsomorphicLayoutEffect(() => {
committedValuesRef.current.autoSaveId = autoSaveId;
committedValuesRef.current.direction = direction;
committedValuesRef.current.dragState = dragState;
committedValuesRef.current.id = groupId;
committedValuesRef.current.onLayout = onLayout;
committedValuesRef.current.storage = storage;
});
useWindowSplitterPanelGroupBehavior({
committedValuesRef,
eagerValuesRef,
groupId,
layout,
panelDataArray: eagerValuesRef.current.panelDataArray,
setLayout,
panelGroupElement: panelGroupElementRef.current,
});
useEffect(() => {
const { panelDataArray } = eagerValuesRef.current;
// If this panel has been configured to persist sizing information, save sizes to local storage.
if (autoSaveId) {
if (layout.length === 0 || layout.length !== panelDataArray.length) {
return;
}
let debouncedSave = debounceMap[autoSaveId];
// Limit the frequency of localStorage updates.
if (debouncedSave == null) {
debouncedSave = debounce(
savePanelGroupState,
LOCAL_STORAGE_DEBOUNCE_INTERVAL
);
debounceMap[autoSaveId] = debouncedSave;
}
// Clone mutable data before passing to the debounced function,
// else we run the risk of saving an incorrect combination of mutable and immutable values to state.
const clonedPanelDataArray = [...panelDataArray];
const clonedPanelSizesBeforeCollapse = new Map(
panelSizeBeforeCollapseRef.current
);
debouncedSave(
autoSaveId,
clonedPanelDataArray,
clonedPanelSizesBeforeCollapse,
layout,
storage
);
}
}, [autoSaveId, layout, storage]);
// DEV warnings
useEffect(() => {
if (isDevelopment) {
const { panelDataArray } = eagerValuesRef.current;
const {
didLogIdAndOrderWarning,
didLogPanelConstraintsWarning,
prevPanelIds,
} = devWarningsRef.current;
if (!didLogIdAndOrderWarning) {
const panelIds = panelDataArray.map(({ id }) => id);
devWarningsRef.current.prevPanelIds = panelIds;
const panelsHaveChanged =
prevPanelIds.length > 0 && !areEqual(prevPanelIds, panelIds);
if (panelsHaveChanged) {
if (
panelDataArray.find(
({ idIsFromProps, order }) => !idIsFromProps || order == null
)
) {
devWarningsRef.current.didLogIdAndOrderWarning = true;
console.warn(
`WARNING: Panel id and order props recommended when panels are dynamically rendered`
);
}
}
}
if (!didLogPanelConstraintsWarning) {
const panelConstraints = panelDataArray.map(
(panelData) => panelData.constraints
);
for (
let panelIndex = 0;
panelIndex < panelConstraints.length;
panelIndex++
) {
const panelData = panelDataArray[panelIndex];
assert(panelData, `Panel data not found for index ${panelIndex}`);
const isValid = validatePanelConstraints({
panelConstraints,
panelId: panelData.id,
panelIndex,
});
if (!isValid) {
devWarningsRef.current.didLogPanelConstraintsWarning = true;
break;
}
}
}
}
});
// External APIs are safe to memoize via committed values ref
const collapsePanel = useCallback((panelData: PanelData) => {
const { onLayout } = committedValuesRef.current;
const { layout: prevLayout, panelDataArray } = eagerValuesRef.current;
if (panelData.constraints.collapsible) {
const panelConstraintsArray = panelDataArray.map(
(panelData) => panelData.constraints
);
const {
collapsedSize = 0,
panelSize,
pivotIndices,
} = panelDataHelper(panelDataArray, panelData, prevLayout);
assert(
panelSize != null,
`Panel size not found for panel "${panelData.id}"`
);
if (!fuzzyNumbersEqual(panelSize, collapsedSize)) {
// Store size before collapse;
// This is the size that gets restored if the expand() API is used.
panelSizeBeforeCollapseRef.current.set(panelData.id, panelSize);
const isLastPanel =
findPanelDataIndex(panelDataArray, panelData) ===
panelDataArray.length - 1;
const delta = isLastPanel
? panelSize - collapsedSize
: collapsedSize - panelSize;
const nextLayout = adjustLayoutByDelta({
delta,
initialLayout: prevLayout,
panelConstraints: panelConstraintsArray,
pivotIndices,
prevLayout,
trigger: "imperative-api",
});
if (!compareLayouts(prevLayout, nextLayout)) {
setLayout(nextLayout);
eagerValuesRef.current.layout = nextLayout;
if (onLayout) {
onLayout(nextLayout);
}
callPanelCallbacks(
panelDataArray,
nextLayout,
panelIdToLastNotifiedSizeMapRef.current
);
}
}
}
}, []);
// External APIs are safe to memoize via committed values ref
const expandPanel = useCallback(
(panelData: PanelData, minSizeOverride?: number) => {
const { onLayout } = committedValuesRef.current;
const { layout: prevLayout, panelDataArray } = eagerValuesRef.current;
if (panelData.constraints.collapsible) {
const panelConstraintsArray = panelDataArray.map(
(panelData) => panelData.constraints
);
const {
collapsedSize = 0,
panelSize = 0,
minSize: minSizeFromProps = 0,
pivotIndices,
} = panelDataHelper(panelDataArray, panelData, prevLayout);
const minSize = minSizeOverride ?? minSizeFromProps;
if (fuzzyNumbersEqual(panelSize, collapsedSize)) {
// Restore this panel to the size it was before it was collapsed, if possible.
const prevPanelSize = panelSizeBeforeCollapseRef.current.get(
panelData.id
);
const baseSize =
prevPanelSize != null && prevPanelSize >= minSize
? prevPanelSize
: minSize;
const isLastPanel =
findPanelDataIndex(panelDataArray, panelData) ===
panelDataArray.length - 1;
const delta = isLastPanel
? panelSize - baseSize
: baseSize - panelSize;
const nextLayout = adjustLayoutByDelta({
delta,
initialLayout: prevLayout,
panelConstraints: panelConstraintsArray,
pivotIndices,
prevLayout,
trigger: "imperative-api",
});
if (!compareLayouts(prevLayout, nextLayout)) {
setLayout(nextLayout);
eagerValuesRef.current.layout = nextLayout;
if (onLayout) {
onLayout(nextLayout);
}
callPanelCallbacks(
panelDataArray,
nextLayout,
panelIdToLastNotifiedSizeMapRef.current
);
}
}
}
},
[]
);
// External APIs are safe to memoize via committed values ref
const getPanelSize = useCallback((panelData: PanelData) => {
const { layout, panelDataArray } = eagerValuesRef.current;
const { panelSize } = panelDataHelper(panelDataArray, panelData, layout);
assert(
panelSize != null,
`Panel size not found for panel "${panelData.id}"`
);
return panelSize;
}, []);
// This API should never read from committedValuesRef
const getPanelStyle = useCallback(
(panelData: PanelData, defaultSize: number | undefined) => {
const { panelDataArray } = eagerValuesRef.current;
const panelIndex = findPanelDataIndex(panelDataArray, panelData);
return computePanelFlexBoxStyle({
defaultSize,
dragState,
layout,
panelData: panelDataArray,
panelIndex,
});
},
[dragState, layout]
);
// External APIs are safe to memoize via committed values ref
const isPanelCollapsed = useCallback((panelData: PanelData) => {
const { layout, panelDataArray } = eagerValuesRef.current;
const {
collapsedSize = 0,
collapsible,
panelSize,
} = panelDataHelper(panelDataArray, panelData, layout);
assert(
panelSize != null,
`Panel size not found for panel "${panelData.id}"`
);
return collapsible === true && fuzzyNumbersEqual(panelSize, collapsedSize);
}, []);
// External APIs are safe to memoize via committed values ref
const isPanelExpanded = useCallback((panelData: PanelData) => {
const { layout, panelDataArray } = eagerValuesRef.current;
const {
collapsedSize = 0,
collapsible,
panelSize,
} = panelDataHelper(panelDataArray, panelData, layout);
assert(
panelSize != null,
`Panel size not found for panel "${panelData.id}"`
);
return !collapsible || fuzzyCompareNumbers(panelSize, collapsedSize) > 0;
}, []);
const registerPanel = useCallback(
(panelData: PanelData) => {
const { panelDataArray } = eagerValuesRef.current;
panelDataArray.push(panelData);
panelDataArray.sort((panelA, panelB) => {
const orderA = panelA.order;
const orderB = panelB.order;
if (orderA == null && orderB == null) {
return 0;
} else if (orderA == null) {
return -1;
} else if (orderB == null) {
return 1;
} else {
return orderA - orderB;
}
});
eagerValuesRef.current.panelDataArrayChanged = true;
forceUpdate();
},
[forceUpdate]
);
// (Re)calculate group layout whenever panels are registered or unregistered.
// eslint-disable-next-line react-hooks/exhaustive-deps
useIsomorphicLayoutEffect(() => {
if (eagerValuesRef.current.panelDataArrayChanged) {
eagerValuesRef.current.panelDataArrayChanged = false;
const { autoSaveId, onLayout, storage } = committedValuesRef.current;
const { layout: prevLayout, panelDataArray } = eagerValuesRef.current;
// If this panel has been configured to persist sizing information,
// default size should be restored from local storage if possible.
let unsafeLayout: number[] | null = null;
if (autoSaveId) {
const state = loadPanelGroupState(autoSaveId, panelDataArray, storage);
if (state) {
panelSizeBeforeCollapseRef.current = new Map(
Object.entries(state.expandToSizes)
);
unsafeLayout = state.layout;
}
}
if (unsafeLayout == null) {
unsafeLayout = calculateUnsafeDefaultLayout({
panelDataArray,
});
}
// Validate even saved layouts in case something has changed since last render
// e.g. for pixel groups, this could be the size of the window
const nextLayout = validatePanelGroupLayout({
layout: unsafeLayout,
panelConstraints: panelDataArray.map(
(panelData) => panelData.constraints
),
});
if (!areEqual(prevLayout, nextLayout)) {
setLayout(nextLayout);
eagerValuesRef.current.layout = nextLayout;
if (onLayout) {
onLayout(nextLayout);
}
callPanelCallbacks(
panelDataArray,
nextLayout,
panelIdToLastNotifiedSizeMapRef.current
);
}
}
});
// Reset the cached layout if hidden by the Activity/Offscreen API
useIsomorphicLayoutEffect(() => {
const eagerValues = eagerValuesRef.current;
return () => {
eagerValues.layout = [];
};
}, []);
const registerResizeHandle = useCallback((dragHandleId: string) => {
return function resizeHandler(event: ResizeEvent) {
event.preventDefault();
const panelGroupElement = panelGroupElementRef.current;
if (!panelGroupElement) {
return () => null;
}
const {
direction,
dragState,
id: groupId,
keyboardResizeBy,
onLayout,
} = committedValuesRef.current;
const { layout: prevLayout, panelDataArray } = eagerValuesRef.current;
const { initialLayout } = dragState ?? {};
const pivotIndices = determinePivotIndices(
groupId,
dragHandleId,
panelGroupElement
);
let delta = calculateDeltaPercentage(
event,
dragHandleId,
direction,
dragState,
keyboardResizeBy,
panelGroupElement
);
// Support RTL layouts
const isHorizontal = direction === "horizontal";
if (document.dir === "rtl" && isHorizontal) {
delta = -delta;
}
const panelConstraints = panelDataArray.map(
(panelData) => panelData.constraints
);
const nextLayout = adjustLayoutByDelta({
delta,
initialLayout: initialLayout ?? prevLayout,
panelConstraints,
pivotIndices,
prevLayout,
trigger: isKeyDown(event) ? "keyboard" : "mouse-or-touch",
});
const layoutChanged = !compareLayouts(prevLayout, nextLayout);
// Only update the cursor for layout changes triggered by touch/mouse events (not keyboard)
// Update the cursor even if the layout hasn't changed (we may need to show an invalid cursor state)
if (isPointerEvent(event) || isMouseEvent(event)) {
// Watch for multiple subsequent deltas; this might occur for tiny cursor movements.
// In this case, Panel sizes might not change
// but updating cursor in this scenario would cause a flicker.
if (prevDeltaRef.current != delta) {
prevDeltaRef.current = delta;
if (!layoutChanged && delta !== 0) {
// If the pointer has moved too far to resize the panel any further, note this so we can update the cursor.
// This mimics VS Code behavior.
if (isHorizontal) {
reportConstraintsViolation(
dragHandleId,
delta < 0 ? EXCEEDED_HORIZONTAL_MIN : EXCEEDED_HORIZONTAL_MAX
);
} else {
reportConstraintsViolation(
dragHandleId,
delta < 0 ? EXCEEDED_VERTICAL_MIN : EXCEEDED_VERTICAL_MAX
);
}
} else {
reportConstraintsViolation(dragHandleId, 0);
}
}
}
if (layoutChanged) {
setLayout(nextLayout);
eagerValuesRef.current.layout = nextLayout;
if (onLayout) {
onLayout(nextLayout);
}
callPanelCallbacks(
panelDataArray,
nextLayout,
panelIdToLastNotifiedSizeMapRef.current
);
}
};
}, []);
// External APIs are safe to memoize via committed values ref
const resizePanel = useCallback(
(panelData: PanelData, unsafePanelSize: number) => {
const { onLayout } = committedValuesRef.current;
const { layout: prevLayout, panelDataArray } = eagerValuesRef.current;
const panelConstraintsArray = panelDataArray.map(
(panelData) => panelData.constraints
);
const { panelSize, pivotIndices } = panelDataHelper(
panelDataArray,
panelData,
prevLayout
);
assert(
panelSize != null,
`Panel size not found for panel "${panelData.id}"`
);
const isLastPanel =
findPanelDataIndex(panelDataArray, panelData) ===
panelDataArray.length - 1;
const delta = isLastPanel
? panelSize - unsafePanelSize
: unsafePanelSize - panelSize;
const nextLayout = adjustLayoutByDelta({
delta,
initialLayout: prevLayout,
panelConstraints: panelConstraintsArray,
pivotIndices,
prevLayout,
trigger: "imperative-api",
});
if (!compareLayouts(prevLayout, nextLayout)) {
setLayout(nextLayout);
eagerValuesRef.current.layout = nextLayout;
if (onLayout) {
onLayout(nextLayout);
}
callPanelCallbacks(
panelDataArray,
nextLayout,
panelIdToLastNotifiedSizeMapRef.current
);
}
},
[]
);
const reevaluatePanelConstraints = useCallback(
(panelData: PanelData, prevConstraints: PanelConstraints) => {
const { layout, panelDataArray } = eagerValuesRef.current;
const {
collapsedSize: prevCollapsedSize = 0,
collapsible: prevCollapsible,
} = prevConstraints;
const {
collapsedSize: nextCollapsedSize = 0,
collapsible: nextCollapsible,
maxSize: nextMaxSize = 100,
minSize: nextMinSize = 0,
} = panelData.constraints;
const { panelSize: prevPanelSize } = panelDataHelper(
panelDataArray,
panelData,
layout
);
if (prevPanelSize == null) {
// It's possible that the panels in this group have changed since the last render
return;
}
if (
prevCollapsible &&
nextCollapsible &&
fuzzyNumbersEqual(prevPanelSize, prevCollapsedSize)
) {
if (!fuzzyNumbersEqual(prevCollapsedSize, nextCollapsedSize)) {
resizePanel(panelData, nextCollapsedSize);
} else {
// Stay collapsed
}
} else if (prevPanelSize < nextMinSize) {
resizePanel(panelData, nextMinSize);
} else if (prevPanelSize > nextMaxSize) {
resizePanel(panelData, nextMaxSize);
}
},
[resizePanel]
);
// TODO Multiple drag handles can be active at the same time so this API is a bit awkward now
const startDragging = useCallback(
(dragHandleId: string, event: ResizeEvent) => {
const { direction } = committedValuesRef.current;
const { layout } = eagerValuesRef.current;
if (!panelGroupElementRef.current) {
return;
}
const handleElement = getResizeHandleElement(
dragHandleId,
panelGroupElementRef.current
);
assert(
handleElement,
`Drag handle element not found for id "${dragHandleId}"`
);
const initialCursorPosition = getResizeEventCursorPosition(
direction,
event
);
setDragState({
dragHandleId,
dragHandleRect: handleElement.getBoundingClientRect(),
initialCursorPosition,
initialLayout: layout,
});
},
[]
);
const stopDragging = useCallback(() => {
setDragState(null);
}, []);
const unregisterPanel = useCallback(
(panelData: PanelData) => {
const { panelDataArray } = eagerValuesRef.current;
const index = findPanelDataIndex(panelDataArray, panelData);
if (index >= 0) {
panelDataArray.splice(index, 1);
// TRICKY
// When a panel is removed from the group, we should delete the most recent prev-size entry for it.
// If we don't do this, then a conditionally rendered panel might not call onResize when it's re-mounted.
// Strict effects mode makes this tricky though because all panels will be registered, unregistered, then re-registered on mount.
delete panelIdToLastNotifiedSizeMapRef.current[panelData.id];
eagerValuesRef.current.panelDataArrayChanged = true;
forceUpdate();
}
},
[forceUpdate]
);
const context = useMemo(
() =>
({
collapsePanel,
direction,
dragState,
expandPanel,
getPanelSize,
getPanelStyle,
groupId,
isPanelCollapsed,
isPanelExpanded,
reevaluatePanelConstraints,
registerPanel,
registerResizeHandle,
resizePanel,
startDragging,
stopDragging,
unregisterPanel,
panelGroupElement: panelGroupElementRef.current,
}) satisfies TPanelGroupContext,
[
collapsePanel,
dragState,
direction,
expandPanel,
getPanelSize,
getPanelStyle,
groupId,
isPanelCollapsed,
isPanelExpanded,
reevaluatePanelConstraints,
registerPanel,
registerResizeHandle,
resizePanel,
startDragging,
stopDragging,
unregisterPanel,
]
);
const style: CSSProperties = {
display: "flex",
flexDirection: direction === "horizontal" ? "row" : "column",
height: "100%",
overflow: "hidden",
width: "100%",
};
return createElement(
PanelGroupContext.Provider,
{ value: context },
createElement(Type, {
...rest,
children,
className: classNameFromProps,
id: idFromProps,
ref: panelGroupElementRef,
style: {
...style,
...styleFromProps,
},
// CSS selectors
"data-panel-group": "",
"data-panel-group-direction": direction,
"data-panel-group-id": groupId,
})
);
}
export const PanelGroup = forwardRef<
ImperativePanelGroupHandle,
PanelGroupProps
>((props: PanelGroupProps, ref: ForwardedRef<ImperativePanelGroupHandle>) =>
createElement(PanelGroupWithForwardedRef, { ...props, forwardedRef: ref })
);
PanelGroupWithForwardedRef.displayName = "PanelGroup";
PanelGroup.displayName = "forwardRef(PanelGroup)";
function findPanelDataIndex(panelDataArray: PanelData[], panelData: PanelData) {
return panelDataArray.findIndex(
(prevPanelData) =>
prevPanelData === panelData || prevPanelData.id === panelData.id
);
}
function panelDataHelper(
panelDataArray: PanelData[],
panelData: PanelData,
layout: number[]
) {
const panelIndex = findPanelDataIndex(panelDataArray, panelData);
const isLastPanel = panelIndex === panelDataArray.length - 1;
const pivotIndices = isLastPanel
? [panelIndex - 1, panelIndex]
: [panelIndex, panelIndex + 1];
const panelSize = layout[panelIndex];
return {
...panelData.constraints,
panelSize,
pivotIndices,
};
}

View File

@@ -0,0 +1,42 @@
import { PanelConstraints, PanelData } from "./Panel";
import { CSSProperties, createContext } from "./vendor/react";
// The "contextmenu" event is not supported as a PointerEvent in all browsers yet, so MouseEvent still need to be handled
export type ResizeEvent = KeyboardEvent | PointerEvent | MouseEvent;
export type ResizeHandler = (event: ResizeEvent) => void;
export type DragState = {
dragHandleId: string;
dragHandleRect: DOMRect;
initialCursorPosition: number;
initialLayout: number[];
};
export type TPanelGroupContext = {
collapsePanel: (panelData: PanelData) => void;
direction: "horizontal" | "vertical";
dragState: DragState | null;
expandPanel: (panelData: PanelData, minSizeOverride?: number) => void;
getPanelSize: (panelData: PanelData) => number;
getPanelStyle: (
panelData: PanelData,
defaultSize: number | undefined
) => CSSProperties;
groupId: string;
isPanelCollapsed: (panelData: PanelData) => boolean;
isPanelExpanded: (panelData: PanelData) => boolean;
reevaluatePanelConstraints: (
panelData: PanelData,
prevConstraints: PanelConstraints
) => void;
registerPanel: (panelData: PanelData) => void;
registerResizeHandle: (dragHandleId: string) => ResizeHandler;
resizePanel: (panelData: PanelData, size: number) => void;
startDragging: (dragHandleId: string, event: ResizeEvent) => void;
stopDragging: () => void;
unregisterPanel: (panelData: PanelData) => void;
panelGroupElement: ParentNode | null;
};
export const PanelGroupContext = createContext<TPanelGroupContext | null>(null);
PanelGroupContext.displayName = "PanelGroupContext";

View File

@@ -0,0 +1,367 @@
import { Root, createRoot } from "react-dom/client";
import { act } from "react-dom/test-utils";
import {
Panel,
PanelGroup,
PanelResizeHandle,
type PanelResizeHandleProps,
} from ".";
import { assert } from "./utils/assert";
import * as cursorUtils from "./utils/cursor";
import { getResizeHandleElement } from "./utils/dom/getResizeHandleElement";
import {
dispatchPointerEvent,
mockBoundingClientRect,
verifyAttribute,
} from "./utils/test-utils";
jest.mock("./utils/cursor", () => ({
getCursorStyle: jest.fn(),
resetGlobalCursorStyle: jest.fn(),
setGlobalCursorStyle: jest.fn(),
}));
describe("PanelResizeHandle", () => {
let expectedWarnings: string[] = [];
let root: Root;
let container: HTMLElement;
beforeEach(() => {
// @ts-expect-error
global.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
expectedWarnings = [];
root = createRoot(container);
jest.spyOn(console, "warn").mockImplementation((actualMessage: string) => {
const match = expectedWarnings.findIndex((expectedMessage) => {
return actualMessage.includes(expectedMessage);
});
if (match >= 0) {
expectedWarnings.splice(match, 1);
return;
}
throw Error(`Unexpected warning: ${actualMessage}`);
});
});
afterEach(() => {
jest.clearAllMocks();
jest.resetModules();
act(() => {
root.unmount();
});
expect(expectedWarnings).toHaveLength(0);
});
it("should support ...rest attributes", () => {
act(() => {
root.render(
<PanelGroup direction="horizontal">
<Panel />
<PanelResizeHandle
data-test-name="foo"
id="handle"
tabIndex={123}
title="bar"
/>
<Panel />
</PanelGroup>
);
});
const element = getResizeHandleElement("handle", container);
assert(element, "");
expect(element.tabIndex).toBe(123);
expect(element.getAttribute("data-test-name")).toBe("foo");
expect(element.title).toBe("bar");
});
function setupMockedGroup({
leftProps = {},
rightProps = {},
}: {
leftProps?: Partial<PanelResizeHandleProps>;
rightProps?: Partial<PanelResizeHandleProps>;
} = {}) {
act(() => {
root.render(
<PanelGroup direction="horizontal" id="test-group">
<Panel />
<PanelResizeHandle id="handle-left" tabIndex={1} {...leftProps} />
<Panel />
<PanelResizeHandle id="handle-right" tabIndex={2} {...rightProps} />
<Panel />
</PanelGroup>
);
});
const leftElement = getResizeHandleElement("handle-left", container);
const rightElement = getResizeHandleElement("handle-right", container);
assert(leftElement, "");
assert(rightElement, "");
// JSDom doesn't properly handle bounding rects
mockBoundingClientRect(leftElement, {
x: 50,
y: 0,
height: 50,
width: 2,
});
mockBoundingClientRect(rightElement, {
x: 100,
y: 0,
height: 50,
width: 2,
});
return {
leftElement,
rightElement,
};
}
describe("callbacks", () => {
describe("onDragging", () => {
it("should fire when dragging starts/stops", () => {
const onDragging = jest.fn();
const { leftElement } = setupMockedGroup({
leftProps: { onDragging },
});
act(() => {
dispatchPointerEvent("pointermove", leftElement);
});
expect(onDragging).not.toHaveBeenCalled();
act(() => {
dispatchPointerEvent("pointerdown", leftElement);
});
expect(onDragging).toHaveBeenCalledTimes(1);
expect(onDragging).toHaveBeenCalledWith(true);
act(() => {
dispatchPointerEvent("pointerup", leftElement);
});
expect(onDragging).toHaveBeenCalledTimes(2);
expect(onDragging).toHaveBeenCalledWith(false);
});
it("should only fire for the handle that has been dragged", () => {
const onDraggingLeft = jest.fn();
const onDraggingRight = jest.fn();
const { leftElement } = setupMockedGroup({
leftProps: { onDragging: onDraggingLeft },
rightProps: { onDragging: onDraggingRight },
});
act(() => {
dispatchPointerEvent("pointermove", leftElement);
});
expect(onDraggingLeft).not.toHaveBeenCalled();
expect(onDraggingRight).not.toHaveBeenCalled();
act(() => {
dispatchPointerEvent("pointerdown", leftElement);
});
expect(onDraggingLeft).toHaveBeenCalledTimes(1);
expect(onDraggingLeft).toHaveBeenCalledWith(true);
expect(onDraggingRight).not.toHaveBeenCalled();
act(() => {
dispatchPointerEvent("pointerup", leftElement);
});
expect(onDraggingLeft).toHaveBeenCalledTimes(2);
expect(onDraggingLeft).toHaveBeenCalledWith(false);
expect(onDraggingRight).not.toHaveBeenCalled();
});
});
});
describe("data attributes", () => {
it("should initialize with the correct props based attributes", () => {
const { leftElement, rightElement } = setupMockedGroup();
verifyAttribute(leftElement, "data-panel-group-id", "test-group");
verifyAttribute(leftElement, "data-resize-handle", "");
verifyAttribute(leftElement, "data-panel-group-direction", "horizontal");
verifyAttribute(leftElement, "data-panel-resize-handle-enabled", "true");
verifyAttribute(
leftElement,
"data-panel-resize-handle-id",
"handle-left"
);
verifyAttribute(rightElement, "data-panel-group-id", "test-group");
verifyAttribute(rightElement, "data-resize-handle", "");
verifyAttribute(rightElement, "data-panel-group-direction", "horizontal");
verifyAttribute(rightElement, "data-panel-resize-handle-enabled", "true");
verifyAttribute(
rightElement,
"data-panel-resize-handle-id",
"handle-right"
);
});
it("should update data-resize-handle-active and data-resize-handle-state when dragging starts/stops", () => {
const { leftElement, rightElement } = setupMockedGroup();
verifyAttribute(leftElement, "data-resize-handle-active", null);
verifyAttribute(rightElement, "data-resize-handle-active", null);
verifyAttribute(leftElement, "data-resize-handle-state", "inactive");
verifyAttribute(rightElement, "data-resize-handle-state", "inactive");
act(() => {
dispatchPointerEvent("pointermove", leftElement);
});
verifyAttribute(leftElement, "data-resize-handle-active", null);
verifyAttribute(rightElement, "data-resize-handle-active", null);
verifyAttribute(leftElement, "data-resize-handle-state", "hover");
verifyAttribute(rightElement, "data-resize-handle-state", "inactive");
act(() => {
dispatchPointerEvent("pointerdown", leftElement);
});
verifyAttribute(leftElement, "data-resize-handle-active", "pointer");
verifyAttribute(rightElement, "data-resize-handle-active", null);
verifyAttribute(leftElement, "data-resize-handle-state", "drag");
verifyAttribute(rightElement, "data-resize-handle-state", "inactive");
act(() => {
dispatchPointerEvent("pointermove", leftElement);
});
verifyAttribute(leftElement, "data-resize-handle-active", "pointer");
verifyAttribute(rightElement, "data-resize-handle-active", null);
verifyAttribute(leftElement, "data-resize-handle-state", "drag");
verifyAttribute(rightElement, "data-resize-handle-state", "inactive");
act(() => {
dispatchPointerEvent("pointerup", leftElement);
});
verifyAttribute(leftElement, "data-resize-handle-active", null);
verifyAttribute(rightElement, "data-resize-handle-active", null);
verifyAttribute(leftElement, "data-resize-handle-state", "hover");
verifyAttribute(rightElement, "data-resize-handle-state", "inactive");
act(() => {
dispatchPointerEvent("pointermove", rightElement);
});
verifyAttribute(leftElement, "data-resize-handle-active", null);
verifyAttribute(rightElement, "data-resize-handle-active", null);
verifyAttribute(leftElement, "data-resize-handle-state", "inactive");
verifyAttribute(rightElement, "data-resize-handle-state", "hover");
});
it("should update data-resize-handle-active when focused", () => {
const { leftElement, rightElement } = setupMockedGroup();
verifyAttribute(leftElement, "data-resize-handle-active", null);
verifyAttribute(rightElement, "data-resize-handle-active", null);
act(() => {
leftElement.focus();
});
expect(document.activeElement).toBe(leftElement);
verifyAttribute(leftElement, "data-resize-handle-active", "keyboard");
verifyAttribute(rightElement, "data-resize-handle-active", null);
act(() => {
leftElement.blur();
});
expect(document.activeElement).not.toBe(leftElement);
verifyAttribute(leftElement, "data-resize-handle-active", null);
verifyAttribute(rightElement, "data-resize-handle-active", null);
});
});
describe("a11y", () => {
it("should pass explicit id prop to DOM", () => {
act(() => {
root.render(
<PanelGroup direction="horizontal">
<Panel />
<PanelResizeHandle id="explicit-id" />
<Panel />
</PanelGroup>
);
});
const element = container.querySelector("[data-resize-handle]");
expect(element).not.toBeNull();
expect(element?.getAttribute("id")).toBe("explicit-id");
});
it("should not pass auto-generated id prop to DOM", () => {
act(() => {
root.render(
<PanelGroup direction="horizontal">
<Panel />
<PanelResizeHandle />
<Panel />
</PanelGroup>
);
});
const element = container.querySelector("[data-resize-handle]");
expect(element).not.toBeNull();
expect(element?.getAttribute("id")).toBeNull();
});
});
it("resets the global cursor style on unmount", () => {
const onDraggingLeft = jest.fn();
const { leftElement } = setupMockedGroup({
leftProps: { onDragging: onDraggingLeft },
rightProps: {},
});
act(() => {
dispatchPointerEvent("pointermove", leftElement);
});
act(() => {
dispatchPointerEvent("pointerdown", leftElement);
});
expect(onDraggingLeft).toHaveBeenCalledTimes(1);
expect(onDraggingLeft).toHaveBeenCalledWith(true);
expect(cursorUtils.resetGlobalCursorStyle).not.toHaveBeenCalled();
expect(cursorUtils.setGlobalCursorStyle).toHaveBeenCalled();
onDraggingLeft.mockReset();
act(() => {
dispatchPointerEvent("pointermove", leftElement);
});
expect(onDraggingLeft).not.toHaveBeenCalled();
act(() => {
dispatchPointerEvent("pointerup", leftElement);
});
expect(onDraggingLeft).toHaveBeenCalledTimes(1);
expect(onDraggingLeft).toHaveBeenCalledWith(false);
onDraggingLeft.mockReset();
act(() => {
dispatchPointerEvent("pointermove", leftElement);
});
expect(onDraggingLeft).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
expect(cursorUtils.resetGlobalCursorStyle).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,246 @@
import useIsomorphicLayoutEffect from "./hooks/useIsomorphicEffect";
import useUniqueId from "./hooks/useUniqueId";
import { useWindowSplitterResizeHandlerBehavior } from "./hooks/useWindowSplitterBehavior";
import {
PanelGroupContext,
ResizeEvent,
ResizeHandler,
} from "./PanelGroupContext";
import {
PointerHitAreaMargins,
registerResizeHandle,
ResizeHandlerAction,
} from "./PanelResizeHandleRegistry";
import { assert } from "./utils/assert";
import {
createElement,
CSSProperties,
HTMLAttributes,
PropsWithChildren,
ReactElement,
useContext,
useEffect,
useRef,
useState,
} from "./vendor/react";
export type PanelResizeHandleOnDragging = (isDragging: boolean) => void;
export type ResizeHandlerState = "drag" | "hover" | "inactive";
export type PanelResizeHandleProps = Omit<
HTMLAttributes<keyof HTMLElementTagNameMap>,
"id" | "onBlur" | "onFocus"
> &
PropsWithChildren<{
className?: string;
disabled?: boolean;
hitAreaMargins?: PointerHitAreaMargins;
id?: string | null;
onBlur?: () => void;
onDragging?: PanelResizeHandleOnDragging;
onFocus?: () => void;
style?: CSSProperties;
tabIndex?: number;
tagName?: keyof HTMLElementTagNameMap;
}>;
export function PanelResizeHandle({
children = null,
className: classNameFromProps = "",
disabled = false,
hitAreaMargins,
id: idFromProps,
onBlur,
onDragging,
onFocus,
style: styleFromProps = {},
tabIndex = 0,
tagName: Type = "div",
...rest
}: PanelResizeHandleProps): ReactElement {
const elementRef = useRef<HTMLElement>(null);
// Use a ref to guard against users passing inline props
const callbacksRef = useRef<{
onDragging: PanelResizeHandleOnDragging | undefined;
}>({ onDragging });
useEffect(() => {
callbacksRef.current.onDragging = onDragging;
});
const panelGroupContext = useContext(PanelGroupContext);
if (panelGroupContext === null) {
throw Error(
`PanelResizeHandle components must be rendered within a PanelGroup container`
);
}
const {
direction,
groupId,
registerResizeHandle: registerResizeHandleWithParentGroup,
startDragging,
stopDragging,
panelGroupElement,
} = panelGroupContext;
const resizeHandleId = useUniqueId(idFromProps);
const [state, setState] = useState<ResizeHandlerState>("inactive");
const [isFocused, setIsFocused] = useState(false);
const [resizeHandler, setResizeHandler] = useState<ResizeHandler | null>(
null
);
const committedValuesRef = useRef<{
state: ResizeHandlerState;
}>({
state,
});
useIsomorphicLayoutEffect(() => {
committedValuesRef.current.state = state;
});
useEffect(() => {
if (disabled) {
setResizeHandler(null);
} else {
const resizeHandler = registerResizeHandleWithParentGroup(resizeHandleId);
setResizeHandler(() => resizeHandler);
}
}, [disabled, resizeHandleId, registerResizeHandleWithParentGroup]);
// Extract hit area margins before passing them to the effect's dependency array
// so that inline object values won't trigger re-renders
const coarseHitAreaMargins = hitAreaMargins?.coarse ?? 15;
const fineHitAreaMargins = hitAreaMargins?.fine ?? 5;
useEffect(() => {
if (disabled || resizeHandler == null) {
return;
}
const element = elementRef.current;
assert(element, "Element ref not attached");
const setResizeHandlerState = (
action: ResizeHandlerAction,
isActive: boolean,
event: ResizeEvent | null
) => {
if (isActive) {
switch (action) {
case "down": {
setState("drag");
assert(event, 'Expected event to be defined for "down" action');
startDragging(resizeHandleId, event);
const { onDragging } = callbacksRef.current;
if (onDragging) {
onDragging(true);
}
break;
}
case "move": {
const { state } = committedValuesRef.current;
if (state !== "drag") {
setState("hover");
}
assert(event, 'Expected event to be defined for "move" action');
resizeHandler(event);
break;
}
case "up": {
setState("hover");
stopDragging();
const { onDragging } = callbacksRef.current;
if (onDragging) {
onDragging(false);
}
break;
}
}
} else {
setState("inactive");
}
};
return registerResizeHandle(
resizeHandleId,
element,
direction,
{
coarse: coarseHitAreaMargins,
fine: fineHitAreaMargins,
},
setResizeHandlerState
);
}, [
coarseHitAreaMargins,
direction,
disabled,
fineHitAreaMargins,
registerResizeHandleWithParentGroup,
resizeHandleId,
resizeHandler,
startDragging,
stopDragging,
]);
useWindowSplitterResizeHandlerBehavior({
disabled,
handleId: resizeHandleId,
resizeHandler,
panelGroupElement,
});
const style: CSSProperties = {
touchAction: "none",
userSelect: "none",
};
return createElement(Type, {
...rest,
children,
className: classNameFromProps,
id: idFromProps,
onBlur: () => {
setIsFocused(false);
onBlur?.();
},
onFocus: () => {
setIsFocused(true);
onFocus?.();
},
ref: elementRef,
role: "separator",
style: {
...style,
...styleFromProps,
},
tabIndex,
// CSS selectors
"data-panel-group-direction": direction,
"data-panel-group-id": groupId,
"data-resize-handle": "",
"data-resize-handle-active":
state === "drag" ? "pointer" : isFocused ? "keyboard" : undefined,
"data-resize-handle-state": state,
"data-panel-resize-handle-enabled": !disabled,
"data-panel-resize-handle-id": resizeHandleId,
});
}
PanelResizeHandle.displayName = "PanelResizeHandle";

View File

@@ -0,0 +1,336 @@
import { Direction, ResizeEvent } from "./types";
import { resetGlobalCursorStyle, setGlobalCursorStyle } from "./utils/cursor";
import { getResizeEventCoordinates } from "./utils/events/getResizeEventCoordinates";
import { getInputType } from "./utils/getInputType";
import { intersects } from "./utils/rects/intersects";
import { compare } from "./vendor/stacking-order";
export type ResizeHandlerAction = "down" | "move" | "up";
export type SetResizeHandlerState = (
action: ResizeHandlerAction,
isActive: boolean,
event: ResizeEvent | null
) => void;
export type PointerHitAreaMargins = {
coarse: number;
fine: number;
};
export type ResizeHandlerData = {
direction: Direction;
element: HTMLElement;
hitAreaMargins: PointerHitAreaMargins;
setResizeHandlerState: SetResizeHandlerState;
};
export const EXCEEDED_HORIZONTAL_MIN = 0b0001;
export const EXCEEDED_HORIZONTAL_MAX = 0b0010;
export const EXCEEDED_VERTICAL_MIN = 0b0100;
export const EXCEEDED_VERTICAL_MAX = 0b1000;
const isCoarsePointer = getInputType() === "coarse";
let intersectingHandles: ResizeHandlerData[] = [];
let isPointerDown = false;
let ownerDocumentCounts: Map<Document, number> = new Map();
let panelConstraintFlags: Map<string, number> = new Map();
const registeredResizeHandlers = new Set<ResizeHandlerData>();
export function registerResizeHandle(
resizeHandleId: string,
element: HTMLElement,
direction: Direction,
hitAreaMargins: PointerHitAreaMargins,
setResizeHandlerState: SetResizeHandlerState
) {
const { ownerDocument } = element;
const data: ResizeHandlerData = {
direction,
element,
hitAreaMargins,
setResizeHandlerState,
};
const count = ownerDocumentCounts.get(ownerDocument) ?? 0;
ownerDocumentCounts.set(ownerDocument, count + 1);
registeredResizeHandlers.add(data);
updateListeners();
return function unregisterResizeHandle() {
panelConstraintFlags.delete(resizeHandleId);
registeredResizeHandlers.delete(data);
const count = ownerDocumentCounts.get(ownerDocument) ?? 1;
ownerDocumentCounts.set(ownerDocument, count - 1);
updateListeners();
if (count === 1) {
ownerDocumentCounts.delete(ownerDocument);
}
// If the resize handle that is currently unmounting is intersecting with the pointer,
// update the global pointer to account for the change
if (intersectingHandles.includes(data)) {
const index = intersectingHandles.indexOf(data);
if (index >= 0) {
intersectingHandles.splice(index, 1);
}
updateCursor();
// Also instruct the handle to stop dragging; this prevents the parent group from being left in an inconsistent state
// See github.com/bvaughn/react-resizable-panels/issues/402
setResizeHandlerState("up", true, null);
}
};
}
function handlePointerDown(event: PointerEvent) {
const { target } = event;
const { x, y } = getResizeEventCoordinates(event);
isPointerDown = true;
recalculateIntersectingHandles({ target, x, y });
updateListeners();
if (intersectingHandles.length > 0) {
updateResizeHandlerStates("down", event);
event.preventDefault();
event.stopPropagation();
}
}
function handlePointerMove(event: PointerEvent) {
const { x, y } = getResizeEventCoordinates(event);
// Edge case (see #340)
// Detect when the pointer has been released outside an iframe on a different domain
if (isPointerDown && event.buttons === 0) {
isPointerDown = false;
updateResizeHandlerStates("up", event);
}
if (!isPointerDown) {
const { target } = event;
// Recalculate intersecting handles whenever the pointer moves, except if it has already been pressed
// at that point, the handles may not move with the pointer (depending on constraints)
// but the same set of active handles should be locked until the pointer is released
recalculateIntersectingHandles({ target, x, y });
}
updateResizeHandlerStates("move", event);
// Update cursor based on return value(s) from active handles
updateCursor();
if (intersectingHandles.length > 0) {
event.preventDefault();
}
}
function handlePointerUp(event: ResizeEvent) {
const { target } = event;
const { x, y } = getResizeEventCoordinates(event);
panelConstraintFlags.clear();
isPointerDown = false;
if (intersectingHandles.length > 0) {
event.preventDefault();
}
updateResizeHandlerStates("up", event);
recalculateIntersectingHandles({ target, x, y });
updateCursor();
updateListeners();
}
function recalculateIntersectingHandles({
target,
x,
y,
}: {
target: EventTarget | null;
x: number;
y: number;
}) {
intersectingHandles.splice(0);
let targetElement: HTMLElement | null = null;
if (target instanceof HTMLElement) {
targetElement = target;
}
registeredResizeHandlers.forEach((data) => {
const { element: dragHandleElement, hitAreaMargins } = data;
const dragHandleRect = dragHandleElement.getBoundingClientRect();
const { bottom, left, right, top } = dragHandleRect;
const margin = isCoarsePointer
? hitAreaMargins.coarse
: hitAreaMargins.fine;
const eventIntersects =
x >= left - margin &&
x <= right + margin &&
y >= top - margin &&
y <= bottom + margin;
if (eventIntersects) {
// TRICKY
// We listen for pointers events at the root in order to support hit area margins
// (determining when the pointer is close enough to an element to be considered a "hit")
// Clicking on an element "above" a handle (e.g. a modal) should prevent a hit though
// so at this point we need to compare stacking order of a potentially intersecting drag handle,
// and the element that was actually clicked/touched
if (
targetElement !== null &&
document.contains(targetElement) &&
dragHandleElement !== targetElement &&
!dragHandleElement.contains(targetElement) &&
!targetElement.contains(dragHandleElement) &&
// Calculating stacking order has a cost, so we should avoid it if possible
// That is why we only check potentially intersecting handles,
// and why we skip if the event target is within the handle's DOM
compare(targetElement, dragHandleElement) > 0
) {
// If the target is above the drag handle, then we also need to confirm they overlap
// If they are beside each other (e.g. a panel and its drag handle) then the handle is still interactive
//
// It's not enough to compare only the target
// The target might be a small element inside of a larger container
// (For example, a SPAN or a DIV inside of a larger modal dialog)
let currentElement: HTMLElement | null = targetElement;
let didIntersect = false;
while (currentElement) {
if (currentElement.contains(dragHandleElement)) {
break;
} else if (
intersects(
currentElement.getBoundingClientRect(),
dragHandleRect,
true
)
) {
didIntersect = true;
break;
}
currentElement = currentElement.parentElement;
}
if (didIntersect) {
return;
}
}
intersectingHandles.push(data);
}
});
}
export function reportConstraintsViolation(
resizeHandleId: string,
flag: number
) {
panelConstraintFlags.set(resizeHandleId, flag);
}
function updateCursor() {
let intersectsHorizontal = false;
let intersectsVertical = false;
intersectingHandles.forEach((data) => {
const { direction } = data;
if (direction === "horizontal") {
intersectsHorizontal = true;
} else {
intersectsVertical = true;
}
});
let constraintFlags = 0;
panelConstraintFlags.forEach((flag) => {
constraintFlags |= flag;
});
if (intersectsHorizontal && intersectsVertical) {
setGlobalCursorStyle("intersection", constraintFlags);
} else if (intersectsHorizontal) {
setGlobalCursorStyle("horizontal", constraintFlags);
} else if (intersectsVertical) {
setGlobalCursorStyle("vertical", constraintFlags);
} else {
resetGlobalCursorStyle();
}
}
function updateListeners() {
ownerDocumentCounts.forEach((_, ownerDocument) => {
const { body } = ownerDocument;
body.removeEventListener("contextmenu", handlePointerUp);
body.removeEventListener("pointerdown", handlePointerDown);
body.removeEventListener("pointerleave", handlePointerMove);
body.removeEventListener("pointermove", handlePointerMove);
});
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
if (registeredResizeHandlers.size > 0) {
if (isPointerDown) {
if (intersectingHandles.length > 0) {
ownerDocumentCounts.forEach((count, ownerDocument) => {
const { body } = ownerDocument;
if (count > 0) {
body.addEventListener("contextmenu", handlePointerUp);
body.addEventListener("pointerleave", handlePointerMove);
body.addEventListener("pointermove", handlePointerMove);
}
});
}
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
} else {
ownerDocumentCounts.forEach((count, ownerDocument) => {
const { body } = ownerDocument;
if (count > 0) {
body.addEventListener("pointerdown", handlePointerDown, {
capture: true,
});
body.addEventListener("pointermove", handlePointerMove);
}
});
}
}
}
function updateResizeHandlerStates(
action: ResizeHandlerAction,
event: ResizeEvent
) {
registeredResizeHandlers.forEach((data) => {
const { setResizeHandlerState } = data;
const isActive = intersectingHandles.includes(data);
setResizeHandlerState(action, isActive, event);
});
}

1
node_modules/react-resizable-panels/src/constants.ts generated vendored Normal file
View File

@@ -0,0 +1 @@
export const PRECISION = 10;

View File

@@ -0,0 +1 @@
export const isBrowser = true;

View File

@@ -0,0 +1 @@
export const isDevelopment = true;

View File

@@ -0,0 +1 @@
export const isBrowser = false;

View File

@@ -0,0 +1 @@
export const isDevelopment = false;

View File

@@ -0,0 +1 @@
export const isBrowser = typeof window !== "undefined";

View File

@@ -0,0 +1,7 @@
import { useCallback, useState } from "../vendor/react";
export function useForceUpdate() {
const [_, setCount] = useState(0);
return useCallback(() => setCount((prevCount) => prevCount + 1), []);
}

View File

@@ -0,0 +1,8 @@
import { isBrowser } from "#is-browser";
import { useLayoutEffect_do_not_use_directly } from "../vendor/react";
const useIsomorphicLayoutEffect = isBrowser
? useLayoutEffect_do_not_use_directly
: () => {};
export default useIsomorphicLayoutEffect;

View File

@@ -0,0 +1,19 @@
import { useId, useRef } from "../vendor/react";
const wrappedUseId: () => string | null =
typeof useId === "function" ? useId : (): null => null;
let counter = 0;
export default function useUniqueId(
idFromParams: string | null = null
): string {
const idFromUseId = wrappedUseId();
const idRef = useRef<string | null>(idFromParams || idFromUseId || null);
if (idRef.current === null) {
idRef.current = "" + counter++;
}
return idFromParams ?? idRef.current;
}

View File

@@ -0,0 +1,90 @@
import { ResizeHandler } from "../types";
import { assert } from "../utils/assert";
import { getResizeHandleElement } from "../utils/dom/getResizeHandleElement";
import { getResizeHandleElementIndex } from "../utils/dom/getResizeHandleElementIndex";
import { getResizeHandleElementsForGroup } from "../utils/dom/getResizeHandleElementsForGroup";
import { useEffect } from "../vendor/react";
// https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/
export function useWindowSplitterResizeHandlerBehavior({
disabled,
handleId,
resizeHandler,
panelGroupElement,
}: {
disabled: boolean;
handleId: string;
resizeHandler: ResizeHandler | null;
panelGroupElement: ParentNode | null;
}): void {
useEffect(() => {
if (disabled || resizeHandler == null || panelGroupElement == null) {
return;
}
const handleElement = getResizeHandleElement(handleId, panelGroupElement);
if (handleElement == null) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) {
return;
}
switch (event.key) {
case "ArrowDown":
case "ArrowLeft":
case "ArrowRight":
case "ArrowUp":
case "End":
case "Home": {
event.preventDefault();
resizeHandler(event);
break;
}
case "F6": {
event.preventDefault();
const groupId = handleElement.getAttribute("data-panel-group-id");
assert(groupId, `No group element found for id "${groupId}"`);
const handles = getResizeHandleElementsForGroup(
groupId,
panelGroupElement
);
const index = getResizeHandleElementIndex(
groupId,
handleId,
panelGroupElement
);
assert(
index !== null,
`No resize element found for id "${handleId}"`
);
const nextIndex = event.shiftKey
? index > 0
? index - 1
: handles.length - 1
: index + 1 < handles.length
? index + 1
: 0;
const nextHandle = handles[nextIndex] as HTMLElement;
nextHandle.focus();
break;
}
}
};
handleElement.addEventListener("keydown", onKeyDown);
return () => {
handleElement.removeEventListener("keydown", onKeyDown);
};
}, [panelGroupElement, disabled, handleId, resizeHandler]);
}

View File

@@ -0,0 +1,201 @@
import { isDevelopment } from "#is-development";
import { PanelData } from "../Panel";
import { Direction } from "../types";
import { adjustLayoutByDelta } from "../utils/adjustLayoutByDelta";
import { assert } from "../utils/assert";
import { calculateAriaValues } from "../utils/calculateAriaValues";
import { determinePivotIndices } from "../utils/determinePivotIndices";
import { getPanelGroupElement } from "../utils/dom/getPanelGroupElement";
import { getResizeHandleElementsForGroup } from "../utils/dom/getResizeHandleElementsForGroup";
import { getResizeHandlePanelIds } from "../utils/dom/getResizeHandlePanelIds";
import { fuzzyNumbersEqual } from "../utils/numbers/fuzzyNumbersEqual";
import { RefObject, useEffect, useRef } from "../vendor/react";
import useIsomorphicLayoutEffect from "./useIsomorphicEffect";
// https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/
export function useWindowSplitterPanelGroupBehavior({
committedValuesRef,
eagerValuesRef,
groupId,
layout,
panelDataArray,
panelGroupElement,
setLayout,
}: {
committedValuesRef: RefObject<{
direction: Direction;
}>;
eagerValuesRef: RefObject<{
panelDataArray: PanelData[];
}>;
groupId: string;
layout: number[];
panelDataArray: PanelData[];
panelGroupElement: ParentNode | null;
setLayout: (sizes: number[]) => void;
}): void {
const devWarningsRef = useRef<{
didWarnAboutMissingResizeHandle: boolean;
}>({
didWarnAboutMissingResizeHandle: false,
});
useIsomorphicLayoutEffect(() => {
if (!panelGroupElement) {
return;
}
const resizeHandleElements = getResizeHandleElementsForGroup(
groupId,
panelGroupElement
);
for (let index = 0; index < panelDataArray.length - 1; index++) {
const { valueMax, valueMin, valueNow } = calculateAriaValues({
layout,
panelsArray: panelDataArray,
pivotIndices: [index, index + 1],
});
const resizeHandleElement = resizeHandleElements[index];
if (resizeHandleElement == null) {
if (isDevelopment) {
const { didWarnAboutMissingResizeHandle } = devWarningsRef.current;
if (!didWarnAboutMissingResizeHandle) {
devWarningsRef.current.didWarnAboutMissingResizeHandle = true;
console.warn(
`WARNING: Missing resize handle for PanelGroup "${groupId}"`
);
}
}
} else {
const panelData = panelDataArray[index];
assert(panelData, `No panel data found for index "${index}"`);
resizeHandleElement.setAttribute("aria-controls", panelData.id);
resizeHandleElement.setAttribute(
"aria-valuemax",
"" + Math.round(valueMax)
);
resizeHandleElement.setAttribute(
"aria-valuemin",
"" + Math.round(valueMin)
);
resizeHandleElement.setAttribute(
"aria-valuenow",
valueNow != null ? "" + Math.round(valueNow) : ""
);
}
}
return () => {
resizeHandleElements.forEach((resizeHandleElement, index) => {
resizeHandleElement.removeAttribute("aria-controls");
resizeHandleElement.removeAttribute("aria-valuemax");
resizeHandleElement.removeAttribute("aria-valuemin");
resizeHandleElement.removeAttribute("aria-valuenow");
});
};
}, [groupId, layout, panelDataArray, panelGroupElement]);
useEffect(() => {
if (!panelGroupElement) {
return;
}
const eagerValues = eagerValuesRef.current;
assert(eagerValues, `Eager values not found`);
const { panelDataArray } = eagerValues;
const groupElement = getPanelGroupElement(groupId, panelGroupElement);
assert(groupElement != null, `No group found for id "${groupId}"`);
const handles = getResizeHandleElementsForGroup(groupId, panelGroupElement);
assert(handles, `No resize handles found for group id "${groupId}"`);
const cleanupFunctions = handles.map((handle) => {
const handleId = handle.getAttribute("data-panel-resize-handle-id");
assert(handleId, `Resize handle element has no handle id attribute`);
const [idBefore, idAfter] = getResizeHandlePanelIds(
groupId,
handleId,
panelDataArray,
panelGroupElement
);
if (idBefore == null || idAfter == null) {
return () => {};
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) {
return;
}
switch (event.key) {
case "Enter": {
event.preventDefault();
const index = panelDataArray.findIndex(
(panelData) => panelData.id === idBefore
);
if (index >= 0) {
const panelData = panelDataArray[index];
assert(panelData, `No panel data found for index ${index}`);
const size = layout[index];
const {
collapsedSize = 0,
collapsible,
minSize = 0,
} = panelData.constraints;
if (size != null && collapsible) {
const nextLayout = adjustLayoutByDelta({
delta: fuzzyNumbersEqual(size, collapsedSize)
? minSize - collapsedSize
: collapsedSize - size,
initialLayout: layout,
panelConstraints: panelDataArray.map(
(panelData) => panelData.constraints
),
pivotIndices: determinePivotIndices(
groupId,
handleId,
panelGroupElement
),
prevLayout: layout,
trigger: "keyboard",
});
if (layout !== nextLayout) {
setLayout(nextLayout);
}
}
}
break;
}
}
};
handle.addEventListener("keydown", onKeyDown);
return () => {
handle.removeEventListener("keydown", onKeyDown);
};
});
return () => {
cleanupFunctions.forEach((cleanupFunction) => cleanupFunction());
};
}, [
panelGroupElement,
committedValuesRef,
eagerValuesRef,
groupId,
layout,
panelDataArray,
setLayout,
]);
}

77
node_modules/react-resizable-panels/src/index.ts generated vendored Normal file
View File

@@ -0,0 +1,77 @@
import { Panel } from "./Panel";
import { PanelGroup } from "./PanelGroup";
import { PanelResizeHandle } from "./PanelResizeHandle";
import { assert } from "./utils/assert";
import { setNonce } from "./utils/csp";
import {
enableGlobalCursorStyles,
disableGlobalCursorStyles,
} from "./utils/cursor";
import { getPanelElement } from "./utils/dom/getPanelElement";
import { getPanelElementsForGroup } from "./utils/dom/getPanelElementsForGroup";
import { getPanelGroupElement } from "./utils/dom/getPanelGroupElement";
import { getResizeHandleElement } from "./utils/dom/getResizeHandleElement";
import { getResizeHandleElementIndex } from "./utils/dom/getResizeHandleElementIndex";
import { getResizeHandleElementsForGroup } from "./utils/dom/getResizeHandleElementsForGroup";
import { getResizeHandlePanelIds } from "./utils/dom/getResizeHandlePanelIds";
import { getIntersectingRectangle } from "./utils/rects/getIntersectingRectangle";
import { intersects } from "./utils/rects/intersects";
import type {
ImperativePanelHandle,
PanelOnCollapse,
PanelOnExpand,
PanelOnResize,
PanelProps,
} from "./Panel";
import type {
ImperativePanelGroupHandle,
PanelGroupOnLayout,
PanelGroupProps,
PanelGroupStorage,
} from "./PanelGroup";
import type {
PanelResizeHandleOnDragging,
PanelResizeHandleProps,
} from "./PanelResizeHandle";
import type { PointerHitAreaMargins } from "./PanelResizeHandleRegistry";
export {
// TypeScript types
ImperativePanelGroupHandle,
ImperativePanelHandle,
PanelGroupOnLayout,
PanelGroupProps,
PanelGroupStorage,
PanelOnCollapse,
PanelOnExpand,
PanelOnResize,
PanelProps,
PanelResizeHandleOnDragging,
PanelResizeHandleProps,
PointerHitAreaMargins,
// React components
Panel,
PanelGroup,
PanelResizeHandle,
// Utility methods
assert,
getIntersectingRectangle,
intersects,
// DOM helpers
getPanelElement,
getPanelElementsForGroup,
getPanelGroupElement,
getResizeHandleElement,
getResizeHandleElementIndex,
getResizeHandleElementsForGroup,
getResizeHandlePanelIds,
// Styles and CSP (see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce)
enableGlobalCursorStyles,
disableGlobalCursorStyles,
setNonce,
};

5
node_modules/react-resizable-panels/src/types.ts generated vendored Normal file
View File

@@ -0,0 +1,5 @@
export type Direction = "horizontal" | "vertical";
// The "contextmenu" event is not supported as a PointerEvent in all browsers yet, so MouseEvent still need to be handled
export type ResizeEvent = KeyboardEvent | PointerEvent | MouseEvent;
export type ResizeHandler = (event: ResizeEvent) => void;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
import { PanelConstraints } from "../Panel";
import { assert } from "./assert";
import { fuzzyCompareNumbers } from "./numbers/fuzzyCompareNumbers";
import { fuzzyLayoutsEqual } from "./numbers/fuzzyLayoutsEqual";
import { fuzzyNumbersEqual } from "./numbers/fuzzyNumbersEqual";
import { resizePanel } from "./resizePanel";
// All units must be in percentages; pixel values should be pre-converted
export function adjustLayoutByDelta({
delta,
initialLayout,
panelConstraints: panelConstraintsArray,
pivotIndices,
prevLayout,
trigger,
}: {
delta: number;
initialLayout: number[];
panelConstraints: PanelConstraints[];
pivotIndices: number[];
prevLayout: number[];
trigger: "imperative-api" | "keyboard" | "mouse-or-touch";
}): number[] {
if (fuzzyNumbersEqual(delta, 0)) {
return initialLayout;
}
const nextLayout = [...initialLayout];
const [firstPivotIndex, secondPivotIndex] = pivotIndices;
assert(firstPivotIndex != null, "Invalid first pivot index");
assert(secondPivotIndex != null, "Invalid second pivot index");
let deltaApplied = 0;
// const DEBUG = [];
// DEBUG.push(`adjustLayoutByDelta()`);
// DEBUG.push(` initialLayout: ${initialLayout.join(", ")}`);
// DEBUG.push(` prevLayout: ${prevLayout.join(", ")}`);
// DEBUG.push(` delta: ${delta}`);
// DEBUG.push(` pivotIndices: ${pivotIndices.join(", ")}`);
// DEBUG.push(` trigger: ${trigger}`);
// DEBUG.push("");
// A resizing panel affects the panels before or after it.
//
// A negative delta means the panel(s) immediately after the resize handle should grow/expand by decreasing its offset.
// Other panels may also need to shrink/contract (and shift) to make room, depending on the min weights.
//
// A positive delta means the panel(s) immediately before the resize handle should "expand".
// This is accomplished by shrinking/contracting (and shifting) one or more of the panels after the resize handle.
{
// If this is a resize triggered by a keyboard event, our logic for expanding/collapsing is different.
// We no longer check the halfway threshold because this may prevent the panel from expanding at all.
if (trigger === "keyboard") {
{
// Check if we should expand a collapsed panel
const index = delta < 0 ? secondPivotIndex : firstPivotIndex;
const panelConstraints = panelConstraintsArray[index];
assert(
panelConstraints,
`Panel constraints not found for index ${index}`
);
const {
collapsedSize = 0,
collapsible,
minSize = 0,
} = panelConstraints;
// DEBUG.push(`edge case check 1: ${index}`);
// DEBUG.push(` -> collapsible? ${collapsible}`);
if (collapsible) {
const prevSize = initialLayout[index];
assert(
prevSize != null,
`Previous layout not found for panel index ${index}`
);
if (fuzzyNumbersEqual(prevSize, collapsedSize)) {
const localDelta = minSize - prevSize;
// DEBUG.push(` -> expand delta: ${localDelta}`);
if (fuzzyCompareNumbers(localDelta, Math.abs(delta)) > 0) {
delta = delta < 0 ? 0 - localDelta : localDelta;
// DEBUG.push(` -> delta: ${delta}`);
}
}
}
}
{
// Check if we should collapse a panel at its minimum size
const index = delta < 0 ? firstPivotIndex : secondPivotIndex;
const panelConstraints = panelConstraintsArray[index];
assert(
panelConstraints,
`No panel constraints found for index ${index}`
);
const {
collapsedSize = 0,
collapsible,
minSize = 0,
} = panelConstraints;
// DEBUG.push(`edge case check 2: ${index}`);
// DEBUG.push(` -> collapsible? ${collapsible}`);
if (collapsible) {
const prevSize = initialLayout[index];
assert(
prevSize != null,
`Previous layout not found for panel index ${index}`
);
if (fuzzyNumbersEqual(prevSize, minSize)) {
const localDelta = prevSize - collapsedSize;
// DEBUG.push(` -> expand delta: ${localDelta}`);
if (fuzzyCompareNumbers(localDelta, Math.abs(delta)) > 0) {
delta = delta < 0 ? 0 - localDelta : localDelta;
// DEBUG.push(` -> delta: ${delta}`);
}
}
}
}
}
// DEBUG.push("");
}
{
// Pre-calculate max available delta in the opposite direction of our pivot.
// This will be the maximum amount we're allowed to expand/contract the panels in the primary direction.
// If this amount is less than the requested delta, adjust the requested delta.
// If this amount is greater than the requested delta, that's useful information too
// as an expanding panel might change from collapsed to min size.
const increment = delta < 0 ? 1 : -1;
let index = delta < 0 ? secondPivotIndex : firstPivotIndex;
let maxAvailableDelta = 0;
// DEBUG.push("pre calc...");
while (true) {
const prevSize = initialLayout[index];
assert(
prevSize != null,
`Previous layout not found for panel index ${index}`
);
const maxSafeSize = resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex: index,
size: 100,
});
const delta = maxSafeSize - prevSize;
// DEBUG.push(` ${index}: ${prevSize} -> ${maxSafeSize}`);
maxAvailableDelta += delta;
index += increment;
if (index < 0 || index >= panelConstraintsArray.length) {
break;
}
}
// DEBUG.push(` -> max available delta: ${maxAvailableDelta}`);
const minAbsDelta = Math.min(Math.abs(delta), Math.abs(maxAvailableDelta));
delta = delta < 0 ? 0 - minAbsDelta : minAbsDelta;
// DEBUG.push(` -> adjusted delta: ${delta}`);
// DEBUG.push("");
}
{
// Delta added to a panel needs to be subtracted from other panels (within the constraints that those panels allow).
const pivotIndex = delta < 0 ? firstPivotIndex : secondPivotIndex;
let index = pivotIndex;
while (index >= 0 && index < panelConstraintsArray.length) {
const deltaRemaining = Math.abs(delta) - Math.abs(deltaApplied);
const prevSize = initialLayout[index];
assert(
prevSize != null,
`Previous layout not found for panel index ${index}`
);
const unsafeSize = prevSize - deltaRemaining;
const safeSize = resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex: index,
size: unsafeSize,
});
if (!fuzzyNumbersEqual(prevSize, safeSize)) {
deltaApplied += prevSize - safeSize;
nextLayout[index] = safeSize;
if (
deltaApplied
.toPrecision(3)
.localeCompare(Math.abs(delta).toPrecision(3), undefined, {
numeric: true,
}) >= 0
) {
break;
}
}
if (delta < 0) {
index--;
} else {
index++;
}
}
}
// DEBUG.push(`after 1: ${nextLayout.join(", ")}`);
// DEBUG.push(` deltaApplied: ${deltaApplied}`);
// DEBUG.push("");
// If we were unable to resize any of the panels panels, return the previous state.
// This will essentially bailout and ignore e.g. drags past a panel's boundaries
if (fuzzyLayoutsEqual(prevLayout, nextLayout)) {
// DEBUG.push(`bailout to previous layout: ${prevLayout.join(", ")}`);
// console.log(DEBUG.join("\n"));
return prevLayout;
}
{
// Now distribute the applied delta to the panels in the other direction
const pivotIndex = delta < 0 ? secondPivotIndex : firstPivotIndex;
const prevSize = initialLayout[pivotIndex];
assert(
prevSize != null,
`Previous layout not found for panel index ${pivotIndex}`
);
const unsafeSize = prevSize + deltaApplied;
const safeSize = resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex: pivotIndex,
size: unsafeSize,
});
// Adjust the pivot panel before, but only by the amount that surrounding panels were able to shrink/contract.
nextLayout[pivotIndex] = safeSize;
// Edge case where expanding or contracting one panel caused another one to change collapsed state
if (!fuzzyNumbersEqual(safeSize, unsafeSize)) {
let deltaRemaining = unsafeSize - safeSize;
const pivotIndex = delta < 0 ? secondPivotIndex : firstPivotIndex;
let index = pivotIndex;
while (index >= 0 && index < panelConstraintsArray.length) {
const prevSize = nextLayout[index];
assert(
prevSize != null,
`Previous layout not found for panel index ${index}`
);
const unsafeSize = prevSize + deltaRemaining;
const safeSize = resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex: index,
size: unsafeSize,
});
if (!fuzzyNumbersEqual(prevSize, safeSize)) {
deltaRemaining -= safeSize - prevSize;
nextLayout[index] = safeSize;
}
if (fuzzyNumbersEqual(deltaRemaining, 0)) {
break;
}
if (delta > 0) {
index--;
} else {
index++;
}
}
}
}
// DEBUG.push(`after 2: ${nextLayout.join(", ")}`);
// DEBUG.push(` deltaApplied: ${deltaApplied}`);
// DEBUG.push("");
const totalSize = nextLayout.reduce((total, size) => size + total, 0);
// DEBUG.push(`total size: ${totalSize}`);
// If our new layout doesn't add up to 100%, that means the requested delta can't be applied
// In that case, fall back to our most recent valid layout
if (!fuzzyNumbersEqual(totalSize, 100)) {
// DEBUG.push(`bailout to previous layout: ${prevLayout.join(", ")}`);
// console.log(DEBUG.join("\n"));
return prevLayout;
}
// console.log(DEBUG.join("\n"));
return nextLayout;
}

View File

@@ -0,0 +1,13 @@
export function areEqual(arrayA: any[], arrayB: any[]): boolean {
if (arrayA.length !== arrayB.length) {
return false;
}
for (let index = 0; index < arrayA.length; index++) {
if (arrayA[index] !== arrayB[index]) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,10 @@
export function assert(
expectedCondition: any,
message: string
): asserts expectedCondition {
if (!expectedCondition) {
console.error(message);
throw Error(message);
}
}

View File

@@ -0,0 +1,106 @@
import { PanelConstraints, PanelData } from "../Panel";
import { calculateAriaValues } from "./calculateAriaValues";
describe("calculateAriaValues", () => {
let idCounter = 0;
let orderCounter = 0;
function createPanelData(constraints: PanelConstraints = {}): PanelData {
return {
callbacks: {
onCollapse: undefined,
onExpand: undefined,
onResize: undefined,
},
constraints,
id: `${idCounter++}`,
idIsFromProps: false,
order: orderCounter++,
};
}
beforeEach(() => {
idCounter = 0;
orderCounter = 0;
});
it("should work correctly for panels with no min/max constraints", () => {
expect(
calculateAriaValues({
layout: [50, 50],
panelsArray: [createPanelData(), createPanelData()],
pivotIndices: [0, 1],
})
).toEqual({
valueMax: 100,
valueMin: 0,
valueNow: 50,
});
expect(
calculateAriaValues({
layout: [20, 50, 30],
panelsArray: [createPanelData(), createPanelData(), createPanelData()],
pivotIndices: [0, 1],
})
).toEqual({
valueMax: 100,
valueMin: 0,
valueNow: 20,
});
expect(
calculateAriaValues({
layout: [20, 50, 30],
panelsArray: [createPanelData(), createPanelData(), createPanelData()],
pivotIndices: [1, 2],
})
).toEqual({
valueMax: 100,
valueMin: 0,
valueNow: 50,
});
});
it("should work correctly for panels with min/max constraints", () => {
expect(
calculateAriaValues({
layout: [25, 75],
panelsArray: [
createPanelData({
maxSize: 35,
minSize: 10,
}),
createPanelData(),
],
pivotIndices: [0, 1],
})
).toEqual({
valueMax: 35,
valueMin: 10,
valueNow: 25,
});
expect(
calculateAriaValues({
layout: [25, 50, 25],
panelsArray: [
createPanelData({
maxSize: 35,
minSize: 10,
}),
createPanelData(),
createPanelData({
maxSize: 35,
minSize: 10,
}),
],
pivotIndices: [1, 2],
})
).toEqual({
valueMax: 80,
valueMin: 30,
valueNow: 50,
});
});
});

View File

@@ -0,0 +1,45 @@
import { PanelData } from "../Panel";
import { assert } from "./assert";
export function calculateAriaValues({
layout,
panelsArray,
pivotIndices,
}: {
layout: number[];
panelsArray: PanelData[];
pivotIndices: number[];
}) {
let currentMinSize = 0;
let currentMaxSize = 100;
let totalMinSize = 0;
let totalMaxSize = 0;
const firstIndex = pivotIndices[0];
assert(firstIndex != null, "No pivot index found");
// A panel's effective min/max sizes also need to account for other panel's sizes.
panelsArray.forEach((panelData, index) => {
const { constraints } = panelData;
const { maxSize = 100, minSize = 0 } = constraints;
if (index === firstIndex) {
currentMinSize = minSize;
currentMaxSize = maxSize;
} else {
totalMinSize += minSize;
totalMaxSize += maxSize;
}
});
const valueMax = Math.min(currentMaxSize, 100 - totalMinSize);
const valueMin = Math.max(currentMinSize, 100 - totalMaxSize);
const valueNow = layout[firstIndex];
return {
valueMax,
valueMin,
valueNow,
};
}

View File

@@ -0,0 +1,63 @@
import { DragState, ResizeEvent } from "../PanelGroupContext";
import { Direction } from "../types";
import { calculateDragOffsetPercentage } from "./calculateDragOffsetPercentage";
import { isKeyDown } from "./events";
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX
export function calculateDeltaPercentage(
event: ResizeEvent,
dragHandleId: string,
direction: Direction,
initialDragState: DragState | null,
keyboardResizeBy: number | null,
panelGroupElement: HTMLElement
): number {
if (isKeyDown(event)) {
const isHorizontal = direction === "horizontal";
let delta = 0;
if (event.shiftKey) {
delta = 100;
} else if (keyboardResizeBy != null) {
delta = keyboardResizeBy;
} else {
delta = 10;
}
let movement = 0;
switch (event.key) {
case "ArrowDown":
movement = isHorizontal ? 0 : delta;
break;
case "ArrowLeft":
movement = isHorizontal ? -delta : 0;
break;
case "ArrowRight":
movement = isHorizontal ? delta : 0;
break;
case "ArrowUp":
movement = isHorizontal ? 0 : -delta;
break;
case "End":
movement = 100;
break;
case "Home":
movement = -100;
break;
}
return movement;
} else {
if (initialDragState == null) {
return 0;
}
return calculateDragOffsetPercentage(
event,
dragHandleId,
direction,
initialDragState,
panelGroupElement
);
}
}

View File

@@ -0,0 +1,40 @@
import { DragState, ResizeEvent } from "../PanelGroupContext";
import { Direction } from "../types";
import { assert } from "./assert";
import { getPanelGroupElement } from "./dom/getPanelGroupElement";
import { getResizeHandleElement } from "./dom/getResizeHandleElement";
import { getResizeEventCursorPosition } from "./events/getResizeEventCursorPosition";
export function calculateDragOffsetPercentage(
event: ResizeEvent,
dragHandleId: string,
direction: Direction,
initialDragState: DragState,
panelGroupElement: HTMLElement
): number {
const isHorizontal = direction === "horizontal";
const handleElement = getResizeHandleElement(dragHandleId, panelGroupElement);
assert(
handleElement,
`No resize handle element found for id "${dragHandleId}"`
);
const groupId = handleElement.getAttribute("data-panel-group-id");
assert(groupId, `Resize handle element has no group id attribute`);
let { initialCursorPosition } = initialDragState;
const cursorPosition = getResizeEventCursorPosition(direction, event);
const groupElement = getPanelGroupElement(groupId, panelGroupElement);
assert(groupElement, `No group element found for id "${groupId}"`);
const groupRect = groupElement.getBoundingClientRect();
const groupSizeInPixels = isHorizontal ? groupRect.width : groupRect.height;
const offsetPixels = cursorPosition - initialCursorPosition;
const offsetPercentage = (offsetPixels / groupSizeInPixels) * 100;
return offsetPercentage;
}

View File

@@ -0,0 +1,87 @@
import { PanelConstraints, PanelData } from "../Panel";
import { calculateUnsafeDefaultLayout } from "./calculateUnsafeDefaultLayout";
import { expectToBeCloseToArray } from "./test-utils";
describe("calculateUnsafeDefaultLayout", () => {
let idCounter = 0;
let orderCounter = 0;
function createPanelData(constraints: PanelConstraints = {}): PanelData {
return {
callbacks: {
onCollapse: undefined,
onExpand: undefined,
onResize: undefined,
},
constraints,
id: `${idCounter++}`,
idIsFromProps: false,
order: orderCounter++,
};
}
beforeEach(() => {
idCounter = 0;
orderCounter = 0;
});
it("should assign even sizes for every panel by default", () => {
expectToBeCloseToArray(
calculateUnsafeDefaultLayout({
panelDataArray: [createPanelData()],
}),
[100]
);
expectToBeCloseToArray(
calculateUnsafeDefaultLayout({
panelDataArray: [createPanelData(), createPanelData()],
}),
[50, 50]
);
expectToBeCloseToArray(
calculateUnsafeDefaultLayout({
panelDataArray: [
createPanelData(),
createPanelData(),
createPanelData(),
],
}),
[33.3, 33.3, 33.3]
);
});
it("should respect default panel size constraints", () => {
expectToBeCloseToArray(
calculateUnsafeDefaultLayout({
panelDataArray: [
createPanelData({
defaultSize: 15,
}),
createPanelData({
defaultSize: 85,
}),
],
}),
[15, 85]
);
});
it("should ignore min and max panel size constraints", () => {
expectToBeCloseToArray(
calculateUnsafeDefaultLayout({
panelDataArray: [
createPanelData({
minSize: 40,
}),
createPanelData(),
createPanelData({
maxSize: 10,
}),
],
}),
[33.3, 33.3, 33.3]
);
});
});

View File

@@ -0,0 +1,50 @@
import { PanelData } from "../Panel";
import { assert } from "./assert";
export function calculateUnsafeDefaultLayout({
panelDataArray,
}: {
panelDataArray: PanelData[];
}): number[] {
const layout = Array<number>(panelDataArray.length);
const panelConstraintsArray = panelDataArray.map(
(panelData) => panelData.constraints
);
let numPanelsWithSizes = 0;
let remainingSize = 100;
// Distribute default sizes first
for (let index = 0; index < panelDataArray.length; index++) {
const panelConstraints = panelConstraintsArray[index];
assert(panelConstraints, `Panel constraints not found for index ${index}`);
const { defaultSize } = panelConstraints;
if (defaultSize != null) {
numPanelsWithSizes++;
layout[index] = defaultSize;
remainingSize -= defaultSize;
}
}
// Remaining size should be distributed evenly between panels without default sizes
for (let index = 0; index < panelDataArray.length; index++) {
const panelConstraints = panelConstraintsArray[index];
assert(panelConstraints, `Panel constraints not found for index ${index}`);
const { defaultSize } = panelConstraints;
if (defaultSize != null) {
continue;
}
const numRemainingPanels = panelDataArray.length - numPanelsWithSizes;
const size = remainingSize / numRemainingPanels;
numPanelsWithSizes++;
layout[index] = size;
remainingSize -= size;
}
return layout;
}

View File

@@ -0,0 +1,49 @@
import { PanelData } from "../Panel";
import { assert } from "./assert";
import { fuzzyNumbersEqual } from "./numbers/fuzzyCompareNumbers";
// Layout should be pre-converted into percentages
export function callPanelCallbacks(
panelsArray: PanelData[],
layout: number[],
panelIdToLastNotifiedSizeMap: Record<string, number>
) {
layout.forEach((size, index) => {
const panelData = panelsArray[index];
assert(panelData, `Panel data not found for index ${index}`);
const { callbacks, constraints, id: panelId } = panelData;
const { collapsedSize = 0, collapsible } = constraints;
const lastNotifiedSize = panelIdToLastNotifiedSizeMap[panelId];
if (lastNotifiedSize == null || size !== lastNotifiedSize) {
panelIdToLastNotifiedSizeMap[panelId] = size;
const { onCollapse, onExpand, onResize } = callbacks;
if (onResize) {
onResize(size, lastNotifiedSize);
}
if (collapsible && (onCollapse || onExpand)) {
if (
onExpand &&
(lastNotifiedSize == null ||
fuzzyNumbersEqual(lastNotifiedSize, collapsedSize)) &&
!fuzzyNumbersEqual(size, collapsedSize)
) {
onExpand();
}
if (
onCollapse &&
(lastNotifiedSize == null ||
!fuzzyNumbersEqual(lastNotifiedSize, collapsedSize)) &&
fuzzyNumbersEqual(size, collapsedSize)
) {
onCollapse();
}
}
}
});
}

View File

@@ -0,0 +1,9 @@
import { compareLayouts } from "./compareLayouts";
describe("compareLayouts", () => {
it("should work", () => {
expect(compareLayouts([1, 2], [1])).toBe(false);
expect(compareLayouts([1], [1, 2])).toBe(false);
expect(compareLayouts([1, 2, 3], [1, 2, 3])).toBe(true);
});
});

View File

@@ -0,0 +1,12 @@
export function compareLayouts(a: number[], b: number[]) {
if (a.length !== b.length) {
return false;
} else {
for (let index = 0; index < a.length; index++) {
if (a[index] != b[index]) {
return false;
}
}
}
return true;
}

View File

@@ -0,0 +1,123 @@
import { PanelConstraints, PanelData } from "../Panel";
import { computePanelFlexBoxStyle } from "./computePanelFlexBoxStyle";
describe("computePanelFlexBoxStyle", () => {
function createPanelData(constraints: PanelConstraints = {}): PanelData {
return {
callbacks: {},
constraints,
id: "fake",
idIsFromProps: false,
order: undefined,
};
}
it("should observe a panel's default size if group layout has not yet been computed", () => {
expect(
computePanelFlexBoxStyle({
defaultSize: 0.1233456789,
dragState: null,
layout: [],
panelData: [
createPanelData({
defaultSize: 0.1233456789,
}),
createPanelData(),
],
panelIndex: 0,
precision: 2,
})
).toMatchInlineSnapshot(`
{
"flexBasis": 0,
"flexGrow": "0.12",
"flexShrink": 1,
"overflow": "hidden",
"pointerEvents": undefined,
}
`);
});
it("should always fill the full width for single-panel groups", () => {
expect(
computePanelFlexBoxStyle({
defaultSize: undefined,
dragState: null,
layout: [],
panelData: [createPanelData()],
panelIndex: 0,
precision: 2,
})
).toMatchInlineSnapshot(`
{
"flexBasis": 0,
"flexGrow": "1",
"flexShrink": 1,
"overflow": "hidden",
"pointerEvents": undefined,
}
`);
});
it("should round sizes to avoid floating point precision errors", () => {
const layout = [0.25435, 0.5758, 0.1698];
const panelData = [createPanelData(), createPanelData(), createPanelData()];
expect(
computePanelFlexBoxStyle({
defaultSize: undefined,
dragState: null,
layout,
panelData,
panelIndex: 0,
precision: 2,
})
).toMatchInlineSnapshot(`
{
"flexBasis": 0,
"flexGrow": "0.25",
"flexShrink": 1,
"overflow": "hidden",
"pointerEvents": undefined,
}
`);
expect(
computePanelFlexBoxStyle({
defaultSize: undefined,
dragState: null,
layout,
panelData,
panelIndex: 1,
precision: 2,
})
).toMatchInlineSnapshot(`
{
"flexBasis": 0,
"flexGrow": "0.58",
"flexShrink": 1,
"overflow": "hidden",
"pointerEvents": undefined,
}
`);
expect(
computePanelFlexBoxStyle({
defaultSize: undefined,
dragState: null,
layout,
panelData,
panelIndex: 2,
precision: 2,
})
).toMatchInlineSnapshot(`
{
"flexBasis": 0,
"flexGrow": "0.17",
"flexShrink": 1,
"overflow": "hidden",
"pointerEvents": undefined,
}
`);
});
});

View File

@@ -0,0 +1,50 @@
// This method returns a number between 1 and 100 representing
import { PanelData } from "../Panel";
import { DragState } from "../PanelGroupContext";
import { CSSProperties } from "../vendor/react";
// the % of the group's overall space this panel should occupy.
export function computePanelFlexBoxStyle({
defaultSize,
dragState,
layout,
panelData,
panelIndex,
precision = 3,
}: {
defaultSize: number | undefined;
layout: number[];
dragState: DragState | null;
panelData: PanelData[];
panelIndex: number;
precision?: number;
}): CSSProperties {
const size = layout[panelIndex];
let flexGrow;
if (size == null) {
// Initial render (before panels have registered themselves)
// In order to support server rendering, fall back to default size if provided
flexGrow =
defaultSize != undefined ? defaultSize.toPrecision(precision) : "1";
} else if (panelData.length === 1) {
// Special case: Single panel group should always fill full width/height
flexGrow = "1";
} else {
flexGrow = size.toPrecision(precision);
}
return {
flexBasis: 0,
flexGrow,
flexShrink: 1,
// Without this, Panel sizes may be unintentionally overridden by their content
overflow: "hidden",
// Disable pointer events inside of a panel during resize
// This avoid edge cases like nested iframes
pointerEvents: dragState !== null ? "none" : undefined,
};
}

9
node_modules/react-resizable-panels/src/utils/csp.ts generated vendored Normal file
View File

@@ -0,0 +1,9 @@
let nonce: string | null;
export function getNonce(): string | null {
return nonce;
}
export function setNonce(value: string | null) {
nonce = value;
}

103
node_modules/react-resizable-panels/src/utils/cursor.ts generated vendored Normal file
View File

@@ -0,0 +1,103 @@
import {
EXCEEDED_HORIZONTAL_MAX,
EXCEEDED_HORIZONTAL_MIN,
EXCEEDED_VERTICAL_MAX,
EXCEEDED_VERTICAL_MIN,
} from "../PanelResizeHandleRegistry";
import { getNonce } from "./csp";
type CursorState = "horizontal" | "intersection" | "vertical";
let currentCursorStyle: string | null = null;
let enabled: boolean = true;
let styleElement: HTMLStyleElement | null = null;
export function disableGlobalCursorStyles() {
enabled = false;
}
export function enableGlobalCursorStyles() {
enabled = true;
}
export function getCursorStyle(
state: CursorState,
constraintFlags: number
): string {
if (constraintFlags) {
const horizontalMin = (constraintFlags & EXCEEDED_HORIZONTAL_MIN) !== 0;
const horizontalMax = (constraintFlags & EXCEEDED_HORIZONTAL_MAX) !== 0;
const verticalMin = (constraintFlags & EXCEEDED_VERTICAL_MIN) !== 0;
const verticalMax = (constraintFlags & EXCEEDED_VERTICAL_MAX) !== 0;
if (horizontalMin) {
if (verticalMin) {
return "se-resize";
} else if (verticalMax) {
return "ne-resize";
} else {
return "e-resize";
}
} else if (horizontalMax) {
if (verticalMin) {
return "sw-resize";
} else if (verticalMax) {
return "nw-resize";
} else {
return "w-resize";
}
} else if (verticalMin) {
return "s-resize";
} else if (verticalMax) {
return "n-resize";
}
}
switch (state) {
case "horizontal":
return "ew-resize";
case "intersection":
return "move";
case "vertical":
return "ns-resize";
}
}
export function resetGlobalCursorStyle() {
if (styleElement !== null) {
document.head.removeChild(styleElement);
currentCursorStyle = null;
styleElement = null;
}
}
export function setGlobalCursorStyle(
state: CursorState,
constraintFlags: number
) {
if (!enabled) {
return;
}
const style = getCursorStyle(state, constraintFlags);
if (currentCursorStyle === style) {
return;
}
currentCursorStyle = style;
if (styleElement === null) {
styleElement = document.createElement("style");
const nonce = getNonce();
if (nonce) {
styleElement.setAttribute("nonce", nonce);
}
document.head.appendChild(styleElement);
}
styleElement.innerHTML = `*{cursor: ${style}!important;}`;
}

View File

@@ -0,0 +1,18 @@
export default function debounce<T extends Function>(
callback: T,
durationMs: number = 10
) {
let timeoutId: NodeJS.Timeout | null = null;
let callable = (...args: any) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
callback(...args);
}, durationMs);
};
return callable as unknown as T;
}

View File

@@ -0,0 +1,15 @@
import { getResizeHandleElementIndex } from "../utils/dom/getResizeHandleElementIndex";
export function determinePivotIndices(
groupId: string,
dragHandleId: string,
panelGroupElement: ParentNode
): [indexBefore: number, indexAfter: number] {
const index = getResizeHandleElementIndex(
groupId,
dragHandleId,
panelGroupElement
);
return index != null ? [index, index + 1] : [-1, -1];
}

View File

@@ -0,0 +1,10 @@
export function getPanelElement(
id: string,
scope: ParentNode | HTMLElement = document
): HTMLElement | null {
const element = scope.querySelector(`[data-panel-id="${id}"]`);
if (element) {
return element as HTMLElement;
}
return null;
}

View File

@@ -0,0 +1,8 @@
export function getPanelElementsForGroup(
groupId: string,
scope: ParentNode | HTMLElement = document
): HTMLElement[] {
return Array.from(
scope.querySelectorAll(`[data-panel][data-panel-group-id="${groupId}"]`)
);
}

View File

@@ -0,0 +1,21 @@
export function getPanelGroupElement(
id: string,
rootElement: ParentNode | HTMLElement = document
): HTMLElement | null {
//If the root element is the PanelGroup
if (
rootElement instanceof HTMLElement &&
(rootElement as HTMLElement)?.dataset?.panelGroupId == id
) {
return rootElement as HTMLElement;
}
//Else query children
const element = rootElement.querySelector(
`[data-panel-group][data-panel-group-id="${id}"]`
);
if (element) {
return element as HTMLElement;
}
return null;
}

View File

@@ -0,0 +1,10 @@
export function getResizeHandleElement(
id: string,
scope: ParentNode | HTMLElement = document
): HTMLElement | null {
const element = scope.querySelector(`[data-panel-resize-handle-id="${id}"]`);
if (element) {
return element as HTMLElement;
}
return null;
}

View File

@@ -0,0 +1,13 @@
import { getResizeHandleElementsForGroup } from "./getResizeHandleElementsForGroup";
export function getResizeHandleElementIndex(
groupId: string,
id: string,
scope: ParentNode | HTMLElement = document
): number | null {
const handles = getResizeHandleElementsForGroup(groupId, scope);
const index = handles.findIndex(
(handle) => handle.getAttribute("data-panel-resize-handle-id") === id
);
return index ?? null;
}

View File

@@ -0,0 +1,10 @@
export function getResizeHandleElementsForGroup(
groupId: string,
scope: ParentNode | HTMLElement = document
): HTMLElement[] {
return Array.from(
scope.querySelectorAll(
`[data-panel-resize-handle-id][data-panel-group-id="${groupId}"]`
)
);
}

View File

@@ -0,0 +1,19 @@
import { PanelData } from "../../Panel";
import { getResizeHandleElement } from "./getResizeHandleElement";
import { getResizeHandleElementsForGroup } from "./getResizeHandleElementsForGroup";
export function getResizeHandlePanelIds(
groupId: string,
handleId: string,
panelsArray: PanelData[],
scope: ParentNode | HTMLElement = document
): [idBefore: string | null, idAfter: string | null] {
const handle = getResizeHandleElement(handleId, scope);
const handles = getResizeHandleElementsForGroup(groupId, scope);
const index = handle ? handles.indexOf(handle) : -1;
const idBefore: string | null = panelsArray[index]?.id ?? null;
const idAfter: string | null = panelsArray[index + 1]?.id ?? null;
return [idBefore, idAfter];
}

View File

@@ -0,0 +1,23 @@
import { ResizeEvent } from "../../types";
import { isMouseEvent, isPointerEvent } from ".";
export function getResizeEventCoordinates(event: ResizeEvent) {
if (isPointerEvent(event)) {
if (event.isPrimary) {
return {
x: event.clientX,
y: event.clientY,
};
}
} else if (isMouseEvent(event)) {
return {
x: event.clientX,
y: event.clientY,
};
}
return {
x: Infinity,
y: Infinity,
};
}

View File

@@ -0,0 +1,14 @@
import { ResizeEvent } from "../../PanelGroupContext";
import { Direction } from "../../types";
import { getResizeEventCoordinates } from "./getResizeEventCoordinates";
export function getResizeEventCursorPosition(
direction: Direction,
event: ResizeEvent
): number {
const isHorizontal = direction === "horizontal";
const { x, y } = getResizeEventCoordinates(event);
return isHorizontal ? x : y;
}

View File

@@ -0,0 +1,13 @@
import { ResizeEvent } from "../../PanelGroupContext";
export function isKeyDown(event: ResizeEvent): event is KeyboardEvent {
return event.type === "keydown";
}
export function isPointerEvent(event: ResizeEvent): event is PointerEvent {
return event.type.startsWith("pointer");
}
export function isMouseEvent(event: ResizeEvent): event is MouseEvent {
return event.type.startsWith("mouse");
}

View File

@@ -0,0 +1,5 @@
export function getInputType(): "coarse" | "fine" | undefined {
if (typeof matchMedia === "function") {
return matchMedia("(pointer:coarse)").matches ? "coarse" : "fine";
}
}

View File

@@ -0,0 +1,26 @@
import { PanelGroupStorage } from "../PanelGroup";
// PanelGroup might be rendering in a server-side environment where localStorage is not available
// or on a browser with cookies/storage disabled.
// In either case, this function avoids accessing localStorage until needed,
// and avoids throwing user-visible errors.
export function initializeDefaultStorage(storageObject: PanelGroupStorage) {
try {
if (typeof localStorage !== "undefined") {
// Bypass this check for future calls
storageObject.getItem = (name: string) => {
return localStorage.getItem(name);
};
storageObject.setItem = (name: string, value: string) => {
localStorage.setItem(name, value);
};
} else {
throw new Error("localStorage not supported in this environment");
}
} catch (error) {
console.error(error);
storageObject.getItem = () => null;
storageObject.setItem = () => {};
}
}

View File

@@ -0,0 +1,16 @@
import { fuzzyCompareNumbers } from "./fuzzyCompareNumbers";
describe("fuzzyCompareNumbers", () => {
it("should return 0 when numbers are equal", () => {
expect(fuzzyCompareNumbers(10.123, 10.123, 5)).toBe(0);
});
it("should return 0 when numbers are fuzzy equal", () => {
expect(fuzzyCompareNumbers(0.000001, 0.000002, 5)).toBe(0);
});
it("should return a delta when numbers are not unequal", () => {
expect(fuzzyCompareNumbers(0.000001, 0.000002, 6)).toBe(-1);
expect(fuzzyCompareNumbers(0.000005, 0.000002, 6)).toBe(1);
});
});

View File

@@ -0,0 +1,21 @@
import { PRECISION } from "../../constants";
export function fuzzyCompareNumbers(
actual: number,
expected: number,
fractionDigits: number = PRECISION
): number {
if (actual.toFixed(fractionDigits) === expected.toFixed(fractionDigits)) {
return 0;
} else {
return actual > expected ? 1 : -1;
}
}
export function fuzzyNumbersEqual(
actual: number,
expected: number,
fractionDigits: number = PRECISION
): boolean {
return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0;
}

View File

@@ -0,0 +1,22 @@
import { fuzzyNumbersEqual } from "./fuzzyNumbersEqual";
export function fuzzyLayoutsEqual(
actual: number[],
expected: number[],
fractionDigits?: number
): boolean {
if (actual.length !== expected.length) {
return false;
}
for (let index = 0; index < actual.length; index++) {
const actualSize = actual[index] as number;
const expectedSize = expected[index] as number;
if (!fuzzyNumbersEqual(actualSize, expectedSize, fractionDigits)) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,9 @@
import { fuzzyCompareNumbers } from "./fuzzyCompareNumbers";
export function fuzzyNumbersEqual(
actual: number,
expected: number,
fractionDigits?: number
): boolean {
return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0;
}

View File

@@ -0,0 +1,198 @@
import { getIntersectingRectangle } from "./getIntersectingRectangle";
import { Rectangle } from "./types";
const emptyRect = { x: 0, y: 0, width: 0, height: 0 };
const rect = { x: 25, y: 25, width: 50, height: 50 };
function forkRect(partial: Partial<Rectangle>, baseRect: Rectangle = rect) {
return { ...rect, ...partial };
}
describe("getIntersectingRectangle", () => {
let strict: boolean = false;
function verify(rectOne: Rectangle, rectTwo: Rectangle, expected: Rectangle) {
const actual = getIntersectingRectangle(rectOne, rectTwo, strict);
try {
expect(actual).toEqual(expected);
} catch (thrown) {
console.log(
"Expect",
strict ? "strict mode" : "loose mode",
"\n",
rectOne,
"\n",
rectTwo,
"\n\nto intersect as:\n",
expected,
"\n\nbut got:\n",
actual
);
throw thrown;
}
}
describe("loose", () => {
beforeEach(() => {
strict = false;
});
it("should support empty rects", () => {
verify(emptyRect, emptyRect, emptyRect);
});
it("should support fully overlapping rects", () => {
verify(rect, forkRect({ x: 35, width: 30 }), {
x: 35,
y: 25,
width: 30,
height: 50,
});
verify(rect, forkRect({ y: 35, height: 30 }), {
x: 25,
y: 35,
width: 50,
height: 30,
});
verify(
rect,
forkRect({
x: 35,
y: 35,
width: 30,
height: 30,
}),
{
x: 35,
y: 35,
width: 30,
height: 30,
}
);
});
it("should support partially overlapping rects", () => {
verify(rect, forkRect({ x: 10, y: 10 }), {
x: 25,
y: 25,
width: 35,
height: 35,
});
verify(rect, forkRect({ x: 45, y: 30 }), {
x: 45,
y: 30,
width: 30,
height: 45,
});
});
it("should support non-overlapping rects", () => {
verify(rect, forkRect({ x: 100, y: 100 }), emptyRect);
});
it("should support all negative coordinates", () => {
verify(
{
x: -100,
y: -100,
width: 50,
height: 50,
},
{ x: -80, y: -80, width: 50, height: 50 },
{
x: -80,
y: -80,
width: 30,
height: 30,
}
);
});
});
describe("strict", () => {
beforeEach(() => {
strict = true;
});
it("should support empty rects", () => {
verify(emptyRect, emptyRect, emptyRect);
});
it("should support fully overlapping rects", () => {
verify(rect, forkRect({ x: 35, width: 30 }), {
x: 35,
y: 25,
width: 30,
height: 50,
});
verify(rect, forkRect({ y: 35, height: 30 }), {
x: 25,
y: 35,
width: 50,
height: 30,
});
verify(
rect,
forkRect({
x: 35,
y: 35,
width: 30,
height: 30,
}),
{
x: 35,
y: 35,
width: 30,
height: 30,
}
);
});
it("should support partially overlapping rects", () => {
verify(rect, forkRect({ x: 10, y: 10 }), {
x: 25,
y: 25,
width: 35,
height: 35,
});
verify(rect, forkRect({ x: 45, y: 30 }), {
x: 45,
y: 30,
width: 30,
height: 45,
});
});
it("should support non-overlapping rects", () => {
verify(rect, forkRect({ x: 100, y: 100 }), emptyRect);
});
it("should support all negative coordinates", () => {
verify(
{
x: -100,
y: -100,
width: 50,
height: 50,
},
{ x: -80, y: -80, width: 50, height: 50 },
{
x: -80,
y: -80,
width: 30,
height: 30,
}
);
});
});
});

View File

@@ -0,0 +1,28 @@
import { intersects } from "./intersects";
import { Rectangle } from "./types";
export function getIntersectingRectangle(
rectOne: Rectangle,
rectTwo: Rectangle,
strict: boolean
): Rectangle {
if (!intersects(rectOne, rectTwo, strict)) {
return {
x: 0,
y: 0,
width: 0,
height: 0,
};
}
return {
x: Math.max(rectOne.x, rectTwo.x),
y: Math.max(rectOne.y, rectTwo.y),
width:
Math.min(rectOne.x + rectOne.width, rectTwo.x + rectTwo.width) -
Math.max(rectOne.x, rectTwo.x),
height:
Math.min(rectOne.y + rectOne.height, rectTwo.y + rectTwo.height) -
Math.max(rectOne.y, rectTwo.y),
};
}

View File

@@ -0,0 +1,197 @@
import { intersects } from "./intersects";
import { Rectangle } from "./types";
const emptyRect = { x: 0, y: 0, width: 0, height: 0 };
const rect = { x: 25, y: 25, width: 50, height: 50 };
function forkRect(partial: Partial<Rectangle>, baseRect: Rectangle = rect) {
return { ...rect, ...partial };
}
describe("intersects", () => {
let strict: boolean = false;
function verify(rectOne: Rectangle, rectTwo: Rectangle, expected: boolean) {
const actual = intersects(rectOne, rectTwo, strict);
try {
expect(actual).toBe(expected);
} catch (thrown) {
console.log(
"Expected",
rectOne,
"to",
expected ? "intersect" : "not intersect",
rectTwo,
strict ? "in strict mode" : "in loose mode"
);
throw thrown;
}
}
describe("loose", () => {
beforeEach(() => {
strict = false;
});
it("should handle empty rects", () => {
verify(emptyRect, emptyRect, true);
});
it("should support fully overlapping rects", () => {
verify(rect, rect, true);
verify(rect, forkRect({ x: 35, width: 30 }), true);
verify(rect, forkRect({ y: 35, height: 30 }), true);
verify(
rect,
forkRect({
x: 35,
y: 35,
width: 30,
height: 30,
}),
true
);
verify(rect, forkRect({ x: 10, width: 100 }), true);
verify(rect, forkRect({ y: 10, height: 100 }), true);
verify(
rect,
forkRect({
x: 10,
y: 10,
width: 100,
height: 100,
}),
true
);
});
it("should support partially overlapping rects", () => {
const cases: Partial<Rectangle>[] = [
{ x: 0 },
{ y: 0 },
// Loose mode only
{ x: -25 },
{ x: 75 },
{ y: -25 },
{ y: 75 },
{ x: -25, y: -25 },
{ x: 75, y: 75 },
];
cases.forEach((partial) => {
verify(forkRect(partial), rect, true);
});
});
it("should support non-overlapping rects", () => {
const cases: Partial<Rectangle>[] = [
{ x: 100 },
{ x: -100 },
{ y: 100 },
{ y: -100 },
{ x: -100, y: -100 },
{ x: 100, y: 100 },
];
cases.forEach((partial) => {
verify(forkRect(partial), rect, false);
});
});
it("should support all negative coordinates", () => {
expect(
intersects(
{ x: -100, y: -100, width: 50, height: 50 },
{ x: -110, y: -90, width: 50, height: 50 },
false
)
).toBe(true);
});
});
describe("strict", () => {
beforeEach(() => {
strict = true;
});
it("should handle empty rects", () => {
verify(emptyRect, emptyRect, false);
});
it("should support fully overlapping rects", () => {
verify(rect, rect, true);
verify(rect, forkRect({ x: 35, width: 30 }), true);
verify(rect, forkRect({ y: 35, height: 30 }), true);
verify(
rect,
forkRect({
x: 35,
y: 35,
width: 30,
height: 30,
}),
true
);
verify(rect, forkRect({ x: 10, width: 100 }), true);
verify(rect, forkRect({ y: 10, height: 100 }), true);
verify(
rect,
forkRect({
x: 10,
y: 10,
width: 100,
height: 100,
}),
true
);
});
it("should support partially overlapping rects", () => {
const cases: Partial<Rectangle>[] = [{ x: 0 }, { y: 0 }];
cases.forEach((partial) => {
verify(forkRect(partial), rect, true);
});
});
it("should support non-overlapping rects", () => {
const cases: Partial<Rectangle>[] = [
{ x: 100 },
{ x: -100 },
{ y: 100 },
{ y: -100 },
{ x: -100, y: -100 },
{ x: 100, y: 100 },
// Strict mode only
{ x: -25 },
{ x: 75 },
{ y: -25 },
{ y: 75 },
{ x: -25, y: -25 },
{ x: 75, y: 75 },
];
cases.forEach((partial) => {
verify(forkRect(partial), rect, false);
});
});
it("should support all negative coordinates", () => {
expect(
intersects(
{ x: -100, y: -100, width: 50, height: 50 },
{ x: -110, y: -90, width: 50, height: 50 },
true
)
).toBe(true);
});
});
});

View File

@@ -0,0 +1,23 @@
import { Rectangle } from "./types";
export function intersects(
rectOne: Rectangle,
rectTwo: Rectangle,
strict: boolean
): boolean {
if (strict) {
return (
rectOne.x < rectTwo.x + rectTwo.width &&
rectOne.x + rectOne.width > rectTwo.x &&
rectOne.y < rectTwo.y + rectTwo.height &&
rectOne.y + rectOne.height > rectTwo.y
);
} else {
return (
rectOne.x <= rectTwo.x + rectTwo.width &&
rectOne.x + rectOne.width >= rectTwo.x &&
rectOne.y <= rectTwo.y + rectTwo.height &&
rectOne.y + rectOne.height >= rectTwo.y
);
}
}

View File

@@ -0,0 +1,6 @@
export interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}

View File

@@ -0,0 +1,59 @@
import { resizePanel } from "./resizePanel";
describe("resizePanel", () => {
it("should not collapse (or expand) until a panel size dips below the halfway point between min size and collapsed size", () => {
expect(
resizePanel({
panelConstraints: [
{
collapsible: true,
collapsedSize: 10,
minSize: 20,
},
],
panelIndex: 0,
size: 15,
})
).toBe(20);
expect(
resizePanel({
panelConstraints: [
{
collapsible: true,
collapsedSize: 10,
minSize: 20,
},
],
panelIndex: 0,
size: 14,
})
).toBe(10);
expect(
resizePanel({
panelConstraints: [
{
collapsible: true,
minSize: 20,
},
],
panelIndex: 0,
size: 10,
})
).toBe(20);
expect(
resizePanel({
panelConstraints: [
{
collapsible: true,
minSize: 20,
},
],
panelIndex: 0,
size: 9,
})
).toBe(0);
});
});

View File

@@ -0,0 +1,47 @@
import { PanelConstraints } from "../Panel";
import { PRECISION } from "../constants";
import { assert } from "./assert";
import { fuzzyCompareNumbers } from "./numbers/fuzzyCompareNumbers";
// Panel size must be in percentages; pixel values should be pre-converted
export function resizePanel({
panelConstraints: panelConstraintsArray,
panelIndex,
size,
}: {
panelConstraints: PanelConstraints[];
panelIndex: number;
size: number;
}) {
const panelConstraints = panelConstraintsArray[panelIndex];
assert(
panelConstraints != null,
`Panel constraints not found for index ${panelIndex}`
);
let {
collapsedSize = 0,
collapsible,
maxSize = 100,
minSize = 0,
} = panelConstraints;
if (fuzzyCompareNumbers(size, minSize) < 0) {
if (collapsible) {
// Collapsible panels should snap closed or open only once they cross the halfway point between collapsed and min size.
const halfwayPoint = (collapsedSize + minSize) / 2;
if (fuzzyCompareNumbers(size, halfwayPoint) < 0) {
size = collapsedSize;
} else {
size = minSize;
}
} else {
size = minSize;
}
}
size = Math.min(maxSize, size);
size = parseFloat(size.toFixed(PRECISION));
return size;
}

View File

@@ -0,0 +1,87 @@
import { PanelData } from "../Panel";
import { PanelGroupStorage } from "../PanelGroup";
export type PanelConfigurationState = {
expandToSizes: {
[panelId: string]: number;
};
layout: number[];
};
export type SerializedPanelGroupState = {
[panelIds: string]: PanelConfigurationState;
};
function getPanelGroupKey(autoSaveId: string): string {
return `react-resizable-panels:${autoSaveId}`;
}
// Note that Panel ids might be user-provided (stable) or useId generated (non-deterministic)
// so they should not be used as part of the serialization key.
// Using the min/max size attributes should work well enough as a backup.
// Pre-sorting by minSize allows remembering layouts even if panels are re-ordered/dragged.
function getPanelKey(panels: PanelData[]): string {
return panels
.map((panel) => {
const { constraints, id, idIsFromProps, order } = panel;
if (idIsFromProps) {
return id;
} else {
return order
? `${order}:${JSON.stringify(constraints)}`
: JSON.stringify(constraints);
}
})
.sort((a, b) => a.localeCompare(b))
.join(",");
}
function loadSerializedPanelGroupState(
autoSaveId: string,
storage: PanelGroupStorage
): SerializedPanelGroupState | null {
try {
const panelGroupKey = getPanelGroupKey(autoSaveId);
const serialized = storage.getItem(panelGroupKey);
if (serialized) {
const parsed = JSON.parse(serialized);
if (typeof parsed === "object" && parsed != null) {
return parsed as SerializedPanelGroupState;
}
}
} catch (error) {}
return null;
}
export function loadPanelGroupState(
autoSaveId: string,
panels: PanelData[],
storage: PanelGroupStorage
): PanelConfigurationState | null {
const state = loadSerializedPanelGroupState(autoSaveId, storage) ?? {};
const panelKey = getPanelKey(panels);
return state[panelKey] ?? null;
}
export function savePanelGroupState(
autoSaveId: string,
panels: PanelData[],
panelSizesBeforeCollapse: Map<string, number>,
sizes: number[],
storage: PanelGroupStorage
): void {
const panelGroupKey = getPanelGroupKey(autoSaveId);
const panelKey = getPanelKey(panels);
const state = loadSerializedPanelGroupState(autoSaveId, storage) ?? {};
state[panelKey] = {
expandToSizes: Object.fromEntries(panelSizesBeforeCollapse.entries()),
layout: sizes,
};
try {
storage.setItem(panelGroupKey, JSON.stringify(state));
} catch (error) {
console.error(error);
}
}

View File

@@ -0,0 +1,205 @@
import { assert } from "./assert";
const util = require("util");
export function dispatchPointerEvent(type: string, target: HTMLElement) {
const rect = target.getBoundingClientRect();
const clientX = rect.left + rect.width / 2;
const clientY = rect.top + rect.height / 2;
const event = new MouseEvent(type, {
bubbles: true,
clientX,
clientY,
buttons: 1,
});
Object.defineProperties(event, {
pageX: {
get() {
return clientX;
},
},
pageY: {
get() {
return clientY;
},
},
isPrimary: {
value: true,
},
});
target.dispatchEvent(event);
}
export function expectToBeCloseToArray(
actualNumbers: number[],
expectedNumbers: number[]
) {
expect(actualNumbers.length).toBe(expectedNumbers.length);
try {
actualNumbers.forEach((actualNumber, index) => {
const expectedNumber = expectedNumbers[index];
assert(expectedNumber != null, `Expected number not found`);
expect(actualNumber).toBeCloseTo(expectedNumber, 1);
});
} catch (error) {
expect(actualNumbers).toEqual(expectedNumbers);
}
}
export function mockBoundingClientRect(
element: HTMLElement,
rect: {
height: number;
width: number;
x: number;
y: number;
}
) {
const { height, width, x, y } = rect;
Object.defineProperty(element, "getBoundingClientRect", {
configurable: true,
value: () =>
({
bottom: y + height,
height,
left: x,
right: x + width,
toJSON() {
return "";
},
top: y,
width,
x,
y,
}) satisfies DOMRect,
});
}
export function mockPanelGroupOffsetWidthAndHeight(
mockWidth = 1_000,
mockHeight = 1_000
) {
const offsetHeightPropertyDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetHeight"
);
const offsetWidthPropertyDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetWidth"
);
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: function () {
if (this.hasAttribute("data-resize-handle")) {
return 0;
} else if (this.hasAttribute("data-panel-group")) {
return mockHeight;
}
},
});
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: function () {
if (this.hasAttribute("data-resize-handle")) {
return 0;
} else if (this.hasAttribute("data-panel-group")) {
return mockWidth;
}
},
});
return function uninstallMocks() {
if (offsetHeightPropertyDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
"offsetHeight",
offsetHeightPropertyDescriptor
);
}
if (offsetWidthPropertyDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
"offsetWidth",
offsetWidthPropertyDescriptor
);
}
};
}
export function verifyAttribute(
element: HTMLElement,
attributeName: string,
expectedValue: string | null
) {
const actualValue = element.getAttribute(attributeName);
expect(actualValue).toBe(expectedValue);
}
export function verifyExpandedPanelGroupLayout(
actualLayout: number[],
expectedLayout: number[]
) {
expect(actualLayout).toEqual(expectedLayout);
}
export function verifyExpectedWarnings(
callback: Function,
...expectedMessages: string[]
) {
const consoleSpy = (format: any, ...args: any[]) => {
const message = util.format(format, ...args);
for (let index = 0; index < expectedMessages.length; index++) {
const expectedMessage = expectedMessages[index];
if (message.includes(expectedMessage)) {
expectedMessages.splice(index, 1);
return;
}
}
if (expectedMessages.length === 0) {
throw new Error(`Unexpected message recorded:\n\n${message}`);
}
};
const originalError = console.error;
const originalWarn = console.warn;
console.error = consoleSpy;
console.warn = consoleSpy;
let caughtError;
let didCatch = false;
try {
callback();
} catch (error) {
caughtError = error;
didCatch = true;
} finally {
console.error = originalError;
console.warn = originalWarn;
if (didCatch) {
throw caughtError;
}
// Any remaining messages indicate a failed expectations.
if (expectedMessages.length > 0) {
throw Error(
`Expected message(s) not recorded:\n\n${expectedMessages.join("\n")}`
);
}
return { pass: true };
}
}

View File

@@ -0,0 +1,143 @@
import { verifyExpectedWarnings } from "./test-utils";
import { validatePanelConstraints } from "./validatePanelConstraints";
describe("validatePanelConstraints", () => {
it("should not warn if there are no validation errors", () => {
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [{}],
panelIndex: 0,
panelId: "test",
});
});
});
it("should warn about conflicting min/max sizes", () => {
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
maxSize: 5,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "min size (10%) should not be greater than max size (5%)");
});
it("should warn about conflicting collapsed and min sizes", () => {
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
collapsedSize: 15,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "collapsed size should not be greater than min size");
});
it("should warn about conflicting default and min/max sizes", () => {
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
defaultSize: -1,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be less than 0");
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
defaultSize: 5,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be less than min size");
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
collapsedSize: 5,
collapsible: true,
defaultSize: 5,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
});
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
collapsedSize: 7,
collapsible: true,
defaultSize: 5,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be less than min size");
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
collapsedSize: 5,
collapsible: false,
defaultSize: 5,
minSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be less than min size");
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
defaultSize: 101,
maxSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be greater than 100");
verifyExpectedWarnings(() => {
validatePanelConstraints({
panelConstraints: [
{
defaultSize: 15,
maxSize: 10,
},
],
panelIndex: 0,
panelId: "test",
});
}, "default size should not be greater than max size");
});
});

View File

@@ -0,0 +1,69 @@
import { isDevelopment } from "#is-development";
import { PanelConstraints } from "../Panel";
import { assert } from "./assert";
export function validatePanelConstraints({
panelConstraints: panelConstraintsArray,
panelId,
panelIndex,
}: {
panelConstraints: PanelConstraints[];
panelId: string | undefined;
panelIndex: number;
}): boolean {
if (isDevelopment) {
const warnings = [];
const panelConstraints = panelConstraintsArray[panelIndex];
assert(
panelConstraints,
`No panel constraints found for index ${panelIndex}`
);
const {
collapsedSize = 0,
collapsible = false,
defaultSize,
maxSize = 100,
minSize = 0,
} = panelConstraints;
if (minSize > maxSize) {
warnings.push(
`min size (${minSize}%) should not be greater than max size (${maxSize}%)`
);
}
if (defaultSize != null) {
if (defaultSize < 0) {
warnings.push("default size should not be less than 0");
} else if (
defaultSize < minSize &&
(!collapsible || defaultSize !== collapsedSize)
) {
warnings.push("default size should not be less than min size");
}
if (defaultSize > 100) {
warnings.push("default size should not be greater than 100");
} else if (defaultSize > maxSize) {
warnings.push("default size should not be greater than max size");
}
}
if (collapsedSize > minSize) {
warnings.push("collapsed size should not be greater than min size");
}
if (warnings.length > 0) {
const name = panelId != null ? `Panel "${panelId}"` : "Panel";
console.warn(
`${name} has an invalid configuration:\n\n${warnings.join("\n")}`
);
return false;
}
}
return true;
}

View File

@@ -0,0 +1,148 @@
import { verifyExpectedWarnings } from "./test-utils";
import { validatePanelGroupLayout } from "./validatePanelGroupLayout";
describe("validatePanelGroupLayout", () => {
it("should accept requested layout if there are no constraints provided", () => {
expect(
validatePanelGroupLayout({
layout: [10, 60, 30],
panelConstraints: [{}, {}, {}],
})
).toEqual([10, 60, 30]);
});
it("should normalize layouts that do not total 100%", () => {
let layout;
verifyExpectedWarnings(() => {
layout = validatePanelGroupLayout({
layout: [10, 20, 20],
panelConstraints: [{}, {}, {}],
});
}, "Invalid layout total size");
expect(layout).toEqual([20, 40, 40]);
verifyExpectedWarnings(() => {
layout = validatePanelGroupLayout({
layout: [50, 100, 50],
panelConstraints: [{}, {}, {}],
});
}, "Invalid layout total size");
expect(layout).toEqual([25, 50, 25]);
});
it("should reject layouts that do not match the number of panels", () => {
expect(() =>
validatePanelGroupLayout({
layout: [10, 20, 30],
panelConstraints: [{}, {}],
})
).toThrow("Invalid 2 panel layout");
expect(() =>
validatePanelGroupLayout({
layout: [50, 50],
panelConstraints: [{}, {}, {}],
})
).toThrow("Invalid 3 panel layout");
});
describe("minimum size constraints", () => {
it("should adjust the layout to account for minimum percentage sizes", () => {
expect(
validatePanelGroupLayout({
layout: [25, 75],
panelConstraints: [
{
minSize: 35,
},
{},
],
})
).toEqual([35, 65]);
});
it("should account for multiple panels with minimum size constraints", () => {
expect(
validatePanelGroupLayout({
layout: [20, 60, 20],
panelConstraints: [
{
minSize: 25,
},
{},
{
minSize: 25,
},
],
})
).toEqual([25, 50, 25]);
});
});
describe("maximum size constraints", () => {
it("should adjust the layout to account for maximum percentage sizes", () => {
expect(
validatePanelGroupLayout({
layout: [25, 75],
panelConstraints: [{}, { maxSize: 65 }],
})
).toEqual([35, 65]);
});
it("should account for multiple panels with maximum size constraints", () => {
expect(
validatePanelGroupLayout({
layout: [20, 60, 20],
panelConstraints: [
{
maxSize: 15,
},
{ maxSize: 50 },
{},
],
})
).toEqual([15, 50, 35]);
});
});
describe("collapsible panels", () => {
it("should not collapse a panel that's at or above the minimum size", () => {
expect(
validatePanelGroupLayout({
layout: [25, 75],
panelConstraints: [{ collapsible: true, minSize: 25 }, {}],
})
).toEqual([25, 75]);
});
it("should collapse a panel once it drops below the halfway point between collapsed and minimum percentage sizes", () => {
expect(
validatePanelGroupLayout({
layout: [15, 85],
panelConstraints: [
{
collapsible: true,
collapsedSize: 10,
minSize: 20,
},
{},
],
})
).toEqual([20, 80]);
expect(
validatePanelGroupLayout({
layout: [14, 86],
panelConstraints: [
{
collapsible: true,
collapsedSize: 10,
minSize: 20,
},
{},
],
})
).toEqual([10, 90]);
});
});
});

View File

@@ -0,0 +1,95 @@
import { isDevelopment } from "#is-development";
import { PanelConstraints } from "../Panel";
import { assert } from "./assert";
import { fuzzyNumbersEqual } from "./numbers/fuzzyNumbersEqual";
import { resizePanel } from "./resizePanel";
// All units must be in percentages; pixel values should be pre-converted
export function validatePanelGroupLayout({
layout: prevLayout,
panelConstraints,
}: {
layout: number[];
panelConstraints: PanelConstraints[];
}): number[] {
const nextLayout = [...prevLayout];
const nextLayoutTotalSize = nextLayout.reduce(
(accumulated, current) => accumulated + current,
0
);
// Validate layout expectations
if (nextLayout.length !== panelConstraints.length) {
throw Error(
`Invalid ${panelConstraints.length} panel layout: ${nextLayout
.map((size) => `${size}%`)
.join(", ")}`
);
} else if (
!fuzzyNumbersEqual(nextLayoutTotalSize, 100) &&
nextLayout.length > 0
) {
// This is not ideal so we should warn about it, but it may be recoverable in some cases
// (especially if the amount is small)
if (isDevelopment) {
console.warn(
`WARNING: Invalid layout total size: ${nextLayout
.map((size) => `${size}%`)
.join(", ")}. Layout normalization will be applied.`
);
}
for (let index = 0; index < panelConstraints.length; index++) {
const unsafeSize = nextLayout[index];
assert(unsafeSize != null, `No layout data found for index ${index}`);
const safeSize = (100 / nextLayoutTotalSize) * unsafeSize;
nextLayout[index] = safeSize;
}
}
let remainingSize = 0;
// First pass: Validate the proposed layout given each panel's constraints
for (let index = 0; index < panelConstraints.length; index++) {
const unsafeSize = nextLayout[index];
assert(unsafeSize != null, `No layout data found for index ${index}`);
const safeSize = resizePanel({
panelConstraints,
panelIndex: index,
size: unsafeSize,
});
if (unsafeSize != safeSize) {
remainingSize += unsafeSize - safeSize;
nextLayout[index] = safeSize;
}
}
// If there is additional, left over space, assign it to any panel(s) that permits it
// (It's not worth taking multiple additional passes to evenly distribute)
if (!fuzzyNumbersEqual(remainingSize, 0)) {
for (let index = 0; index < panelConstraints.length; index++) {
const prevSize = nextLayout[index];
assert(prevSize != null, `No layout data found for index ${index}`);
const unsafeSize = prevSize + remainingSize;
const safeSize = resizePanel({
panelConstraints,
panelIndex: index,
size: unsafeSize,
});
if (prevSize !== safeSize) {
remainingSize -= safeSize - prevSize;
nextLayout[index] = safeSize;
// Once we've used up the remainder, bail
if (fuzzyNumbersEqual(remainingSize, 0)) {
break;
}
}
}
}
return nextLayout;
}

View File

@@ -0,0 +1,73 @@
// This module exists to work around Webpack issue https://github.com/webpack/webpack/issues/14814
// and limitations with ParcelJS parsing of the useId workaround (used below).
// For the time being, all react-resizable-panels must import "react" with the "* as React" syntax.
// To avoid mistakes, we use the ESLint "no-restricted-imports" to prevent "react" imports except in this file.
// See https://github.com/bvaughn/react-resizable-panels/issues/118
// eslint-disable-next-line no-restricted-imports
import * as React from "react";
// eslint-disable-next-line no-restricted-imports
import type {
CSSProperties,
ElementType,
ForwardedRef,
HTMLAttributes,
MouseEvent,
PropsWithChildren,
ReactElement,
ReactNode,
RefObject,
TouchEvent,
} from "react";
const {
createElement,
createContext,
createRef,
forwardRef,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} = React;
// `Math.random()` and `.slice(0, 5)` prevents bundlers from trying to `import { useId } from 'react'`
const useId = (React as any)[
`useId${Math.random()}`.slice(0, 5)
] as () => string;
const useLayoutEffect_do_not_use_directly = useLayoutEffect;
export {
createElement,
createContext,
createRef,
forwardRef,
useCallback,
useContext,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect_do_not_use_directly,
useMemo,
useRef,
useState,
};
export type {
CSSProperties,
ElementType,
ForwardedRef,
HTMLAttributes,
MouseEvent,
PropsWithChildren,
ReactElement,
ReactNode,
RefObject,
TouchEvent,
};

View File

@@ -0,0 +1,139 @@
// Forked from NPM stacking-order@2.0.0
// Background at https://github.com/Rich-Harris/stacking-order/issues/3
// Background at https://github.com/Rich-Harris/stacking-order/issues/6
import { assert } from "..";
/**
* Determine which of two nodes appears in front of the other —
* if `a` is in front, returns 1, otherwise returns -1
* @param {HTMLElement} a
* @param {HTMLElement} b
*/
export function compare(a: HTMLElement, b: HTMLElement): number {
if (a === b) throw new Error("Cannot compare node with itself");
const ancestors = {
a: get_ancestors(a),
b: get_ancestors(b),
};
let common_ancestor;
// remove shared ancestors
while (ancestors.a.at(-1) === ancestors.b.at(-1)) {
a = ancestors.a.pop() as HTMLElement;
b = ancestors.b.pop() as HTMLElement;
common_ancestor = a;
}
assert(
common_ancestor,
"Stacking order can only be calculated for elements with a common ancestor"
);
const z_indexes = {
a: get_z_index(find_stacking_context(ancestors.a)),
b: get_z_index(find_stacking_context(ancestors.b)),
};
if (z_indexes.a === z_indexes.b) {
const children = common_ancestor.childNodes;
const furthest_ancestors = {
a: ancestors.a.at(-1),
b: ancestors.b.at(-1),
};
let i = children.length;
while (i--) {
const child = children[i];
if (child === furthest_ancestors.a) return 1;
if (child === furthest_ancestors.b) return -1;
}
}
return Math.sign(z_indexes.a - z_indexes.b);
}
const props =
/\b(?:position|zIndex|opacity|transform|webkitTransform|mixBlendMode|filter|webkitFilter|isolation)\b/;
/** @param {HTMLElement} node */
function is_flex_item(node: HTMLElement) {
// @ts-ignore
const display = getComputedStyle(get_parent(node) ?? node).display;
return display === "flex" || display === "inline-flex";
}
/** @param {HTMLElement} node */
function creates_stacking_context(node: HTMLElement) {
const style = getComputedStyle(node);
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context
if (style.position === "fixed") return true;
// Forked to fix upstream bug https://github.com/Rich-Harris/stacking-order/issues/3
// if (
// (style.zIndex !== "auto" && style.position !== "static") ||
// is_flex_item(node)
// )
if (
style.zIndex !== "auto" &&
(style.position !== "static" || is_flex_item(node))
)
return true;
if (+style.opacity < 1) return true;
if ("transform" in style && style.transform !== "none") return true;
if ("webkitTransform" in style && style.webkitTransform !== "none")
return true;
if ("mixBlendMode" in style && style.mixBlendMode !== "normal") return true;
if ("filter" in style && style.filter !== "none") return true;
if ("webkitFilter" in style && style.webkitFilter !== "none") return true;
if ("isolation" in style && style.isolation === "isolate") return true;
if (props.test(style.willChange)) return true;
// @ts-expect-error
if (style.webkitOverflowScrolling === "touch") return true;
return false;
}
/** @param {HTMLElement[]} nodes */
function find_stacking_context(nodes: HTMLElement[]) {
let i = nodes.length;
while (i--) {
const node = nodes[i];
assert(node, "Missing node");
if (creates_stacking_context(node)) return node;
}
return null;
}
/** @param {HTMLElement} node */
function get_z_index(node: HTMLElement | null) {
return (node && Number(getComputedStyle(node).zIndex)) || 0;
}
/** @param {HTMLElement} node */
function get_ancestors(node: HTMLElement | null) {
const ancestors = [];
while (node) {
ancestors.push(node);
// @ts-ignore
node = get_parent(node);
}
return ancestors; // [ node, ... <body>, <html>, document ]
}
/** @param {HTMLElement} node */
function get_parent(node: HTMLElement) {
const { parentNode } = node;
if (parentNode && parentNode instanceof ShadowRoot) {
return parentNode.host
}
return parentNode;
}