Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/splitter-collapse-intent.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -53,30 +53,40 @@
</script>

<template>
<SplitterHandle v-slot="{ state, attrs }" renderless>
<SplitterHandle v-slot="{ state, pending, attrs }" renderless>
<div
v-bind="attrs"
class="hidden md:block bg-divider relative hover:bg-primary transition-colors shrink-0"
:class="[
state !== 'drag' && (direction === 'horizontal' ? 'cursor-col-resize' : 'cursor-row-resize'),
direction === 'horizontal' && 'w-[4px]',
direction === 'vertical' && 'h-[4px]',
direction === 'horizontal' && (pending ? 'w-[6px]' : 'w-[4px]'),
direction === 'vertical' && (pending ? 'h-[6px]' : 'h-[4px]'),
pending && '!bg-primary',
]"
:style="hidden ? { display: 'none' } : undefined"
@dblclick="onDblclick(attrs)"
>
<span
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-1 rounded inline-flex items-center justify-center bg-surface text-on-surface border border-divider"
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-1 rounded inline-flex items-center justify-center bg-surface border transition-colors"
:class="[
direction === 'horizontal' ? 'w-4 h-6' : 'w-6 h-4',
state === 'drag' ? 'cursor-grabbing' : 'cursor-grab',
pending ? 'border-primary text-primary' : 'border-divider text-on-surface',
]"
>
<AppIcon
:icon="direction === 'horizontal' ? 'drag-vertical' : 'drag-horizontal'"
:size="16"
/>
</span>

<span
v-if="pending"
class="absolute left-1/2 top-1/2 z-2 whitespace-nowrap rounded border border-primary bg-surface px-2 py-1 text-xs font-medium text-primary shadow-sm pointer-events-none"
:class="direction === 'horizontal' ? 'translate-x-3 -translate-y-1/2' : '-translate-x-1/2 translate-y-3'"
>
{{ pending === 'collapse' ? 'Release to hide' : 'Release to open' }}
</span>
</div>
</SplitterHandle>
</template>
15 changes: 14 additions & 1 deletion packages/0/src/components/Splitter/SplitterHandle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,6 +42,7 @@
isDragging: boolean
isDisabled: boolean
state: SplitterHandleState
pending: SplitterIntentMode | null
attrs: {
'role': 'separator'
'tabindex': 0 | -1
Expand All @@ -55,6 +56,7 @@
'data-state': SplitterHandleState
'data-orientation': SplitterOrientation
'data-disabled': true | undefined
'data-pending': SplitterIntentMode | undefined
'style': Record<string, string>
'onPointerdown': (e: PointerEvent) => void
'onPointerenter': () => void
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
117 changes: 95 additions & 22 deletions packages/0/src/components/Splitter/SplitterRoot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -56,6 +64,7 @@
handles: RegistryContext
dragging: Readonly<Ref<boolean>>
draggingHandle: Readonly<Ref<number | null>>
pending: Readonly<Ref<SplitterPendingIntent | null>>
rootEl: Readonly<Ref<Element | null>>
panel: (index: number) => SplitterPanelTicket | undefined
resize: (index: number, delta: number, options?: { emit?: boolean }) => void
Expand Down Expand Up @@ -116,9 +125,28 @@
const rootEl = toRef(() => toElement(rootAtom.value?.element) ?? null)
const draggingHandle = shallowRef<number | null>(null)
const dragging = toRef(() => !isNull(draggingHandle.value))
const expandAccum = new Map<string | number, number>()

// Keyboard / programmatic resize keeps the legacy instant collapse + accumulate-to-expand behavior.
const expandAccum = new Map<ID, number>()
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<ID, number>()
const pending = shallowRef<SplitterPendingIntent | null>(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<SplitterPanelInput>({
multiple: true,
enroll: true,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand All @@ -339,6 +411,7 @@
handles,
dragging,
draggingHandle,
pending,
rootEl,
panel,
resize,
Expand Down
Loading
Loading