GrSplitter
Splits a layout into two resizable panels with a draggable divider.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- two areas need a shared boundary — a tree and the content, an editor and a preview, a list and a detail;
- the size is chosen by the user — the boundary moves with the mouse and from the keyboard, and
min/maxhold the edges; - one area is collapsed —
collapsibleremoves a panel entirely and brings it back; - the layout is saved —
v-modelgives away the size, and all that is left is to write it down.
When to take something else
| Need | Take |
|---|---|
| There are many areas and they are on a grid | GrDashboard |
| The layout is fixed | CSS Grid and GrCard |
| The panel slides out over the content | GrDrawer |
| Sections are switched | GrTabs |
| Just a line between blocks | GrDivider |
Three panels means nesting
The component holds exactly two panels and one divider. The “tree | editor / console” layout is assembled by nesting, as in code editors:
<GrSplitter v-model="treeWidth">
<template #start><FileTree /></template>
<template #end>
<GrSplitter v-model="consoleHeight" orientation="vertical">
<template #start><Editor /></template>
<template #end><Console /></template>
</GrSplitter>
</template>
</GrSplitter>
Nested splitters are independent: each runs a model of its own.
The bounds of the panels
min and max limit the first panel, and minEnd the second. Without minEnd the second panel
can be squeezed to zero, and there will be nothing to bring it back with.
In a conflict between min and minEnd the min wins: the first panel does not go below its
minimum even if the second one turns out narrower than its own. The reverse order would give a
panel the user cannot get out of.
The keyboard and the mouse
The divider stands in the tab order (role="separator", tabindex="0"):
←/→in the horizontal layout,↑/↓in the vertical one — a step;Shift+ an arrow — a large step;Home/End— tomin/max;Enter— collapse and bring back (withcollapsible);- a double click — a reset to
defaultSize; - dragging right up to the edge (below half of
min) collapses the panel with the mouse: the double click is taken by the reset, and there has to be a way to collapse with the mouse.
The grab zone is wider than the visible strip: a six-pixel target is not caught with a mouse, and making the strip thicker for that would mean painting the layout to suit the mouse.
`aria-orientation` is inverted — and that is right
orientation describes the layout: horizontal means the panels stand side by side.
aria-orientation describes the divider itself, and panels standing side by side are separated
by a vertical strip. A horizontal layout therefore has aria-orientation="vertical", and vice
versa.
The divider announces aria-valuenow/valuemin/valuemax in the same percentages as the model
and refers to the first panel through aria-controls.
Being collapsed is not a zero in the model
collapsed is a separate state (v-model:collapsed) rather than modelValue: 0. The previous
size has to survive the collapsing, otherwise Enter would bring the panel back to the wrong
place. In the track a collapsed panel takes up 0 %, and aria-valuenow shows 0 — what is
visible on the screen.
Limits
GrResizable(resizing a block by a corner) does not belong here — a different scenario and a different pattern;- saving the layout is the job of the application:
v-modelandchangegive everything necessary, and alocalStorageinside the component would be state the application knows nothing about; - there are no snap points and no “magnets” — without a scenario that is extra mechanics on top of the clamping;
- there is no touch physics of its own — the
pointerevents cover touch as it is.
Playground 12
Loading…
<GrSplitter />Install
npm i @feugene/granularityImport
import { GrSplitter } from '@feugene/granularity/components/GrSplitter'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelValue | number | undefined | undefined | The share of the first panel as a percentage. It supports `v-model`; without it the component remembers the size itself. |
disabled | boolean | undefined | false | The separator does not drag, does not take focus and does not answer to keys. |
ariaLabel | string | undefined | undefined | The name of the separator for a screen reader. Unset — from the locale. |
max | number | undefined | 90 | The maximum of the first panel as a percentage. |
orientation | "horizontal" | "vertical" | undefined | undefined | `horizontal` — the panels side by side, `vertical` — one above the other. |
step | number | undefined | 1 | The step of the arrows. |
min | number | undefined | 10 | The minimum of the first panel as a percentage. |
collapsed | boolean | undefined | false | The first panel is collapsed. It supports `v-model:collapsed`. |
defaultSize | number | undefined | 50 | The starting share and the point to return to on a double click. |
minEnd | number | undefined | 10 | The minimum of the second panel: without it the panel can be squeezed to zero. |
bigStep | number | undefined | 10 | The step of the arrows with `Shift`. |
collapsible | boolean | undefined | undefined | Permit collapsing the first panel. |
Slots
| Slot | Type | Description |
|---|---|---|
start | any | The first panel — the one whose size is set by the model. |
end | any | The second panel: it takes the remainder. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: number] | — |
change | [value: number] | — |
update:collapsed | [value: boolean] | — |
Examples 3
Basic
src/
components/
composables/
styles/
docs/
package.json
Тяните границу мышью или доведите до неё фокус клавишей Tab: стрелки двигают на процент, Shift + стрелка — на десять, Home и End упираются в границы.
Доля первой панели: 28 %
<script setup lang="ts">
import { ref } from 'vue'
import { GrSplitter } from '@feugene/granularity'
const size = ref(28)
const files = ['src/', ' components/', ' composables/', ' styles/', 'docs/', 'package.json']
</script>
<template>
<div class="grid gap-3">
<div class="h-64 overflow-hidden rounded-[var(--gr-radius-md)] border border-[var(--gr-brd)]">
<GrSplitter v-model="size" :min="15" :max="60" aria-label="Ширина дерева файлов">
<template #start>
<div class="h-full overflow-auto bg-[var(--gr-muted)] p-3" tabindex="0">
<p
v-for="file in files"
:key="file"
class="whitespace-pre text-[length:var(--gr-text-xs)] leading-[var(--gr-leading-relaxed)] text-[var(--gr-muted-fg)]"
>{{ file }}</p>
</div>
</template>
<template #end>
<div class="h-full overflow-auto p-4">
<p class="text-[length:var(--gr-text-sm)] text-[var(--gr-fg)]">
Тяните границу мышью или доведите до неё фокус клавишей Tab: стрелки двигают на процент,
Shift + стрелка — на десять, Home и End упираются в границы.
</p>
</div>
</template>
</GrSplitter>
</div>
<p class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
Доля первой панели: {{ Math.round(size) }} %
</p>
</div>
</template>Nested
$ yarn build
✓ built in 692ms
<script setup lang="ts">
import { ref } from 'vue'
import { GrSplitter } from '@feugene/granularity'
const treeWidth = ref(25)
const consoleHeight = ref(65)
</script>
<template>
<div class="h-72 overflow-hidden rounded-[var(--gr-radius-md)] border border-[var(--gr-brd)]">
<GrSplitter v-model="treeWidth" :min="15" :max="50" aria-label="Ширина дерева">
<template #start>
<div class="h-full bg-[var(--gr-muted)] p-3 text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
Дерево
</div>
</template>
<template #end>
<!-- Трёхпанельная раскладка — это вложение, а не третья панель у сплиттера. -->
<GrSplitter v-model="consoleHeight" orientation="vertical" :min="30" aria-label="Высота консоли">
<template #start>
<div class="h-full p-4 text-[length:var(--gr-text-sm)] text-[var(--gr-fg)]">
Редактор
</div>
</template>
<template #end>
<div class="h-full overflow-auto bg-[var(--gr-muted)] p-3 font-mono text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]" tabindex="0">
<p>$ yarn build</p>
<p>✓ built in 692ms</p>
</div>
</template>
</GrSplitter>
</template>
</GrSplitter>
</div>
</template>Collapsible
Доля панели: 30 %
<script setup lang="ts">
import { ref } from 'vue'
import { GrSplitter } from '@feugene/granularity'
const size = ref(30)
const collapsed = ref(false)
</script>
<template>
<div class="grid gap-3">
<div class="h-56 overflow-hidden rounded-[var(--gr-radius-md)] border border-[var(--gr-brd)]">
<GrSplitter
v-model="size"
v-model:collapsed="collapsed"
collapsible
:min="20"
:min-end="30"
:default-size="30"
aria-label="Ширина боковой панели"
>
<template #start>
<div class="h-full bg-[var(--gr-muted)] p-3 text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
Фильтры
</div>
</template>
<template #end>
<div class="h-full p-4 text-[length:var(--gr-text-sm)] text-[var(--gr-fg)]">
Enter на разделителе сворачивает панель и возвращает её туда же, откуда свернул.
Двойной клик сбрасывает границу к значению по умолчанию, а перетаскивание вплотную
к левому краю схлопывает панель мышью.
</div>
</template>
</GrSplitter>
</div>
<p class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
{{ collapsed ? 'Панель свёрнута' : `Доля панели: ${Math.round(size)} %` }}
</p>
</div>
</template>Accessibility
- APG pattern
window splitter