diff --git a/README.md b/README.md index 1d1b7884c..336d61b36 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,13 @@ For most cases, it is recommended to use the onLayoutChanged callba

Called after the Group's layout has been changed.

ℹ️ For layout changes caused by pointer events, this method is not called until the pointer has been released. This method is recommended when saving layouts to some storage api.

+

The second argument is a meta object whose isUserInteraction field is +true when the change was caused by the user directly manipulating a +separator — releasing a pointer drag or pressing a resize key (arrow keys, +Home/End, Enter). It is false for every other source (programmatic +setLayout and other imperative API calls, constraint recompute, +default-size change, initial mount), because the library cannot attribute +the caller's intent there. See #716.

diff --git a/lib/components/group/Group.test.tsx b/lib/components/group/Group.test.tsx index 8a6c37025..729d8e434 100644 --- a/lib/components/group/Group.test.tsx +++ b/lib/components/group/Group.test.tsx @@ -100,7 +100,11 @@ describe("Group", () => { groupRef: RefObject; panelRef: RefObject; }) => Promise, - expectedLayout: Layout + expectedLayout: Layout, + // `true` when the callback simulates a direct separator manipulation — + // a pointer drag or a keyboard resize (see #716); `false` for imperative + // API / mount which fall under the "library-driven" bucket of the flag. + expectedIsUserInteraction: boolean ) { setElementBoundsFunction((element) => { switch (element.id) { @@ -133,15 +137,20 @@ describe("Group", () => { ); expect(onLayoutChanged).toHaveBeenCalledTimes(1); - expect(onLayoutChanged).toHaveBeenLastCalledWith({ - left: 50, - right: 50 - }); + expect(onLayoutChanged).toHaveBeenLastCalledWith( + { + left: 50, + right: 50 + }, + { isUserInteraction: false } + ); await callback({ container, groupRef, panelRef }); expect(onLayoutChanged).toHaveBeenCalledTimes(2); - expect(onLayoutChanged).toHaveBeenLastCalledWith(expectedLayout); + expect(onLayoutChanged).toHaveBeenLastCalledWith(expectedLayout, { + isUserInteraction: expectedIsUserInteraction + }); rerender( @@ -150,7 +159,10 @@ describe("Group", () => { ); expect(onLayoutChanged).toHaveBeenCalledTimes(3); - expect(onLayoutChanged).toHaveBeenLastCalledWith({ right: 100 }); + expect(onLayoutChanged).toHaveBeenLastCalledWith( + { right: 100 }, + { isUserInteraction: false } + ); rerender( @@ -161,7 +173,9 @@ describe("Group", () => { ); expect(onLayoutChanged).toHaveBeenCalledTimes(4); - expect(onLayoutChanged).toHaveBeenLastCalledWith(expectedLayout); + expect(onLayoutChanged).toHaveBeenLastCalledWith(expectedLayout, { + isUserInteraction: false + }); } test("should update when resized via pointer", async () => { @@ -172,7 +186,8 @@ describe("Group", () => { { left: 60, right: 40 - } + }, + true ); }); @@ -185,7 +200,8 @@ describe("Group", () => { { left: 55, right: 45 - } + }, + true ); }); @@ -200,7 +216,8 @@ describe("Group", () => { { left: 75, right: 25 - } + }, + false ); }); @@ -212,7 +229,8 @@ describe("Group", () => { { left: 35, right: 65 - } + }, + false ); }); }); @@ -264,10 +282,13 @@ describe("Group", () => { ); expect(onLayoutChanged).toBeCalledTimes(1); - expect(onLayoutChanged).toHaveBeenLastCalledWith({ - left: 25, - right: 75 - }); + expect(onLayoutChanged).toHaveBeenLastCalledWith( + { + left: 25, + right: 75 + }, + { isUserInteraction: false } + ); expect(groupRef.current?.getLayout()).toEqual({ left: 25, right: 75 @@ -310,10 +331,13 @@ describe("Group", () => { ); expect(onLayoutChanged).toBeCalledTimes(1); - expect(onLayoutChanged).toHaveBeenLastCalledWith({ - left: 25, - right: 75 - }); + expect(onLayoutChanged).toHaveBeenLastCalledWith( + { + left: 25, + right: 75 + }, + { isUserInteraction: false } + ); expect(groupRef.current?.getLayout()).toEqual({ left: 25, right: 75 @@ -328,10 +352,13 @@ describe("Group", () => { }); expect(onLayoutChanged).toBeCalledTimes(2); - expect(onLayoutChanged).toHaveBeenLastCalledWith({ - left: 20, - right: 80 - }); + expect(onLayoutChanged).toHaveBeenLastCalledWith( + { + left: 20, + right: 80 + }, + { isUserInteraction: false } + ); expect(groupRef.current?.getLayout()).toEqual({ left: 20, right: 80 @@ -429,19 +456,25 @@ describe("Group", () => { ); expect(onLayoutChanged).toHaveBeenCalledTimes(1); - expect(onLayoutChanged).toHaveBeenCalledWith({ - left: 60, - right: 40 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + left: 60, + right: 40 + }, + { isUserInteraction: false } + ); // Simulate a drag from the draggable element to the target area await moveSeparator(10); expect(onLayoutChanged).toHaveBeenCalledTimes(2); - expect(onLayoutChanged).toHaveBeenCalledWith({ - left: 70, - right: 30 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + left: 70, + right: 30 + }, + { isUserInteraction: true } + ); }); test("three panel vertical group", async () => { @@ -489,21 +522,27 @@ describe("Group", () => { ); expect(onLayoutChanged).toHaveBeenCalledTimes(1); - expect(onLayoutChanged).toHaveBeenCalledWith({ - bottom: 50, - middle: 30, - top: 20 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + bottom: 50, + middle: 30, + top: 20 + }, + { isUserInteraction: false } + ); // Simulate a drag from the draggable element to the target area await moveSeparator(15, "top-separator"); expect(onLayoutChanged).toHaveBeenCalledTimes(2); - expect(onLayoutChanged).toHaveBeenCalledWith({ - bottom: 50, - middle: 20, - top: 30 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + bottom: 50, + middle: 20, + top: 30 + }, + { isUserInteraction: true } + ); }); }); @@ -706,10 +745,13 @@ describe("Group", () => { }); expect(onLayoutChanged).toHaveBeenCalledTimes(1); - expect(onLayoutChanged).toHaveBeenCalledWith({ - a: 50, - b: 50 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + a: 50, + b: 50 + }, + { isUserInteraction: false } + ); rerender( { }); expect(onLayoutChanged).toHaveBeenCalledTimes(1); - expect(onLayoutChanged).toHaveBeenCalledWith({ - a: 40, - b: 60 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + a: 40, + b: 60 + }, + { isUserInteraction: false } + ); rerender( { }); expect(onLayoutChanged).toHaveBeenCalledTimes(1); - expect(onLayoutChanged).toHaveBeenCalledWith({ - a: 50, - b: 50 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + a: 50, + b: 50 + }, + { isUserInteraction: false } + ); rerender( { }); expect(onLayoutChanged).toHaveBeenCalledTimes(2); - expect(onLayoutChanged).toHaveBeenCalledWith({ - a: 25, - b: 25, - c: 25, - d: 25 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + a: 25, + b: 25, + c: 25, + d: 25 + }, + { isUserInteraction: false } + ); }); test("should be called once per layout change", async () => { @@ -857,10 +908,13 @@ describe("Group", () => { }); expect(onLayoutChanged).toHaveBeenCalledTimes(1); - expect(onLayoutChanged).toHaveBeenCalledWith({ - a: 50, - c: 50 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + a: 50, + c: 50 + }, + { isUserInteraction: false } + ); onLayoutChange.mockReset(); onLayoutChanged.mockReset(); @@ -875,10 +929,13 @@ describe("Group", () => { }); expect(onLayoutChanged).toHaveBeenCalledTimes(1); - expect(onLayoutChanged).toHaveBeenCalledWith({ - a: 75, - c: 25 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + a: 75, + c: 25 + }, + { isUserInteraction: true } + ); onLayoutChange.mockReset(); onLayoutChanged.mockReset(); @@ -919,10 +976,13 @@ describe("Group", () => { }); expect(onLayoutChanged).toHaveBeenCalledTimes(1); - expect(onLayoutChanged).toHaveBeenCalledWith({ - a: 25, - b: 75 - }); + expect(onLayoutChanged).toHaveBeenCalledWith( + { + a: 25, + b: 75 + }, + { isUserInteraction: false } + ); rerender( { - if (layoutsEqual(prevLayoutRef.current.onLayoutChanged, layout)) { - // Memoize callback - return; - } + const onLayoutChangedStable = useStableCallback( + (layout: Layout, isUserInteraction: boolean) => { + if (layoutsEqual(prevLayoutRef.current.onLayoutChanged, layout)) { + // Memoize callback + return; + } - prevLayoutRef.current.onLayoutChanged = layout; - onLayoutChangedUnstable?.(layout); - }); + prevLayoutRef.current.onLayoutChanged = layout; + onLayoutChangedUnstable?.(layout, { isUserInteraction }); + } + ); const id = useId(idProp); @@ -290,7 +292,8 @@ export function Group({ if (!defaultLayoutDeferred && derivedPanelConstraints.length > 0) { onLayoutChangeStable(layout); - onLayoutChangedStable(layout); + // Initial mount is not a user interaction (#716). + onLayoutChangedStable(layout, false); } const removeChangeEventListener = subscribeToMountedGroup(id, (event) => { @@ -336,7 +339,7 @@ export function Group({ const isCompleted = interactionState.state !== "active"; onLayoutChangeStable(layout); if (isCompleted) { - onLayoutChangedStable(layout); + onLayoutChangedStable(layout, event.isUserInteraction); } }); diff --git a/lib/components/group/types.ts b/lib/components/group/types.ts index 3282f9a83..a2b8b25ae 100644 --- a/lib/components/group/types.ts +++ b/lib/components/group/types.ts @@ -17,6 +17,22 @@ export type Layout = { export type LayoutStorage = Pick; +/** + * Metadata describing a completed layout change, passed as the second argument + * to the `onLayoutChanged` callback. See #716. + */ +export type LayoutChangedMeta = { + /** + * `true` when the change was caused by the user directly manipulating a + * separator — releasing a pointer drag or pressing a resize key (arrow keys, + * Home/End, Enter). `false` for every other source (programmatic `setLayout` + * and other imperative API calls, constraint recompute, default-size change, + * initial mount), because the library cannot attribute the caller's intent + * there. + */ + isUserInteraction: boolean; +}; + export type DragState = { state: "default" | "hover" | "dragging"; separatorId: string | undefined; @@ -159,8 +175,16 @@ export type GroupProps = HTMLAttributes & { * * ℹ️ For layout changes caused by pointer events, this method is not called until the pointer has been released. * This method is recommended when saving layouts to some storage api. + * + * The second argument is a `meta` object whose `isUserInteraction` field is + * `true` when the change was caused by the user directly manipulating a + * separator — releasing a pointer drag or pressing a resize key (arrow keys, + * Home/End, Enter). It is `false` for every other source (programmatic + * `setLayout` and other imperative API calls, constraint recompute, + * default-size change, initial mount), because the library cannot attribute + * the caller's intent there. See #716. */ - onLayoutChanged?: (layout: Layout) => void | undefined; + onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void; /** * Minimum size of the resizable hit target area (either `Separator` or `Panel` edge) diff --git a/lib/components/group/useDefaultLayout.test.tsx b/lib/components/group/useDefaultLayout.test.tsx index e51dd9b8d..869a70171 100644 --- a/lib/components/group/useDefaultLayout.test.tsx +++ b/lib/components/group/useDefaultLayout.test.tsx @@ -35,10 +35,13 @@ describe("useDefaultLayout", () => { ); expect(storage.setItem).not.toHaveBeenCalled(); - result.current.onLayoutChanged({ - bar: 35, - baz: 65 - }); + result.current.onLayoutChanged( + { + bar: 35, + baz: 65 + }, + { isUserInteraction: false } + ); expect(storage.setItem).toHaveBeenCalledTimes(1); expect(storage.setItem).toHaveBeenCalledWith( "react-resizable-panels:test-group-id", @@ -68,10 +71,13 @@ describe("useDefaultLayout", () => { expect(result.current.defaultLayout).toMatchInlineSnapshot(`undefined`); expect(storage.setItem).not.toHaveBeenCalled(); - result.current.onLayoutChanged({ - bar: 35, - baz: 65 - }); + result.current.onLayoutChanged( + { + bar: 35, + baz: 65 + }, + { isUserInteraction: false } + ); expect(storage.setItem).toHaveBeenCalledTimes(1); expect(storage.setItem).toHaveBeenCalledWith( @@ -210,10 +216,13 @@ describe("useDefaultLayout", () => { ); expect(storage.setItem).not.toHaveBeenCalled(); - result.current.onLayoutChanged({ - foo: 35, - bar: 65 - }); + result.current.onLayoutChanged( + { + foo: 35, + bar: 65 + }, + { isUserInteraction: false } + ); expect(storage.setItem).toHaveBeenCalledTimes(1); expect(storage.setItem).toHaveBeenCalledWith( "react-resizable-panels:test-group-id:foo:bar", @@ -226,11 +235,14 @@ describe("useDefaultLayout", () => { // This test verifies two things: // 1. Panel layout is saved separately // 2. Panel ids in the Layout are prioritized over those in the prop - result.current.onLayoutChanged({ - foo: 25, - bar: 55, - baz: 20 - }); + result.current.onLayoutChanged( + { + foo: 25, + bar: 55, + baz: 20 + }, + { isUserInteraction: false } + ); expect(storage.setItem).toHaveBeenCalledTimes(2); expect(storage.setItem).toHaveBeenCalledWith( "react-resizable-panels:test-group-id:foo:bar:baz", @@ -666,10 +678,13 @@ describe("useDefaultLayout", () => { bar: 35, baz: 65 }); - result.current.onLayoutChanged({ - bar: 35, - baz: 65 - }); + result.current.onLayoutChanged( + { + bar: 35, + baz: 65 + }, + { isUserInteraction: false } + ); expect(storage.setItem).toHaveBeenCalledTimes(1); expect(storage.setItem).toHaveBeenCalledWith( @@ -915,10 +930,13 @@ describe("useDefaultLayout", () => { expect(storage.getItem).toHaveBeenCalledWith(keyV3); expect(storage.setItem).not.toHaveBeenCalled(); - result.current.onLayoutChanged({ - left: 35, - right: 65 - }); + result.current.onLayoutChanged( + { + left: 35, + right: 65 + }, + { isUserInteraction: false } + ); expect(storage.setItem).toHaveBeenCalledTimes(1); expect(storage.setItem).toHaveBeenCalledWith( diff --git a/lib/components/group/useDefaultLayout.ts b/lib/components/group/useDefaultLayout.ts index f6709a272..d09ee6b37 100644 --- a/lib/components/group/useDefaultLayout.ts +++ b/lib/components/group/useDefaultLayout.ts @@ -119,6 +119,11 @@ export function useDefaultLayout({ }, [clearPendingTimeout]); const onLayoutChanged = useCallback>( + // The hook persists every layout commit -- including library-driven ones -- + // because it owns its own storage and the goal is to remember whatever + // layout the user is currently looking at. Consumers that only want to + // persist on user interaction should branch on `isUserInteraction` in + // their own callback (see #716) rather than via this hook. (layout: Layout) => { clearPendingTimeout(); @@ -144,10 +149,10 @@ export function useDefaultLayout({ clearPendingTimeout(); if (debounceSaveMs === 0) { - onLayoutChanged(layout); + onLayoutChanged(layout, { isUserInteraction: false }); } else { timeoutRef.current = setTimeout(() => { - onLayoutChanged(layout); + onLayoutChanged(layout, { isUserInteraction: false }); }, debounceSaveMs); } }, diff --git a/lib/global/event-handlers/onDocumentPointerMove.ts b/lib/global/event-handlers/onDocumentPointerMove.ts index a03a1d613..71cb257bd 100644 --- a/lib/global/event-handlers/onDocumentPointerMove.ts +++ b/lib/global/event-handlers/onDocumentPointerMove.ts @@ -32,11 +32,15 @@ export function onDocumentPointerMove(event: PointerEvent) { state: "inactive" }); - // Dispatch one more "change" event after the interaction state has been reset - // Groups use this as a signal to call onLayoutChanged + // Dispatch one more "change" event after the interaction state has been reset. + // Groups use this as a signal to call onLayoutChanged. + // This is the missed-pointerup fallback (pointer released outside a + // cross-origin iframe, see #340) — still a real user interaction. interactionState.hitRegions.forEach((hitRegion) => { const groupState = getMountedGroupState(hitRegion.group.id, true); - updateMountedGroup(hitRegion.group, groupState); + updateMountedGroup(hitRegion.group, groupState, { + isUserInteraction: true + }); }); return; diff --git a/lib/global/event-handlers/onDocumentPointerUp.ts b/lib/global/event-handlers/onDocumentPointerUp.ts index 3f019786b..a29f9e272 100644 --- a/lib/global/event-handlers/onDocumentPointerUp.ts +++ b/lib/global/event-handlers/onDocumentPointerUp.ts @@ -27,11 +27,15 @@ export function onDocumentPointerUp(event: PointerEvent) { if (interactionState.hitRegions.length > 0) { updateCursorStyle(event.currentTarget as Document); - // Dispatch one more "change" event after the interaction state has been reset - // Groups use this as a signal to call onLayoutChanged + // Dispatch one more "change" event after the interaction state has been reset. + // Groups use this as a signal to call onLayoutChanged. + // This is the canonical user-pointer-up site, so flag the dispatch with + // isUserInteraction: true. See #716. interactionState.hitRegions.forEach((hitRegion) => { const groupState = getMountedGroupState(hitRegion.group.id, true); - updateMountedGroup(hitRegion.group, groupState); + updateMountedGroup(hitRegion.group, groupState, { + isUserInteraction: true + }); }); event.preventDefault(); diff --git a/lib/global/mutable-state/groups.ts b/lib/global/mutable-state/groups.ts index e6c82bc63..37de938e7 100644 --- a/lib/global/mutable-state/groups.ts +++ b/lib/global/mutable-state/groups.ts @@ -17,6 +17,9 @@ let map: MountedGroups = new Map(); type GroupChangeEvent = { group: RegisteredGroup; + // True if the change was triggered by a pointer or keyboard event handler + // False for other types of resize (constraint recompute, default-size change, imperative API, etc.). + isUserInteraction: boolean; next: State; prev: State | undefined; }; @@ -87,7 +90,11 @@ export function subscribeToMountedGroup( }); } -export function updateMountedGroup(group: RegisteredGroup, next: State) { +export function updateMountedGroup( + group: RegisteredGroup, + next: State, + meta?: { isUserInteraction?: boolean } +) { const prev = map.get(group); map = new Map(map); @@ -95,6 +102,7 @@ export function updateMountedGroup(group: RegisteredGroup, next: State) { eventEmitter.emit("groupChange", { group, + isUserInteraction: meta?.isUserInteraction === true, prev, next }); diff --git a/lib/global/utils/adjustLayoutForSeparator.ts b/lib/global/utils/adjustLayoutForSeparator.ts index 03e2d2650..24a6a2170 100644 --- a/lib/global/utils/adjustLayoutForSeparator.ts +++ b/lib/global/utils/adjustLayoutForSeparator.ts @@ -43,12 +43,20 @@ export function adjustLayoutForSeparator( }); if (!layoutsEqual(prevLayout, nextLayout)) { - updateMountedGroup(group, { - defaultLayoutDeferred: groupState.defaultLayoutDeferred, - derivedPanelConstraints: groupState.derivedPanelConstraints, - groupSize: groupState.groupSize, - layout: nextLayout, - separatorToPanels: groupState.separatorToPanels - }); + updateMountedGroup( + group, + { + defaultLayoutDeferred: groupState.defaultLayoutDeferred, + derivedPanelConstraints: groupState.derivedPanelConstraints, + groupSize: groupState.groupSize, + layout: nextLayout, + separatorToPanels: groupState.separatorToPanels + }, + // Keyboard resizes (arrow keys, Home/End, Enter collapse/expand) originate + // from a real DOM event on the separator, so they are user interactions + // just like pointer drags. This function is only reached from + // onDocumentKeyDown. See #716. + { isUserInteraction: true } + ); } } diff --git a/lib/index.ts b/lib/index.ts index 7ee713e6c..584e4bcdc 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -13,6 +13,7 @@ export type { GroupImperativeHandle, GroupProps, Layout, + LayoutChangedMeta, LayoutStorage, OnGroupLayoutChange, Orientation diff --git a/public/generated/docs/Group.json b/public/generated/docs/Group.json index 6f0c985f0..b2cea2b8c 100644 --- a/public/generated/docs/Group.json +++ b/public/generated/docs/Group.json @@ -149,9 +149,12 @@ { "content": "

For layout changes caused by pointer events, this method is not called until the pointer has been released.\nThis method is recommended when saving layouts to some storage api.

\n", "intent": "primary" + }, + { + "content": "

The second argument is a meta object whose isUserInteraction field is\ntrue when the change was caused by the user directly manipulating a\nseparator — releasing a pointer drag or pressing a resize key (arrow keys,\nHome/End, Enter). It is false for every other source (programmatic\nsetLayout and other imperative API calls, constraint recompute,\ndefault-size change, initial mount), because the library cannot attribute\nthe caller's intent there. See #716.

\n" } ], - "html": "
onLayoutChanged?: (layout: Layout) => void
", + "html": "
onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void
", "name": "onLayoutChanged", "required": false }, diff --git a/public/generated/site-map.json b/public/generated/site-map.json index 343259d56..207fb32ce 100644 --- a/public/generated/site-map.json +++ b/public/generated/site-map.json @@ -42,7 +42,7 @@ { "path": "/examples/persistent-layout/server-rendering", "section": "Examples", - "text": " Because localStorage is unavailable on the server, a custom storage configuration is needed to avoid layout shift when server rendering. Building on the previous example, a Cookie based storage might look like this: import { type LayoutStorage } from \"react-resizable-panels\";\n \nconst cookieStorage: LayoutStorage = {\n getItem(key: string) {\n const cookies = document.cookie.split(\";\");\n for (const cookie of cookies) {\n const [name, value] = cookie.trim().split(\"=\");\n if (name === key) {\n return value;\n }\n }\n return null;\n },\n setItem(key: string, value: string) {\n document.cookie = `${key}=${value}; path=/;`;\n }\n};\n \nuseDefaultLayout({\n id: \"unique-layout-id\",\n storage: cookieStorage\n}); If an async storage API is required, saved layouts should be loaded using suspense. Do not conditionally use localStorage as it will cause errors during hydration. The example above includes path=/ so that saved layouts can be shared between paths; see Using HTTP cookies for more. ", + "text": " Because localStorage is unavailable on the server, a custom storage configuration is needed to avoid layout shift when server rendering. Building on the previous example, a Cookie based storage might look like this: import { type LayoutStorage } from \"react-resizable-panels\";\n \nconst cookieStorage: LayoutStorage = {\n getItem(key: string) {\n const cookies = document.cookie.split(\";\");\n for (const cookie of cookies) {\n const [name, value] = cookie.trim().split(\"=\");\n if (name === key) {\n return value;\n }\n }\n return null;\n },\n setItem(key: string, value: string) {\n document.cookie = `${key}=${value}; path=/;`;\n }\n};\n \nuseDefaultLayout({\n id: \"unique-layout-id\",\n storage: cookieStorage\n}); If async storage is needed, use Suspense to load saved layouts. Do not conditionally use localStorage as it will cause errors during hydration and may leave CSS styles in an invalid state. See issue #714 for more information. The example above includes path=/ so that saved layouts can be shared between paths; see Using HTTP cookies for more. ", "title": "Persistent layouts with server rendering" }, { @@ -96,7 +96,7 @@ { "path": "/props/group", "section": "Props", - "text": " A Group wraps a set of resizable Panel components.\nGroup content can be resized horizontally or vertically.\n Group elements always include the following attributes:\n < div data-group data-testid = \"group-id-prop\" id = \"group-id-prop\" > Test id can be used to narrow selection when unit testing.\n Optional props children?: ReactNode Panel and Separator components that comprise this group.\n className?: string CSS class name.\n defaultLayout?: Layout Default layout for the Group.\n This value allows layouts to be remembered between page reloads.\n Slight layout shift may occur when server-rendering panels with percentage-based default sizes.\nRefer to the documentation for suggestions on how to minimize the impact of this.\n disableCursor?: boolean This library sets custom mouse cursor styles to indicate drag state.\nUse this prop to disable that behavior for Panels and Separators in this group.\n disabled?: boolean Disable resize functionality.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n groupRef?: Ref Exposes the following imperative API:\n\n getLayout(): Layout \n setLayout(layout: Layout): void \n\n The useGroupRef and useGroupCallbackRef hooks are exported for convenience use in TypeScript projects.\n id?: string | number Uniquely identifies this group within an application.\nFalls back to useId when not provided.\n This value will also be assigned to the data-group attribute.\n onLayoutChange?: (layout: Layout) => void Called when the Group's layout is changing.\n For layout changes caused by pointer events, this method is called each time the pointer is moved.\nFor most cases, it is recommended to use the onLayoutChanged callback instead.\n onLayoutChanged?: (layout: Layout) => void Called after the Group's layout has been changed.\n For layout changes caused by pointer events, this method is not called until the pointer has been released.\nThis method is recommended when saving layouts to some storage api.\n orientation?: \"horizontal\" | \"vertical\" = \"horizontal\" Specifies the resizable orientation (\"horizontal\" or \"vertical\"); defaults to \"horizontal\"\n resizeTargetMinimumSize?: { coarse: number; fine: number; } = {\n coarse: 20,\n fine: 10\n } Minimum size of the resizable hit target area (either Separator or Panel edge)\nThis threshold ensures are large enough to avoid mis-clicks.\n \nCoarse inputs (typically a finger on a touchscreen) have reduced accuracy;\nto ensure accessibility and ease of use, hit targets should be larger to prevent mis-clicks.\nFine inputs (typically a mouse) can be smaller\n\n Apple interface guidelines suggest 20pt (27px) on desktops and 28pt (37px) for touch devices\nIn practice this seems to be much larger than many of their own applications use though.\n style?: CSSProperties CSS properties.\n The following styles cannot be overridden: display, flex-direction, flex-wrap, and overflow.\n ", + "text": " A Group wraps a set of resizable Panel components.\nGroup content can be resized horizontally or vertically.\n Group elements always include the following attributes:\n < div data-group data-testid = \"group-id-prop\" id = \"group-id-prop\" > Test id can be used to narrow selection when unit testing.\n Optional props children?: ReactNode Panel and Separator components that comprise this group.\n className?: string CSS class name.\n defaultLayout?: Layout Default layout for the Group.\n This value allows layouts to be remembered between page reloads.\n Slight layout shift may occur when server-rendering panels with percentage-based default sizes.\nRefer to the documentation for suggestions on how to minimize the impact of this.\n disableCursor?: boolean This library sets custom mouse cursor styles to indicate drag state.\nUse this prop to disable that behavior for Panels and Separators in this group.\n disabled?: boolean Disable resize functionality.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n groupRef?: Ref Exposes the following imperative API:\n\n getLayout(): Layout \n setLayout(layout: Layout): void \n\n The useGroupRef and useGroupCallbackRef hooks are exported for convenience use in TypeScript projects.\n id?: string | number Uniquely identifies this group within an application.\nFalls back to useId when not provided.\n This value will also be assigned to the data-group attribute.\n onLayoutChange?: (layout: Layout) => void Called when the Group's layout is changing.\n For layout changes caused by pointer events, this method is called each time the pointer is moved.\nFor most cases, it is recommended to use the onLayoutChanged callback instead.\n onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void Called after the Group's layout has been changed.\n For layout changes caused by pointer events, this method is not called until the pointer has been released.\nThis method is recommended when saving layouts to some storage api.\n The second argument is a meta object whose isUserInteraction field is\ntrue when the change was caused by the user directly manipulating a\nseparator — releasing a pointer drag or pressing a resize key (arrow keys,\nHome/End, Enter). It is false for every other source (programmatic\nsetLayout and other imperative API calls, constraint recompute,\ndefault-size change, initial mount), because the library cannot attribute\nthe caller's intent there. See #716.\n orientation?: \"horizontal\" | \"vertical\" = \"horizontal\" Specifies the resizable orientation (\"horizontal\" or \"vertical\"); defaults to \"horizontal\"\n resizeTargetMinimumSize?: { coarse: number; fine: number; } = {\n coarse: 20,\n fine: 10\n } Minimum size of the resizable hit target area (either Separator or Panel edge)\nThis threshold ensures are large enough to avoid mis-clicks.\n \nCoarse inputs (typically a finger on a touchscreen) have reduced accuracy;\nto ensure accessibility and ease of use, hit targets should be larger to prevent mis-clicks.\nFine inputs (typically a mouse) can be smaller\n\n Apple interface guidelines suggest 20pt (27px) on desktops and 28pt (37px) for touch devices\nIn practice this seems to be much larger than many of their own applications use though.\n style?: CSSProperties CSS properties.\n The following styles cannot be overridden: display, flex-direction, flex-wrap, and overflow.\n ", "title": "Group component props" }, {