Skip to content

PlPanes

A set of regions with draggable handles between them. Sized in fractions, so the split survives the window being resized without a line of JavaScript running.

React
tsx
import { PlPane, PlPanes } from 'plass-ui';

<PlPanes>
  <PlPane defaultSize="240px" minSize="180px" maxSize="50%">
    {sidebar}
  </PlPane>
  <PlPane>{body}</PlPane>
</PlPanes>;
dart
import 'package:plass_ui/plass_ui.dart';

PlPanes(
  panes: <PlPane>[
    PlPane(
      defaultSize: const PlPaneSize.pixels(240),
      minSize: const PlPaneSize.pixels(180),
      maxSize: const PlPaneSize.percent(50),
      child: sidebar,
    ),
    PlPane(child: body),
  ],
);

Props

PropTypeDefaultDescription
orientationsharedPlassResponsive<'horizontal' | 'vertical'>'horizontal'Which way the panes run — side by side, or stacked
resizablebooleantrueWhether the handles can be dragged. Off for a split that is a layout rather than a control
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'How thick a handle is, and how wide the target the pointer has to hit
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'The family the handles light up in. There is no sheet, so it reaches the hairline, the tint and the ring
onResize(sizes: number[]) => voidFires with every pane's share, in percent, while a handle is dragged
onResizeEnd(sizes: number[]) => voidFires once, with the same shape, when the handle is let go — and on a key press
childrenReactNodeThe PlPanes. Anything that is not one is still laid out, but has no size
PropTypeDefaultDescription
panes * List<PlPane>The regions, as a list of descriptions rather than children — the three sizing values are read by the split, not the pane
orientationsharedPlassResponsive<PlassOrientation>PlassOrientation.horizontalWhich way the panes run — side by side, or stacked
resizablebooltrueWhether the handles can be dragged. Off for a split that is a layout rather than a control
sizesharedPlassSizePlassSize.mdHow thick a handle is, and how wide the target the pointer has to hit
colorsharedPlassColorPlassColor.primaryThe family the handles light up in. There is no sheet, so it reaches the hairline, the tint and the ring
onResizeValueChanged<List<double>>?Fires with every pane's share, in percent, while a handle is dragged
onResizeEndValueChanged<List<double>>?Fires once, with the same shape, when the handle is let go — and on a key press
labelString?What a screen reader calls a handle, before the share it is at

PlPane

PropTypeDefaultDescription
defaultSizenumber | stringThe share it starts with. A number is a percentage, a string an absolute length. Without one, panes split what is left evenly
minSizenumber | string0How small it may be dragged
maxSizenumber | stringHow large it may be dragged. Unbounded when left out
childrenReactNodeWhat is inside the pane
PropTypeDefaultDescription
child * WidgetWhat is inside the pane
defaultSizePlPaneSize?The share it starts with, as PlPaneSize.percent or PlPaneSize.pixels — Dart has no number | string union
minSizePlPaneSize?0How small it may be dragged
maxSizePlPaneSize?How large it may be dragged. Unbounded when left out

Every native <div> attribute passes through on both. color is excluded on the split because it is a Plass prop there.

A PlPane is a description rather than a widget, the idiom this package uses for an accordion's folds and a table's columns, and it is the same reason: the three sizing values are read by the split, so it has to be able to read them.

A pane carries no surface of its own, and neither does the split: this is layout, and the moment a pane drew a sheet it would stop being usable as the thing a PlCard, a PlTable or an editor is put inside. Put a PlCard in it when a surface is wanted. What the shared axes mean across the library is in prop conventions.

Examples

Measuring a split

The panes are sized in fractions, written out as flex-basis: calc((100% − gutters) × fraction).

That is the one decision the rest of the component follows from. A split described as a percentage survives the window being resized without anything running, so the component measures itself only twice: once on mount, to turn a '240px' default into a fraction, and once at the start of each drag, to know what a pixel of pointer movement is worth.

The measurement is a ResizeObserver rather than a single read, because a split inside a closed PlAccordion or an unselected PlTab is zero wide when it mounts, and dividing by that would put every pane at nothing.

This is the one place Flutter makes the same idea easier rather than harder. CSS measures itself, so the React build needs a ResizeObserver and a mounted split that is zero wide is a real hazard; a LayoutBuilder is handed the extent on every layout pass, so there is nothing to observe and nothing to re-measure. A split with no room yet simply lays its panes out evenly until there is some.

defaultSize, minSize and maxSize

A percentage is how a split is usually described and what keeps its meaning when the window changes size. An absolute length is what a sidebar with a minimum actually needs: "at least 200 pixels" does not survive being written down as a percentage of a width nobody knows yet.

A bare number is the percentage and a string is the length: '240px', '15rem', '20%'.Dart has no number | string union, so the two are two constructors: PlPaneSize.percent and PlPaneSize.pixels.

Panes with no defaultSize split whatever is left over equally.

The three props are read by the split rather than used by the pane. A pane cannot know what "half" is; only the thing holding all of them can. Which is also why the direct children of a PlPanes have to be PlPanes. A pane wrapped in something else is a pane with no minimum.

React

orientation

horizontal puts the panes side by side with upright handles between them; vertical stacks them. Nesting one inside a pane of the other is how a three-region layout is built.

It is responsive, so a set can run one way on a phone and the other on a laptop. A server renders the xs entry and the browser corrects it on hydration.It is resolved against the window's width during build, so the first frame is already right. See breakpoints.

React

resizable

Turn it off for a split that is a layout rather than a control. The handles stay, they are still the line between two regions, but they stop taking the pointer and leave the tab order.

React

size and color

size is how thick a handle is. What is drawn is a hairline; what can be grabbed is the track around it. The same split a scrollbar makes between the two, and the reason a one-pixel line is not a target.

The split draws no sheet, so color reaches three things and stops: the handle's hairline when the pointer is on it, the tint under it, and the focus ring.

React

Accessibility

  • Every handle is a role="separator" carrying aria-valuenow as the share of the pane before it, so a screen reader can say where the boundary is rather than that there is one.
  • A handle is a tab stop while the split can be resized, and the arrow keys move it. It leaves the tab order entirely when resizable is off, a control that cannot be operated should not be a stop on the way to one that can.
  • A key press is a whole gesture on its own, so onResizeEnd fires with it. There is no "let go" to wait for.
  • The handle is focused by the browser on a press, not by the component. Focusing it by hand would put a keyboard focus ring on every handle somebody merely dragged.
  • A drag takes the page's text selection away for its own length instead of calling preventDefault on the press, which is what would have stopped the focus above. The property is written as -webkit-user-select through setProperty, because WebKit implements only the prefixed name and style.userSelect = 'none' silently does nothing there.
  • A drag in flight is torn down if the split unmounts. The pointerup that would have ended it never arrives after a route change, and what is left behind is a page whose text can no longer be selected, on top of two listeners on a detached node.
  • A handle is a slider rather than a separator, which is what it actually is to a screen reader here: Flutter's semantics tree has no separator role and no valuenow, but it has a control with a value that can be turned up and down, and label names it.
  • The arrow keys move it, and they follow the writing direction, so they run the other way under RTL, exactly as a drag does.
  • None of the three drag hazards the other build has exists here. There is no document selection to take away, no browser focusing anything on a press, and a gesture recogniser is disposed with the widget that owns it.

Differences from the React build

ReactFlutterWhy
<PlPane> childrenpanes: List<PlPane>The split reads the three sizing values off its members, and a Widget is opaque.
number | string sizesPlPaneSize.percent / .pixelsDart has no union. The two constructors are the two halves of it.
a ResizeObservera LayoutBuilderCSS measures itself; Flutter hands the extent to whoever asks in the layout pass.
role="separator", aria-valuenowslider semanticsFlutter's tree has neither, and a control with a value is the honest description.
className, styleThere is no class list and no style attribute to pass through.

Released under the MIT License