GrSplitter

Package: @feugene/granularitycoreGroup: utilities

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/max hold the edges;
  • one area is collapsedcollapsible removes a panel entirely and brings it back;
  • the layout is savedv-model gives away the size, and all that is left is to write it down.

When to take something else

NeedTake
There are many areas and they are on a gridGrDashboard
The layout is fixedCSS Grid and GrCard
The panel slides out over the contentGrDrawer
Sections are switchedGrTabs
Just a line between blocksGrDivider

The size is a share, not pixels

modelValue is the percentage the first panel takes up. Percentages survive a change of the width of the window: a layout tuned on a wide monitor stays meaningful on a laptop, whereas a pixel width there would eat up half the screen.

The second consequence matters more than the first: the share is known before the render, so the server markup matches the client one and the hydration passes cleanly. The component measures the DOM in exactly one place — when it converts the coordinate of the pointer into percentages; neither the keyboard, nor Home/End, nor collapsing require measurements.

v-model is not mandatory: without it the splitter remembers the size itself (as collapsed does in GrSidebar). change is emitted at the end of a gesture and on every step from the keyboard — that is the point where the saving of the layout is hung.

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 — to min / max;
  • Enter — collapse and bring back (with collapsible);
  • 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-model and change give everything necessary, and a localStorage inside 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 pointer events cover touch as it is.

Playground 12

Loading…

Code
<GrSplitter />

Install

npm i @feugene/granularity

Import

import { GrSplitter } from '@feugene/granularity/components/GrSplitter'

API

Props

PropTypedefaultDescription
modelValuenumber | undefinedundefinedThe share of the first panel as a percentage. It supports `v-model`; without it the component remembers the size itself.
disabledboolean | undefinedfalseThe separator does not drag, does not take focus and does not answer to keys.
ariaLabelstring | undefinedundefinedThe name of the separator for a screen reader. Unset — from the locale.
maxnumber | undefined90The maximum of the first panel as a percentage.
orientation"horizontal" | "vertical" | undefinedundefined`horizontal` — the panels side by side, `vertical` — one above the other.
stepnumber | undefined1The step of the arrows.
minnumber | undefined10The minimum of the first panel as a percentage.
collapsedboolean | undefinedfalseThe first panel is collapsed. It supports `v-model:collapsed`.
defaultSizenumber | undefined50The starting share and the point to return to on a double click.
minEndnumber | undefined10The minimum of the second panel: without it the panel can be squeezed to zero.
bigStepnumber | undefined10The step of the arrows with `Shift`.
collapsibleboolean | undefinedundefinedPermit collapsing the first panel.

Slots

SlotTypeDescription
startanyThe first panel — the one whose size is set by the model.
endanyThe second panel: it takes the remainder.

Events

EventTypeDescription
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 %

Basic
<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

Nested
<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

Фильтры
Enter на разделителе сворачивает панель и возвращает её туда же, откуда свернул. Двойной клик сбрасывает границу к значению по умолчанию, а перетаскивание вплотную к левому краю схлопывает панель мышью.

Доля панели: 30 %

Collapsible
<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

Full keyboard contract of the package

Component documentationAll components