From 945cba246ea6da9f6802cf42e6fea0473a3ba187 Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 1 Jul 2026 08:32:12 -0500 Subject: [PATCH 1/2] feat(Splitter): defer drag collapse/expand to release with pending intent Dragging a collapsible panel past minSize previously collapsed it instantly mid-drag, and reopening required accumulating a large fixed delta before the panel popped open. Both feel abrupt. The panel now pins at minSize (or collapsedSize when opening) while dragging and arms a pending intent; collapse/expand commits only on pointer release, and dragging back cancels it. SplitterHandle exposes the armed state via a 'pending' slot prop and data-pending attribute. Keyboard and programmatic resize keep the existing instant behavior. --- .changeset/splitter-collapse-intent.md | 5 + .../app/PlaygroundSplitterHandle.vue | 18 +- .../components/Splitter/SplitterHandle.vue | 15 +- .../src/components/Splitter/SplitterRoot.vue | 117 +++++++++--- .../0/src/components/Splitter/index.test.ts | 178 ++++++++++++++++-- packages/0/src/components/Splitter/index.ts | 2 +- 6 files changed, 295 insertions(+), 40 deletions(-) create mode 100644 .changeset/splitter-collapse-intent.md diff --git a/.changeset/splitter-collapse-intent.md b/.changeset/splitter-collapse-intent.md new file mode 100644 index 000000000..bd9735a8b --- /dev/null +++ b/.changeset/splitter-collapse-intent.md @@ -0,0 +1,5 @@ +--- +"@vuetify/v0": minor +--- + +feat(Splitter): defer drag collapse/expand to pointer release with a pending intent — dragging a collapsible panel past its `minSize` no longer collapses instantly. While dragging, the panel now pins at its `minSize` (or `collapsedSize` when opening a collapsed panel) and arms a pending intent; the collapse/expand only commits on release, and dragging back out cancels it. `SplitterHandle` exposes the armed state through a `pending` slot prop (`'collapse' | 'expand' | null`) and a matching `data-pending` attribute so consumers can render a "release to hide/open" affordance. Keyboard and programmatic resize keep their existing instant behavior. diff --git a/apps/playground/src/components/playground/app/PlaygroundSplitterHandle.vue b/apps/playground/src/components/playground/app/PlaygroundSplitterHandle.vue index d4e293217..6cbd65fc4 100644 --- a/apps/playground/src/components/playground/app/PlaygroundSplitterHandle.vue +++ b/apps/playground/src/components/playground/app/PlaygroundSplitterHandle.vue @@ -53,23 +53,25 @@ diff --git a/packages/0/src/components/Splitter/SplitterHandle.vue b/packages/0/src/components/Splitter/SplitterHandle.vue index 69894cbc6..d61df9bff 100644 --- a/packages/0/src/components/Splitter/SplitterHandle.vue +++ b/packages/0/src/components/Splitter/SplitterHandle.vue @@ -29,7 +29,7 @@ // Types import type { AtomProps } from '#v0/components/Atom' - import type { SplitterOrientation } from './SplitterRoot.vue' + import type { SplitterIntentMode, SplitterOrientation } from './SplitterRoot.vue' export interface SplitterHandleProps extends AtomProps { disabled?: boolean @@ -42,6 +42,7 @@ isDragging: boolean isDisabled: boolean state: SplitterHandleState + pending: SplitterIntentMode | null attrs: { 'role': 'separator' 'tabindex': 0 | -1 @@ -55,6 +56,7 @@ 'data-state': SplitterHandleState 'data-orientation': SplitterOrientation 'data-disabled': true | undefined + 'data-pending': SplitterIntentMode | undefined 'style': Record 'onPointerdown': (e: PointerEvent) => void 'onPointerenter': () => void @@ -103,6 +105,15 @@ return 'inactive' }) + // Armed collapse/expand intent for either panel adjacent to this handle (null until past threshold) + const pending = toRef((): SplitterIntentMode | null => { + const intent = splitter.pending.value + if (!intent) return null + const before = splitter.panel(ticket.index) + const after = splitter.panel(ticket.index + 1) + return intent.id === before?.id || intent.id === after?.id ? intent.mode : null + }) + // aria-valuenow: size of the panel before this handle (0-100), rounded for AT /* v8 ignore next -- defensive: the handle's index always points to a registered panel */ const valuenow = toRef(() => Math.round(splitter.panel(ticket.index)?.size ?? 0)) @@ -260,6 +271,7 @@ isDragging: splitter.draggingHandle.value === ticket.index, isDisabled: isDisabled.value, state: state.value, + pending: pending.value, attrs: { 'role': 'separator', 'tabindex': isDisabled.value ? -1 : 0, @@ -273,6 +285,7 @@ 'data-state': state.value, 'data-orientation': splitter.orientation.value, 'data-disabled': isDisabled.value || undefined, + 'data-pending': pending.value ?? undefined, 'style': { 'touch-action': 'none' }, 'onPointerdown': onPointerDown, 'onPointerenter': onPointerEnter, diff --git a/packages/0/src/components/Splitter/SplitterRoot.vue b/packages/0/src/components/Splitter/SplitterRoot.vue index 1059e8d6d..7730a038a 100644 --- a/packages/0/src/components/Splitter/SplitterRoot.vue +++ b/packages/0/src/components/Splitter/SplitterRoot.vue @@ -34,10 +34,18 @@ import type { AtomExpose, AtomProps } from '#v0/components/Atom' import type { RegistryContext } from '#v0/composables/createRegistry' import type { SelectionContext, SelectionTicket, SelectionTicketInput } from '#v0/composables/createSelection' + import type { ID } from '#v0/types' import type { Ref } from 'vue' export type SplitterOrientation = 'horizontal' | 'vertical' + export type SplitterIntentMode = 'collapse' | 'expand' + + export interface SplitterPendingIntent { + id: ID + mode: SplitterIntentMode + } + export interface SplitterPanelInput extends SelectionTicketInput { size: number minSize: number @@ -56,6 +64,7 @@ handles: RegistryContext dragging: Readonly> draggingHandle: Readonly> + pending: Readonly> rootEl: Readonly> panel: (index: number) => SplitterPanelTicket | undefined resize: (index: number, delta: number, options?: { emit?: boolean }) => void @@ -116,9 +125,28 @@ const rootEl = toRef(() => toElement(rootAtom.value?.element) ?? null) const draggingHandle = shallowRef(null) const dragging = toRef(() => !isNull(draggingHandle.value)) - const expandAccum = new Map() + + // Keyboard / programmatic resize keeps the legacy instant collapse + accumulate-to-expand behavior. + const expandAccum = new Map() const EXPAND_THRESHOLD = 10 + // Pointer drag instead arms an intent at the min/collapsed boundary and only commits on release, + // so the panel resists going smaller and the user can back out before letting go. + const intentAccum = new Map() + const pending = shallowRef(null) + const INTENT_THRESHOLD = 5 + + function arm (id: ID, mode: SplitterIntentMode, delta: number) { + const accum = (intentAccum.get(id) ?? 0) + Math.abs(delta) + intentAccum.set(id, accum) + if (accum >= INTENT_THRESHOLD) pending.value = { id, mode } + } + + function disarm (id: ID) { + intentAccum.delete(id) + if (pending.value?.id === id) pending.value = null + } + const panels = createSelection({ multiple: true, enroll: true, @@ -173,40 +201,68 @@ let size = clamp(before.size + delta, lower, upper) - // Collapse snap: dragging a collapsible panel below minSize snaps to collapsedSize const beforeCollapsed = !toValue(before.isSelected) + const drag = dragging.value + + // Collapse boundary for the leading panel. While dragging, pin at minSize and arm an intent + // instead of collapsing outright; keyboard/programmatic resize keeps the legacy instant snap. if (before.collapsible && !beforeCollapsed && size <= before.minSize && delta < 0) { - size = before.collapsedSize - before.unselect() - expandAccum.set(before.id, 0) - } else if (before.collapsible && beforeCollapsed && delta > 0) { - const accum = (expandAccum.get(before.id) ?? 0) + delta - expandAccum.set(before.id, accum) - if (accum >= EXPAND_THRESHOLD) { - size = clamp(accum, before.collapsedSize, before.maxSize) - before.select() - expandAccum.delete(before.id) + if (drag) { + size = before.minSize + arm(before.id, 'collapse', delta) } else { size = before.collapsedSize + before.unselect() + expandAccum.set(before.id, 0) + } + } else if (before.collapsible && beforeCollapsed && delta > 0) { + // Expand boundary for the leading panel. While dragging, hold at collapsedSize and arm intent. + if (drag) { + size = before.collapsedSize + arm(before.id, 'expand', delta) + } else { + const accum = (expandAccum.get(before.id) ?? 0) + delta + expandAccum.set(before.id, accum) + if (accum >= EXPAND_THRESHOLD) { + size = clamp(accum, before.collapsedSize, before.maxSize) + before.select() + expandAccum.delete(before.id) + } else { + size = before.collapsedSize + } } + } else if (drag) { + disarm(before.id) } const afterSize = total - size const afterCollapsed = !toValue(after.isSelected) if (after.collapsible && !afterCollapsed && afterSize <= after.minSize && delta > 0) { - size = total - after.collapsedSize - after.unselect() - expandAccum.set(after.id, 0) - } else if (after.collapsible && afterCollapsed && delta < 0) { - const accum = (expandAccum.get(after.id) ?? 0) + Math.abs(delta) - expandAccum.set(after.id, accum) - if (accum >= EXPAND_THRESHOLD) { - size = total - clamp(accum, after.collapsedSize, after.maxSize) - after.select() - expandAccum.delete(after.id) + if (drag) { + size = total - after.minSize + arm(after.id, 'collapse', delta) } else { size = total - after.collapsedSize + after.unselect() + expandAccum.set(after.id, 0) + } + } else if (after.collapsible && afterCollapsed && delta < 0) { + if (drag) { + size = total - after.collapsedSize + arm(after.id, 'expand', delta) + } else { + const accum = (expandAccum.get(after.id) ?? 0) + Math.abs(delta) + expandAccum.set(after.id, accum) + if (accum >= EXPAND_THRESHOLD) { + size = total - clamp(accum, after.collapsedSize, after.maxSize) + after.select() + expandAccum.delete(after.id) + } else { + size = total - after.collapsedSize + } } + } else if (drag) { + disarm(after.id) } before.size = size @@ -323,8 +379,24 @@ function onEndDrag () { if (isNull(draggingHandle.value)) return + + const handleIndex = draggingHandle.value + const intent = pending.value draggingHandle.value = null + pending.value = null expandAccum.clear() + intentAccum.clear() + + if (intent) { + const ticket = panels.get(intent.id) + if (ticket) { + const neighborIndex = ticket.index === handleIndex ? handleIndex + 1 : handleIndex + if (intent.mode === 'collapse') collapse(ticket.index, neighborIndex) + else expand(ticket.index, neighborIndex) + return + } + } + emitLayout() } @@ -339,6 +411,7 @@ handles, dragging, draggingHandle, + pending, rootEl, panel, resize, diff --git a/packages/0/src/components/Splitter/index.test.ts b/packages/0/src/components/Splitter/index.test.ts index 987410f33..dfbe5fd01 100644 --- a/packages/0/src/components/Splitter/index.test.ts +++ b/packages/0/src/components/Splitter/index.test.ts @@ -1491,7 +1491,7 @@ describe('splitter', () => { }) describe('drag expand from collapsed', () => { - it('should expand a collapsed panel when dragged past threshold', async () => { + it('should hold a collapsed panel closed during drag and expand on release', async () => { const onLayout = vi.fn() const wrapper = twoPanel({ onLayout, @@ -1527,7 +1527,7 @@ describe('splitter', () => { })) await nextTick() - // Drag past EXPAND_THRESHOLD (10%) — move 120px = 12% + // Drag out 12% — past INTENT_THRESHOLD, arms an expand intent document.dispatchEvent(new PointerEvent('pointermove', { clientX: 120, clientY: 50, @@ -1536,12 +1536,20 @@ describe('splitter', () => { await new Promise(resolve => requestAnimationFrame(resolve)) await nextTick() - // Panel should be expanded and track cursor (12%), not snap to minSize (15%) + // Still collapsed and pinned at collapsedSize while dragging — only intent is armed + expect(panels[0].vm.isCollapsed).toBe(true) + expect(panels[0].vm.size).toBe(0) + expect(handle.attributes('data-pending')).toBe('expand') + + // Release commits the expand + document.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + await nextTick() + expect(panels[0].vm.isCollapsed).toBe(false) - expect(panels[0].vm.size).toBe(12) + expect(panels[0].vm.size).toBeGreaterThan(0) }) - it('should allow panel to stay below minSize during drag', async () => { + it('should not expand when released before the intent threshold', async () => { const wrapper = twoPanel({ panels: [ { defaultSize: 50, collapsible: true, collapsedSize: 0, minSize: 15 }, @@ -1550,7 +1558,6 @@ describe('splitter', () => { }) await nextTick() - // Collapse, then expand via drag just past threshold const panels = wrapper.findAllComponents(SplitterPanel as any) panels[0].vm.collapse() await nextTick() @@ -1571,28 +1578,175 @@ describe('splitter', () => { })) await nextTick() - // Drag to 11% — just past threshold, below minSize (15%) + // Drag out only 3% — below INTENT_THRESHOLD (5%), never arms + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 30, + clientY: 50, + bubbles: true, + })) + await new Promise(resolve => requestAnimationFrame(resolve)) + await nextTick() + + expect(handle.attributes('data-pending')).toBeUndefined() + + document.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + await nextTick() + + // Stays collapsed + expect(panels[0].vm.isCollapsed).toBe(true) + expect(panels[0].vm.size).toBe(0) + }) + }) + + describe('drag collapse intent', () => { + it('should pin at minSize while dragging and collapse on release', async () => { + const wrapper = twoPanel({ + panels: [ + { defaultSize: 50, collapsible: true, collapsedSize: 0, minSize: 15 }, + { defaultSize: 50 }, + ], + }) + await nextTick() + + const panels = wrapper.findAllComponents(SplitterPanel as any) + const handle = wrapper.findComponent(SplitterHandle as any) + const handleEl = handle.element as HTMLElement + handleEl.setPointerCapture = vi.fn() + + const rootEl = wrapper.element as HTMLElement + Object.defineProperty(rootEl, 'offsetWidth', { value: 1000, configurable: true }) + + // Start drag at the 50% boundary + handleEl.dispatchEvent(new PointerEvent('pointerdown', { + button: 0, + clientX: 500, + clientY: 50, + bubbles: true, + pointerId: 1, + })) + await nextTick() + + // Drag well below minSize (toward 5%) + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 50, + clientY: 50, + bubbles: true, + })) + await new Promise(resolve => requestAnimationFrame(resolve)) + await nextTick() + + // Pinned at minSize, still expanded, intent armed + expect(panels[0].vm.isCollapsed).toBe(false) + expect(panels[0].vm.size).toBe(15) + expect(handle.attributes('data-pending')).toBe('collapse') + + // Release commits the collapse + document.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + await nextTick() + + expect(panels[0].vm.isCollapsed).toBe(true) + expect(panels[0].vm.size).toBe(0) + }) + + it('should cancel the collapse when dragged back out before release', async () => { + const wrapper = twoPanel({ + panels: [ + { defaultSize: 50, collapsible: true, collapsedSize: 0, minSize: 15 }, + { defaultSize: 50 }, + ], + }) + await nextTick() + + const panels = wrapper.findAllComponents(SplitterPanel as any) + const handle = wrapper.findComponent(SplitterHandle as any) + const handleEl = handle.element as HTMLElement + handleEl.setPointerCapture = vi.fn() + + const rootEl = wrapper.element as HTMLElement + Object.defineProperty(rootEl, 'offsetWidth', { value: 1000, configurable: true }) + + handleEl.dispatchEvent(new PointerEvent('pointerdown', { + button: 0, + clientX: 500, + clientY: 50, + bubbles: true, + pointerId: 1, + })) + await nextTick() + + // Drag below minSize — arms collapse intent + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 50, + clientY: 50, + bubbles: true, + })) + await new Promise(resolve => requestAnimationFrame(resolve)) + await nextTick() + expect(handle.attributes('data-pending')).toBe('collapse') + + // Drag back out above minSize — disarms document.dispatchEvent(new PointerEvent('pointermove', { - clientX: 110, + clientX: 350, clientY: 50, bubbles: true, })) await new Promise(resolve => requestAnimationFrame(resolve)) await nextTick() + expect(handle.attributes('data-pending')).toBeUndefined() + + document.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + await nextTick() + // Stays expanded — never collapsed expect(panels[0].vm.isCollapsed).toBe(false) - expect(panels[0].vm.size).toBe(11) + expect(panels[0].vm.size).toBeGreaterThanOrEqual(15) + }) + + it('should not collapse when released without arming the intent', async () => { + const wrapper = twoPanel({ + panels: [ + { defaultSize: 16, collapsible: true, collapsedSize: 0, minSize: 15 }, + { defaultSize: 84 }, + ], + }) + await nextTick() + + const panels = wrapper.findAllComponents(SplitterPanel as any) + const handle = wrapper.findComponent(SplitterHandle as any) + const handleEl = handle.element as HTMLElement + handleEl.setPointerCapture = vi.fn() - // Continue drag to 13% — still below minSize, should track cursor + const rootEl = wrapper.element as HTMLElement + Object.defineProperty(rootEl, 'offsetWidth', { value: 1000, configurable: true }) + + // Start at the 16% boundary + handleEl.dispatchEvent(new PointerEvent('pointerdown', { + button: 0, + clientX: 160, + clientY: 50, + bubbles: true, + pointerId: 1, + })) + await nextTick() + + // Nudge ~1% below min — pins at min but accumulates under INTENT_THRESHOLD document.dispatchEvent(new PointerEvent('pointermove', { - clientX: 130, + clientX: 148, clientY: 50, bubbles: true, })) await new Promise(resolve => requestAnimationFrame(resolve)) await nextTick() - expect(panels[0].vm.size).toBe(13) + expect(panels[0].vm.size).toBe(15) + expect(handle.attributes('data-pending')).toBeUndefined() + + document.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + await nextTick() + + // Released without intent — pinned at min, not collapsed + expect(panels[0].vm.isCollapsed).toBe(false) + expect(panels[0].vm.size).toBe(15) }) }) diff --git a/packages/0/src/components/Splitter/index.ts b/packages/0/src/components/Splitter/index.ts index c32025507..96f91631f 100644 --- a/packages/0/src/components/Splitter/index.ts +++ b/packages/0/src/components/Splitter/index.ts @@ -3,7 +3,7 @@ export { default as SplitterRoot } from './SplitterRoot.vue' export { default as SplitterPanel } from './SplitterPanel.vue' export { default as SplitterHandle } from './SplitterHandle.vue' -export type { SplitterContext, SplitterOrientation, SplitterPanelInput, SplitterPanelTicket, SplitterRootExpose, SplitterRootProps, SplitterRootSlotProps } from './SplitterRoot.vue' +export type { SplitterContext, SplitterIntentMode, SplitterOrientation, SplitterPanelInput, SplitterPanelTicket, SplitterPendingIntent, SplitterRootExpose, SplitterRootProps, SplitterRootSlotProps } from './SplitterRoot.vue' export type { SplitterPanelExpose, SplitterPanelProps, SplitterPanelSlotProps } from './SplitterPanel.vue' export type { SplitterHandleProps, SplitterHandleSlotProps, SplitterHandleState } from './SplitterHandle.vue' From b6e73bc11f341e66305400b89d05f6411368b53c Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 3 Jul 2026 09:35:45 -0500 Subject: [PATCH 2/2] test(Splitter): cover trailing-panel drag intents and keyboard leading-panel collapse --- .../0/src/components/Splitter/index.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/packages/0/src/components/Splitter/index.test.ts b/packages/0/src/components/Splitter/index.test.ts index dfbe5fd01..6842e8041 100644 --- a/packages/0/src/components/Splitter/index.test.ts +++ b/packages/0/src/components/Splitter/index.test.ts @@ -1596,6 +1596,61 @@ describe('splitter', () => { expect(panels[0].vm.isCollapsed).toBe(true) expect(panels[0].vm.size).toBe(0) }) + + it('should hold a collapsed trailing panel closed during drag and expand on release', async () => { + const wrapper = twoPanel({ + panels: [ + { defaultSize: 50 }, + { defaultSize: 50, collapsible: true, collapsedSize: 0, minSize: 15 }, + ], + }) + await nextTick() + + // Collapse the trailing panel + const panels = wrapper.findAllComponents(SplitterPanel as any) + panels[1].vm.collapse() + await nextTick() + expect(panels[1].vm.isCollapsed).toBe(true) + expect(panels[1].vm.size).toBe(0) + + const handle = wrapper.findComponent(SplitterHandle as any) + const handleEl = handle.element as HTMLElement + handleEl.setPointerCapture = vi.fn() + + const rootEl = wrapper.element as HTMLElement + Object.defineProperty(rootEl, 'offsetWidth', { value: 1000, configurable: true }) + + // Start drag at the collapsed boundary (100%) + handleEl.dispatchEvent(new PointerEvent('pointerdown', { + button: 0, + clientX: 1000, + clientY: 50, + bubbles: true, + pointerId: 1, + })) + await nextTick() + + // Drag out 12% — past INTENT_THRESHOLD, arms an expand intent for the trailing panel + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 880, + clientY: 50, + bubbles: true, + })) + await new Promise(resolve => requestAnimationFrame(resolve)) + await nextTick() + + // Still collapsed and pinned at collapsedSize while dragging — only intent is armed + expect(panels[1].vm.isCollapsed).toBe(true) + expect(panels[1].vm.size).toBe(0) + expect(handle.attributes('data-pending')).toBe('expand') + + // Release commits the expand + document.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + await nextTick() + + expect(panels[1].vm.isCollapsed).toBe(false) + expect(panels[1].vm.size).toBeGreaterThan(0) + }) }) describe('drag collapse intent', () => { @@ -1748,6 +1803,55 @@ describe('splitter', () => { expect(panels[0].vm.isCollapsed).toBe(false) expect(panels[0].vm.size).toBe(15) }) + + it('should pin the trailing panel at minSize while dragging and collapse on release', async () => { + const wrapper = twoPanel({ + panels: [ + { defaultSize: 50 }, + { defaultSize: 50, collapsible: true, collapsedSize: 0, minSize: 15 }, + ], + }) + await nextTick() + + const panels = wrapper.findAllComponents(SplitterPanel as any) + const handle = wrapper.findComponent(SplitterHandle as any) + const handleEl = handle.element as HTMLElement + handleEl.setPointerCapture = vi.fn() + + const rootEl = wrapper.element as HTMLElement + Object.defineProperty(rootEl, 'offsetWidth', { value: 1000, configurable: true }) + + // Start drag at the 50% boundary + handleEl.dispatchEvent(new PointerEvent('pointerdown', { + button: 0, + clientX: 500, + clientY: 50, + bubbles: true, + pointerId: 1, + })) + await nextTick() + + // Drag well past the trailing panel's minSize (toward 95%) + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 950, + clientY: 50, + bubbles: true, + })) + await new Promise(resolve => requestAnimationFrame(resolve)) + await nextTick() + + // Trailing panel pinned at minSize, still expanded, intent armed + expect(panels[1].vm.isCollapsed).toBe(false) + expect(panels[1].vm.size).toBe(15) + expect(handle.attributes('data-pending')).toBe('collapse') + + // Release commits the collapse + document.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + await nextTick() + + expect(panels[1].vm.isCollapsed).toBe(true) + expect(panels[1].vm.size).toBe(0) + }) }) describe('onEndDrag emits layout', () => { @@ -2009,5 +2113,27 @@ describe('splitter', () => { expect(panels[1]!.vm.isCollapsed).toBe(true) expect(panels[1]!.vm.size).toBe(5) }) + + it('should snap the leading panel to collapsedSize when ArrowLeft drives it below minSize', async () => { + const wrapper = twoPanel({ + panels: [ + // Left panel is collapsible — driving its size below minSize triggers the instant snap + { defaultSize: 50, collapsible: true, collapsedSize: 0, minSize: 20 }, + { defaultSize: 50, minSize: 0, maxSize: 100 }, + ], + }) + await nextTick() + + // ArrowLeft = -1 delta on the leading panel; keyboard resize keeps the instant collapse + const handle = wrapper.findComponent(SplitterHandle as any) + for (let i = 0; i < 35; i++) { + await handle.trigger('keydown', { key: 'ArrowLeft' }) + } + await nextTick() + + const panels = wrapper.findAllComponents(SplitterPanel as any) + expect(panels[0]!.vm.isCollapsed).toBe(true) + expect(panels[0]!.vm.size).toBe(0) + }) }) })