From 8298b25d08228c9d2c36cec500d6fc1269c11f41 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 20 Sep 2026 13:16:11 -0400 Subject: [PATCH 1/2] Panels support configurable collapse threshold --- README.md | 15 ++++ lib/components/panel/Panel.test.tsx | 37 ++++++++ lib/components/panel/Panel.tsx | 3 + lib/components/panel/types.ts | 17 ++++ lib/global/dom/calculatePanelConstraints.ts | 12 +++ lib/global/utils/adjustLayoutByDelta.test.ts | 57 ++++++++++++ lib/global/utils/adjustLayoutByDelta.ts | 88 ++++--------------- .../utils/getImperativePanelMethods.test.ts | 31 +++++++ lib/global/utils/validatePanelSize.ts | 16 +++- public/generated/docs/Panel.json | 17 ++++ .../generated/examples/CollapseThreshold.json | 3 + public/generated/site-map.json | 4 +- src/routes/CollapsiblePanelsRoute.tsx | 23 +++++ src/routes/examples/CollapseThreshold.tsx | 15 ++++ 14 files changed, 261 insertions(+), 77 deletions(-) create mode 100644 public/generated/examples/CollapseThreshold.json create mode 100644 src/routes/examples/CollapseThreshold.tsx diff --git a/README.md b/README.md index 0d9c98ab0..7c2c1409d 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,21 @@ Falls back to useId when not provided.

collapsedSize

Panel size when collapsed; defaults to 0%.

+ + + + collapsedThreshold +

Distance a collapsible panel must be resized past its minSize to collapse, +or past its collapsedSize to expand. +Defaults to half the distance between collapsedSize and minSize.

+

For example if a panel declares collapsedSize="5%", collapsedThreshold="5%", and minSize="25%", +it will collapse when resized below 20% and expands when resized above 10%.

+

ℹ️ Interpretation rules:

+ diff --git a/lib/components/panel/Panel.test.tsx b/lib/components/panel/Panel.test.tsx index d18609dfc..18657b885 100644 --- a/lib/components/panel/Panel.test.tsx +++ b/lib/components/panel/Panel.test.tsx @@ -1,6 +1,7 @@ import { act, render } from "@testing-library/react"; import { createRef, Profiler } from "react"; import { describe, expect, test, vi } from "vitest"; +import { calculatePanelConstraints } from "../../global/dom/calculatePanelConstraints"; import { getRegisteredGroup } from "../../global/mutable-state/groups"; import { moveSeparator } from "../../global/test/moveSeparator"; import { assert } from "../../utils/assert"; @@ -14,6 +15,42 @@ import { Separator } from "../separator/Separator"; import { Panel } from "./Panel"; describe("Panel", () => { + test.each(["5%", "5", "10px", 10])( + "collapsedThreshold %s is converted and updated", + (collapsedThreshold) => { + setDefaultElementBounds(new DOMRect(0, 0, 100, 100)); + + const { rerender } = render( + + + + + ); + + expect( + calculatePanelConstraints(getRegisteredGroup("group", true)).find( + ({ panelId }) => panelId === "a" + )?.collapsedThreshold + ).toBe(5); + expect(document.getElementById("a")).not.toHaveAttribute( + "collapsedThreshold" + ); + + rerender( + + + + + ); + + expect( + calculatePanelConstraints(getRegisteredGroup("group", true)).find( + ({ panelId }) => panelId === "a" + )?.collapsedThreshold + ).toBe(3); + } + ); + describe("disabled prop", () => { test("changes to disabled prop should not cause the Panel to remount", () => { const { rerender } = render( diff --git a/lib/components/panel/Panel.tsx b/lib/components/panel/Panel.tsx index af68d477b..279cecaba 100644 --- a/lib/components/panel/Panel.tsx +++ b/lib/components/panel/Panel.tsx @@ -45,6 +45,7 @@ export function Panel({ children, className, collapsedSize = "0%", + collapsedThreshold, collapsible = false, defaultSize, disabled, @@ -105,6 +106,7 @@ export function Panel({ panelConstraints: { groupResizeBehavior, collapsedSize, + collapsedThreshold, collapsible, defaultSize, disabled: stableProps.disabled, @@ -118,6 +120,7 @@ export function Panel({ }, [ groupResizeBehavior, collapsedSize, + collapsedThreshold, collapsible, defaultSize, hasOnResize, diff --git a/lib/components/panel/types.ts b/lib/components/panel/types.ts index 66ceb7787..079f2b688 100644 --- a/lib/components/panel/types.ts +++ b/lib/components/panel/types.ts @@ -15,6 +15,7 @@ export type GroupResizeBehavior = */ export type PanelConstraints = { collapsedSize: number; + collapsedThreshold?: number | undefined; collapsible: boolean; defaultSize: number | undefined; disabled: boolean | undefined; @@ -109,6 +110,21 @@ export type PanelProps = BasePanelAttributes & { */ collapsedSize?: number | string | undefined; + /** + * Distance a collapsible panel must be resized past its `minSize` to collapse, + * or past its `collapsedSize` to expand. + * Defaults to half the distance between `collapsedSize` and `minSize`. + * + * For example if a panel declares `collapsedSize="5%"`, `collapsedThreshold="5%"`, and `minSize="25%"`, + * it will collapse when resized below 20% and expands when resized above 10%. + * + * ℹ️ Interpretation rules: + * - Numbers are interpreted as pixels (e.g. `minSize={200}` is 200 pixels) + * - Strings without explicit units are interpreted as percentage (e.g. `minSize="50"` is 50 percent) + * - Use explicit units (e.g. "px", "%", "em", "rem", "vh", or "vw") to change interpretation + */ + collapsedThreshold?: number | string | undefined; + /** * This panel can be collapsed. * @@ -240,6 +256,7 @@ export type OnPanelResize = PanelProps["onResize"]; export type PanelConstraintProps = Pick< PanelProps, | "collapsedSize" + | "collapsedThreshold" | "collapsible" | "defaultSize" | "disabled" diff --git a/lib/global/dom/calculatePanelConstraints.ts b/lib/global/dom/calculatePanelConstraints.ts index 0803b9c0b..e02497479 100644 --- a/lib/global/dom/calculatePanelConstraints.ts +++ b/lib/global/dom/calculatePanelConstraints.ts @@ -37,6 +37,17 @@ export function calculatePanelConstraints(group: RegisteredGroup) { collapsedSize = formatLayoutNumber((pixels / groupSize) * 100); } + let collapsedThreshold: number | undefined = undefined; + if (panelConstraints.collapsedThreshold !== undefined) { + const pixels = sizeStyleToPixels({ + groupSize, + panelElement: element, + styleProp: panelConstraints.collapsedThreshold + }); + + collapsedThreshold = formatLayoutNumber((pixels / groupSize) * 100); + } + let defaultSize: number | undefined = undefined; if (panelConstraints.defaultSize !== undefined) { const pixels = sizeStyleToPixels({ @@ -73,6 +84,7 @@ export function calculatePanelConstraints(group: RegisteredGroup) { return { groupResizeBehavior: panelConstraints.groupResizeBehavior, collapsedSize, + collapsedThreshold, collapsible: panelConstraints.collapsible === true, defaultSize, disabled: panelConstraints.disabled, diff --git a/lib/global/utils/adjustLayoutByDelta.test.ts b/lib/global/utils/adjustLayoutByDelta.test.ts index 34d501ca0..c1ff4117f 100644 --- a/lib/global/utils/adjustLayoutByDelta.test.ts +++ b/lib/global/utils/adjustLayoutByDelta.test.ts @@ -56,6 +56,63 @@ function l(numbers: number[]) { } describe("adjustLayoutByDelta", () => { + describe("collapsedThreshold", () => { + test.each([0, 1])("panel at index %s", (index) => { + const constraints = c([{}, {}]); + constraints[index] = { + ...constraints[index], + collapsedSize: 5, + collapsedThreshold: 5, + collapsible: true, + minSize: 25 + }; + + function resize( + size: number, + delta: number, + trigger: Args["trigger"] = "mouse-or-touch" + ) { + const sizes = index === 0 ? [size, 100 - size] : [100 - size, size]; + + return Object.values( + adjustLayoutByDelta({ + delta: index === 0 ? delta : -delta, + initialLayout: l(sizes), + panelConstraints: constraints, + prevLayout: l(sizes), + trigger + }) + )[index]; + } + + expect(resize(25, -4)).toBe(25); + expect(resize(25, -5)).toBe(25); + expect(resize(25, -6)).toBe(5); + + expect(resize(5, 4)).toBe(5); + expect(resize(5, 5)).toBe(5); + expect(resize(5, 6)).toBe(25); + + expect(resize(25, -1, "keyboard")).toBe(5); + expect(resize(5, 1, "keyboard")).toBe(25); + + for (const threshold of [20, 30]) { + constraints[index].collapsedThreshold = threshold; + + expect(resize(25, -1, "keyboard")).toBe(5); + expect(resize(5, 1, "keyboard")).toBe(25); + expect(resize(25, -19)).toBe(25); + expect(resize(25, -20)).toBe(5); + expect(resize(25, -21)).toBe(5); + } + + constraints[index].collapsedThreshold = 0; + + expect(resize(25, -1)).toBe(5); + expect(resize(5, 1)).toBe(25); + }); + }); + test("[1++,2]", () => { expect( adjustLayoutByDelta({ diff --git a/lib/global/utils/adjustLayoutByDelta.ts b/lib/global/utils/adjustLayoutByDelta.ts index 6fa260f33..229926dd0 100644 --- a/lib/global/utils/adjustLayoutByDelta.ts +++ b/lib/global/utils/adjustLayoutByDelta.ts @@ -38,15 +38,6 @@ export function adjustLayoutByDelta({ 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 separator should grow/expand by decreasing its offset. @@ -75,8 +66,6 @@ export function adjustLayoutByDelta({ minSize = 0 } = panelConstraints; - // DEBUG.push(`edge case check 1: ${index}`); - // DEBUG.push(` -> collapsible? ${collapsible}`); if (collapsible) { const prevSize = initialLayout[index]; assert( @@ -86,11 +75,9 @@ export function adjustLayoutByDelta({ if (layoutNumbersEqual(prevSize, collapsedSize)) { const localDelta = minSize - prevSize; - // DEBUG.push(` -> expand delta: ${localDelta}`); if (compareLayoutNumbers(localDelta, Math.abs(delta)) > 0) { delta = delta < 0 ? 0 - localDelta : localDelta; - // DEBUG.push(` -> delta: ${delta}`); } } } @@ -111,8 +98,6 @@ export function adjustLayoutByDelta({ minSize = 0 } = panelConstraints; - // DEBUG.push(`edge case check 2: ${index}`); - // DEBUG.push(` -> collapsible? ${collapsible}`); if (collapsible) { const prevSize = initialLayout[index]; assert( @@ -122,11 +107,9 @@ export function adjustLayoutByDelta({ if (layoutNumbersEqual(prevSize, minSize)) { const localDelta = prevSize - collapsedSize; - // DEBUG.push(` -> expand delta: ${localDelta}`); if (compareLayoutNumbers(localDelta, Math.abs(delta)) > 0) { delta = delta < 0 ? 0 - localDelta : localDelta; - // DEBUG.push(` -> delta: ${delta}`); } } } @@ -134,10 +117,9 @@ export function adjustLayoutByDelta({ break; } default: { - // If we're starting from a collapsed state, dragging past the halfway point should cause the panel to expand + // If we're starting from a collapsed state, dragging past the threshold should cause the panel to expand // This can happen for positive or negative drags, and panels on either side of the separator can be collapsible // The easiest way to support this is to detect this scenario and pre-adjust the delta before applying the rest of the layout algorithm - // DEBUG.push(`edge case check 3: collapsible panels`); const index = delta < 0 ? secondPivotIndex : firstPivotIndex; const panelConstraints = panelConstraintsArray[index]; @@ -148,47 +130,28 @@ export function adjustLayoutByDelta({ const prevSize = initialLayout[index]; - const { collapsible, collapsedSize, minSize } = panelConstraints; + const { collapsedSize, collapsedThreshold, collapsible, minSize } = + panelConstraints; if (collapsible && compareLayoutNumbers(prevSize, minSize) < 0) { - // DEBUG.push(` -> collapsible ${delta < 0 ? "2nd" : "1st"} panel`); - if (delta > 0) { - const gapSize = minSize - collapsedSize; - const halfwayDelta = gapSize / 2; - // DEBUG.push(` -> halfway delta: ${halfwayDelta}`); - // DEBUG.push(` collapsed: ${collapsedSize}`); - // DEBUG.push(` min: ${minSize}`); - - const nextSize = prevSize + delta; - if (compareLayoutNumbers(nextSize, minSize) < 0) { - // DEBUG.push(" -> adjusting delta"); - // DEBUG.push(` from: ${delta}`); - delta = - compareLayoutNumbers(delta, halfwayDelta) <= 0 ? 0 : gapSize; - // DEBUG.push(` to: ${delta}`); - } - } else { - const gapSize = minSize - collapsedSize; - const halfwayDelta = 100 - gapSize / 2; - // DEBUG.push(` -> halfway delta: ${halfwayDelta}`); - // DEBUG.push(` collapsed: ${100 - collapsedSize}`); - // DEBUG.push(` min: ${100 - minSize}`); - - const nextSize = prevSize - delta; - if (compareLayoutNumbers(nextSize, minSize) < 0) { - // DEBUG.push(" -> adjusting delta"); - // DEBUG.push(` from: ${delta}`); - delta = - compareLayoutNumbers(100 + delta, halfwayDelta) > 0 - ? 0 - : -gapSize; - // DEBUG.push(` to: ${delta}`); + const gapSize = minSize - collapsedSize; + const threshold = collapsedThreshold ?? gapSize / 2; + const nextSize = prevSize + Math.abs(delta); + + if (compareLayoutNumbers(nextSize, minSize) < 0) { + const comparison = compareLayoutNumbers(Math.abs(delta), threshold); + // Preserve the existing boundary behavior when no threshold is specified. + const expandAtBoundary = + collapsedThreshold === undefined && delta < 0; + if (comparison > 0 || (comparison === 0 && expandAtBoundary)) { + delta = delta < 0 ? -gapSize : gapSize; + } else { + delta = 0; } } } break; } } - // DEBUG.push(""); } { @@ -203,7 +166,6 @@ export function adjustLayoutByDelta({ let index = delta < 0 ? secondPivotIndex : firstPivotIndex; let maxAvailableDelta = 0; - // DEBUG.push("pre calc..."); while (true) { const prevSize = initialLayout[index]; assert( @@ -218,7 +180,6 @@ export function adjustLayoutByDelta({ size: 100 }); const delta = maxSafeSize - prevSize; - // DEBUG.push(` ${index}: ${prevSize} -> ${maxSafeSize}`); maxAvailableDelta += delta; index += increment; @@ -228,11 +189,8 @@ export function adjustLayoutByDelta({ } } - // 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(""); } { @@ -280,16 +238,10 @@ export function adjustLayoutByDelta({ } } } - // 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 (isArrayEqual(prevLayout, nextLayout)) { - // DEBUG.push(`bailout to previous layout: ${prevLayout.join(", ")}`); - // console.log(DEBUG.join("\n")); - return prevLayoutProp; } @@ -353,29 +305,21 @@ export function adjustLayoutByDelta({ } } } - // DEBUG.push(`after 2: ${nextLayout.join(", ")}`); - // DEBUG.push(` deltaApplied: ${deltaApplied}`); - // DEBUG.push(""); const totalSize = Object.values(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 // Allow for a small rounding difference, else e.g. 3 panel layouts may never be considered valid if (!layoutNumbersEqual(totalSize, 100, 0.1)) { - // DEBUG.push(`bailout to previous layout: ${prevLayout.join(", ")}`); - // console.log(DEBUG.join("\n")); - return prevLayoutProp; } const prevLayoutKeys = Object.keys(prevLayoutProp); - // console.log(DEBUG.join("\n")); return nextLayout.reduce((accumulated, current, index) => { accumulated[prevLayoutKeys[index]] = current; return accumulated; diff --git a/lib/global/utils/getImperativePanelMethods.test.ts b/lib/global/utils/getImperativePanelMethods.test.ts index f14837753..7db8b46c4 100644 --- a/lib/global/utils/getImperativePanelMethods.test.ts +++ b/lib/global/utils/getImperativePanelMethods.test.ts @@ -68,6 +68,7 @@ describe("getImperativePanelMethods", () => { panelConstraints.forEach( ({ collapsedSize, + collapsedThreshold, collapsible, defaultSize, disabled, @@ -87,6 +88,10 @@ describe("getImperativePanelMethods", () => { { collapsedSize: collapsedSize !== undefined ? `${collapsedSize}%` : 0, + collapsedThreshold: + collapsedThreshold !== undefined + ? `${collapsedThreshold}%` + : undefined, collapsible, defaultSize: defaultSize !== undefined ? `${defaultSize}%` : undefined, @@ -130,6 +135,32 @@ describe("getImperativePanelMethods", () => { }); describe("collapse", () => { + test.each([20, 30])( + "collapses and restores a panel with threshold %s", + (collapsedThreshold) => { + const { panelApis } = init([ + { + collapsedSize: 5, + collapsedThreshold, + collapsible: true, + defaultSize: 25, + minSize: 25 + }, + {} + ]); + + panelApis[0].collapse(); + + expect(panelApis[0].isCollapsed()).toBe(true); + expect(onLayoutChange).toHaveBeenLastCalledWith([5, 95]); + + panelApis[0].expand(); + + expect(panelApis[0].isCollapsed()).toBe(false); + expect(onLayoutChange).toHaveBeenLastCalledWith([25, 75]); + } + ); + test("does nothing if panel is not collapsible", () => { const { panelApis } = init([{}, {}]); panelApis[0].collapse(); diff --git a/lib/global/utils/validatePanelSize.ts b/lib/global/utils/validatePanelSize.ts index eb32fbc11..bf539c170 100644 --- a/lib/global/utils/validatePanelSize.ts +++ b/lib/global/utils/validatePanelSize.ts @@ -16,6 +16,7 @@ export function validatePanelSize({ }) { const { collapsedSize = 0, + collapsedThreshold, collapsible, disabled, maxSize = 100, @@ -28,9 +29,18 @@ export function validatePanelSize({ if (compareLayoutNumbers(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 (compareLayoutNumbers(size, halfwayPoint) < 0) { + const threshold = collapsedThreshold ?? (minSize - collapsedSize) / 2; + const wasCollapsed = compareLayoutNumbers(prevSize, collapsedSize) <= 0; + const boundary = wasCollapsed + ? collapsedSize + threshold + : minSize - threshold; + const comparison = compareLayoutNumbers(size, boundary); + + if ( + compareLayoutNumbers(size, collapsedSize) <= 0 || + comparison < 0 || + (collapsedThreshold !== undefined && wasCollapsed && comparison === 0) + ) { size = collapsedSize; } else { size = minSize; diff --git a/public/generated/docs/Panel.json b/public/generated/docs/Panel.json index 9c0f25500..8f1ddf514 100644 --- a/public/generated/docs/Panel.json +++ b/public/generated/docs/Panel.json @@ -84,6 +84,23 @@ "name": "collapsedSize", "required": false }, + "collapsedThreshold": { + "description": [ + { + "content": "

Distance a collapsible panel must be resized past its minSize to collapse,\nor past its collapsedSize to expand.\nDefaults to half the distance between collapsedSize and minSize.

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

For example if a panel declares collapsedSize="5%", collapsedThreshold="5%", and minSize="25%",\nit will collapse when resized below 20% and expands when resized above 10%.

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

Interpretation rules:

\n\n", + "intent": "primary" + } + ], + "html": "
collapsedThreshold?: string | number
", + "name": "collapsedThreshold", + "required": false + }, "collapsible": { "description": [ { diff --git a/public/generated/examples/CollapseThreshold.json b/public/generated/examples/CollapseThreshold.json new file mode 100644 index 000000000..f3005038a --- /dev/null +++ b/public/generated/examples/CollapseThreshold.json @@ -0,0 +1,3 @@ +{ + "html": "
<Group>
\n
<Panel
\n
collapsedSize=\"5%\"
\n
collapsible
\n
collapsedThreshold=\"5%\"
\n
minSize=\"35%\"
\n
/>
\n
<Separator />
\n
<Panel />
\n
</Group>
" +} \ No newline at end of file diff --git a/public/generated/site-map.json b/public/generated/site-map.json index 28285d93d..4ff90c53f 100644 --- a/public/generated/site-map.json +++ b/public/generated/site-map.json @@ -24,7 +24,7 @@ { "path": "/examples/collapsible-panels", "section": "Examples", - "text": " Panels can be configured to be collapsible using the collapsible and minSize properties. \n \n \n \n Although it isn't required, it's recommended that you also render a Separator for panels that can be collapsed fully. This separator gives users something to click to re-open a panel after it's been collapsed. The collapsedSize property can also be provided to prevent a panel from disappearing fully when collapsed. \n \n \n \n This enables building UIs like VS Code's \"Folders\" side panel. A panel's collapse threshold is half its minimum size. Collapsible panels can also be collapsed by default by setting their defaultSize to 0 (pixels or percent). \n \n \n \n ", + "text": " Panels can be configured to be collapsible using the collapsible and minSize properties. \n \n \n \n Although it isn't required, it's recommended that you also render a Separator for panels that can be collapsed fully. This separator gives users something to click to re-open a panel after it's been collapsed. The collapsedSize property can also be provided to prevent a panel from disappearing fully when collapsed. \n \n \n \n This enables building UIs like VS Code's \"Folders\" side panel. A panel's collapse threshold is half its minimum size. Collapsible panels can also be collapsed by default by setting their defaultSize to 0 (pixels or percent). \n \n \n \n By default, panels will collapse (or expand) when resized beyond the midpoint of their collapsed and minimum sizes. (For example, a panel with a collapsed size of 0 and a minimum size of 20% will collapse when resized below 10%.) The collapsedThreshold prop can be used to customize this behavior. TODO ", "title": "Collapsible panels" }, { @@ -108,7 +108,7 @@ { "path": "/props/panel", "section": "Props", - "text": " A Panel wraps resizable content and can be configured with min/max size constraints and collapsible behavior.\n Panel size props can be in the following formats:\n\nPercentage of the parent Group (0..100)\nPixels\nRelative font units (em, rem)\nViewport relative units (vh, vw)\n\n Numeric values are assumed to be pixels.\nStrings without explicit units are assumed to be percentages (0%..100%).\nPercentages may also be specified as strings ending with \"%\" (e.g. \"33%\")\nPixels may also be specified as strings ending with the unit \"px\".\nOther units should be specified as strings ending with their CSS property units (e.g. 1rem, 50vh)\n Panel elements always include the following attributes:\n < div data-panel data-testid = \"panel-id-prop\" id = \"panel-id-prop\" > Test id can be used to narrow selection when unit testing.\n Panel elements must be direct DOM children of their parent Group elements.\n Optional props className?: string CSS class name.\n Class is applied to nested HTMLDivElement to avoid styles that interfere with Flex layout.\n collapsedSize?: string | number = \"0%\" Panel size when collapsed; defaults to 0%.\n collapsible?: boolean = false This panel can be collapsed.\n A collapsible panel will collapse when it's size is less than of the specified minSize \n defaultSize?: string | number Default size of Panel within its parent group; default is auto-assigned based on the total number of Panels.\n Interpretation rules:\n\nNumbers are interpreted as pixels (e.g. defaultSize={200} is 200 pixels)\nStrings without explicit units are interpreted as percentage (e.g. defaultSize=\"50\" is 50 percent)\nUse explicit units (e.g. \"px\", \"%\", \"em\", \"rem\", \"vh\", or \"vw\") to change interpretation\n\n Percentage based sizes may cause slight layout shift when server-rendering.\nFor more information see the documentation.\n disabled?: boolean When disabled, a panel cannot be resized either directly or indirectly (by resizing another panel).\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n groupResizeBehavior?: \"preserve-relative-size\" | \"preserve-pixel-size\" = \"preserve-relative-size\" How should this Panel behave if the parent Group is resized?\nDefaults to preserve-relative-size.\n \n preserve-relative-size: Retain the current relative size (as a percentage of the Group)\n preserve-pixel-size: Retain its current size (in pixels)\n\n Panel min/max size constraints may impact this behavior.\n A Group must contain at least one Panel with preserve-relative-size resize behavior.\n id?: string | number Uniquely identifies this panel within the parent group.\nFalls back to useId when not provided.\n This prop is used to associate persisted group layouts with the original panel.\n This value will also be assigned to the data-panel attribute.\n maxSize?: string | number = \"100%\" Maximum size of Panel within its parent group; defaults to \"100%\".\n Interpretation rules:\n\nNumbers are interpreted as pixels (e.g. maxSize={200} is 200 pixels)\nStrings without explicit units are interpreted as percentage (e.g. maxSize=\"50\" is 50 percent)\nUse explicit units (e.g. \"px\", \"%\", \"em\", \"rem\", \"vh\", or \"vw\") to change interpretation\n\n minSize?: string | number = \"0%\" Minimum size of Panel within its parent group; defaults to 0%.\n Interpretation rules:\n\nNumbers are interpreted as pixels (e.g. minSize={200} is 200 pixels)\nStrings without explicit units are interpreted as percentage (e.g. minSize=\"50\" is 50 percent)\nUse explicit units (e.g. \"px\", \"%\", \"em\", \"rem\", \"vh\", or \"vw\") to change interpretation\n\n onResize?: ((panelSize: PanelSize, id: string | number, prevPanelSize: PanelSize | undefined) => void) | undefined Called when panel sizes change.\n\n panelSize Panel size (both as a percentage of the parent Group and in pixels)\n id Panel id (if one was provided as a prop)\n prevPanelSize Previous panel size (will be undefined on mount)\n\n panelRef?: Ref Exposes the following imperative API:\n\n collapse(): void \n expand(): void \n getSize(): number \n isCollapsed(): boolean \n resize(size: number): void \n\n The usePanelRef and usePanelCallbackRef hooks are exported for convenience use in TypeScript projects.\n style?: CSSProperties CSS properties.\n The default inline styles cannot be overridden, except for overflow .\n ", + "text": " A Panel wraps resizable content and can be configured with min/max size constraints and collapsible behavior.\n Panel size props can be in the following formats:\n\nPercentage of the parent Group (0..100)\nPixels\nRelative font units (em, rem)\nViewport relative units (vh, vw)\n\n Numeric values are assumed to be pixels.\nStrings without explicit units are assumed to be percentages (0%..100%).\nPercentages may also be specified as strings ending with \"%\" (e.g. \"33%\")\nPixels may also be specified as strings ending with the unit \"px\".\nOther units should be specified as strings ending with their CSS property units (e.g. 1rem, 50vh)\n Panel elements always include the following attributes:\n < div data-panel data-testid = \"panel-id-prop\" id = \"panel-id-prop\" > Test id can be used to narrow selection when unit testing.\n Panel elements must be direct DOM children of their parent Group elements.\n Optional props className?: string CSS class name.\n Class is applied to nested HTMLDivElement to avoid styles that interfere with Flex layout.\n collapsedSize?: string | number = \"0%\" Panel size when collapsed; defaults to 0%.\n collapsedThreshold?: string | number Distance a collapsible panel must be resized past its minSize to collapse,\nor past its collapsedSize to expand.\nDefaults to half the distance between collapsedSize and minSize.\n For example if a panel declares collapsedSize=\"5%\", collapsedThreshold=\"5%\", and minSize=\"25%\",\nit will collapse when resized below 20% and expands when resized above 10%.\n Interpretation rules:\n\nNumbers are interpreted as pixels (e.g. minSize={200} is 200 pixels)\nStrings without explicit units are interpreted as percentage (e.g. minSize=\"50\" is 50 percent)\nUse explicit units (e.g. \"px\", \"%\", \"em\", \"rem\", \"vh\", or \"vw\") to change interpretation\n\n collapsible?: boolean = false This panel can be collapsed.\n A collapsible panel will collapse when it's size is less than of the specified minSize \n defaultSize?: string | number Default size of Panel within its parent group; default is auto-assigned based on the total number of Panels.\n Interpretation rules:\n\nNumbers are interpreted as pixels (e.g. defaultSize={200} is 200 pixels)\nStrings without explicit units are interpreted as percentage (e.g. defaultSize=\"50\" is 50 percent)\nUse explicit units (e.g. \"px\", \"%\", \"em\", \"rem\", \"vh\", or \"vw\") to change interpretation\n\n Percentage based sizes may cause slight layout shift when server-rendering.\nFor more information see the documentation.\n disabled?: boolean When disabled, a panel cannot be resized either directly or indirectly (by resizing another panel).\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n groupResizeBehavior?: \"preserve-relative-size\" | \"preserve-pixel-size\" = \"preserve-relative-size\" How should this Panel behave if the parent Group is resized?\nDefaults to preserve-relative-size.\n \n preserve-relative-size: Retain the current relative size (as a percentage of the Group)\n preserve-pixel-size: Retain its current size (in pixels)\n\n Panel min/max size constraints may impact this behavior.\n A Group must contain at least one Panel with preserve-relative-size resize behavior.\n id?: string | number Uniquely identifies this panel within the parent group.\nFalls back to useId when not provided.\n This prop is used to associate persisted group layouts with the original panel.\n This value will also be assigned to the data-panel attribute.\n maxSize?: string | number = \"100%\" Maximum size of Panel within its parent group; defaults to \"100%\".\n Interpretation rules:\n\nNumbers are interpreted as pixels (e.g. maxSize={200} is 200 pixels)\nStrings without explicit units are interpreted as percentage (e.g. maxSize=\"50\" is 50 percent)\nUse explicit units (e.g. \"px\", \"%\", \"em\", \"rem\", \"vh\", or \"vw\") to change interpretation\n\n minSize?: string | number = \"0%\" Minimum size of Panel within its parent group; defaults to 0%.\n Interpretation rules:\n\nNumbers are interpreted as pixels (e.g. minSize={200} is 200 pixels)\nStrings without explicit units are interpreted as percentage (e.g. minSize=\"50\" is 50 percent)\nUse explicit units (e.g. \"px\", \"%\", \"em\", \"rem\", \"vh\", or \"vw\") to change interpretation\n\n onResize?: ((panelSize: PanelSize, id: string | number, prevPanelSize: PanelSize | undefined) => void) | undefined Called when panel sizes change.\n\n panelSize Panel size (both as a percentage of the parent Group and in pixels)\n id Panel id (if one was provided as a prop)\n prevPanelSize Previous panel size (will be undefined on mount)\n\n panelRef?: Ref Exposes the following imperative API:\n\n collapse(): void \n expand(): void \n getSize(): number \n isCollapsed(): boolean \n resize(size: number): void \n\n The usePanelRef and usePanelCallbackRef hooks are exported for convenience use in TypeScript projects.\n style?: CSSProperties CSS properties.\n The default inline styles cannot be overridden, except for overflow .\n ", "title": "Panel component props" }, { diff --git a/src/routes/CollapsiblePanelsRoute.tsx b/src/routes/CollapsiblePanelsRoute.tsx index ca3437aca..4dbea7e47 100644 --- a/src/routes/CollapsiblePanelsRoute.tsx +++ b/src/routes/CollapsiblePanelsRoute.tsx @@ -2,6 +2,7 @@ import { Box, Callout, Code, Header } from "react-lib-tools"; import { html as ExampleHTML } from "../../public/generated/examples/CollapsiblePanels.json"; import { html as ExampleCollapsedByDefaultHTML } from "../../public/generated/examples/CollapsiblePanelsCollapsedByDefault.json"; import { html as ExampleWithCollapsedSizeHTML } from "../../public/generated/examples/CollapsiblePanelsCollapsedSize.json"; +import { html as ExampleCollapseThresholdHTML } from "../../public/generated/examples/CollapseThreshold.json"; import { Group } from "../components/styled-panels/Group"; import { Panel } from "../components/styled-panels/Panel"; import { Separator } from "../components/styled-panels/Separator"; @@ -68,6 +69,28 @@ export default function CollapsiblePanelsRoute() { dragging the separator. +
+ By default, panels will collapse (or expand) when resized beyond the + midpoint of their collapsed and minimum sizes. (For example, a panel + with a collapsed size of 0 and a minimum size of 20% will collapse when + resized below 10%.) The collapsedThreshold prop can be used + to customize this behavior. +
+ + + + + + The panel on the left will collapse when resized below 30% and expand + when resized above 10% + + ); } diff --git a/src/routes/examples/CollapseThreshold.tsx b/src/routes/examples/CollapseThreshold.tsx new file mode 100644 index 000000000..0ec9839d8 --- /dev/null +++ b/src/routes/examples/CollapseThreshold.tsx @@ -0,0 +1,15 @@ +import { Group, Panel, Separator } from "react-resizable-panels"; + +// + +/* prettier-ignore */ + + + + + From 11ab4915c0a69aa6029ef96d56dee461f5c5341a Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 20 Sep 2026 13:17:56 -0400 Subject: [PATCH 2/2] Update sitemap --- public/generated/site-map.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/generated/site-map.json b/public/generated/site-map.json index 4ff90c53f..4805a5d3d 100644 --- a/public/generated/site-map.json +++ b/public/generated/site-map.json @@ -24,7 +24,7 @@ { "path": "/examples/collapsible-panels", "section": "Examples", - "text": " Panels can be configured to be collapsible using the collapsible and minSize properties. \n \n \n \n Although it isn't required, it's recommended that you also render a Separator for panels that can be collapsed fully. This separator gives users something to click to re-open a panel after it's been collapsed. The collapsedSize property can also be provided to prevent a panel from disappearing fully when collapsed. \n \n \n \n This enables building UIs like VS Code's \"Folders\" side panel. A panel's collapse threshold is half its minimum size. Collapsible panels can also be collapsed by default by setting their defaultSize to 0 (pixels or percent). \n \n \n \n By default, panels will collapse (or expand) when resized beyond the midpoint of their collapsed and minimum sizes. (For example, a panel with a collapsed size of 0 and a minimum size of 20% will collapse when resized below 10%.) The collapsedThreshold prop can be used to customize this behavior. TODO ", + "text": " Panels can be configured to be collapsible using the collapsible and minSize properties. \n \n \n \n Although it isn't required, it's recommended that you also render a Separator for panels that can be collapsed fully. This separator gives users something to click to re-open a panel after it's been collapsed. The collapsedSize property can also be provided to prevent a panel from disappearing fully when collapsed. \n \n \n \n This enables building UIs like VS Code's \"Folders\" side panel. A panel's collapse threshold is half its minimum size. Collapsible panels can also be collapsed by default by setting their defaultSize to 0 (pixels or percent). \n \n \n \n By default, panels will collapse (or expand) when resized beyond the midpoint of their collapsed and minimum sizes. (For example, a panel with a collapsed size of 0 and a minimum size of 20% will collapse when resized below 10%.) The collapsedThreshold prop can be used to customize this behavior. \n \n \n \n ", "title": "Collapsible panels" }, {