Skip to content

feat(resizable): split panes with draggable, keyboard-operable dividers - #640

Draft
mplatts wants to merge 2 commits into
mainfrom
feat/resizable
Draft

feat(resizable): split panes with draggable, keyboard-operable dividers#640
mplatts wants to merge 2 commits into
mainfrom
feat/resizable

Conversation

@mplatts

@mplatts mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #621

Summary

<.resizable_group> / <.resizable_panel> / <.resizable_handle> plus the PetalResizable hook, a pc-resizable CSS section, both test suites, a showcase module and a /c/resizable playground page.

Panels are percentages of the group, rendered as flex: <pct> 1 0px, so the split stays proportional through a window resize. Per-panel default_size / min_size / max_size / collapsible / collapsed_size, both orientations, n-panel groups. Nested groups are first class: the hook only ever resolves :scope > [data-pc-resizable-panel], so a horizontal split inside a vertical one gets two independent hook instances that cannot see each other's panels.

The sizing maths is a set of pure exported functions (distributeSizes, resolveDrag, resolveEdge, resolveToggle, resolveReset, keyboardDelta), which is what makes the clamping testable without a DOM. Zero new dependencies, npm or hex.

Deviations from the issue's API sketch

The sketch has the group taking a free-form inner_block of panels and handles, and the handle carrying live aria-valuenow / aria-valuemin / aria-valuemax / aria-controls. A function component cannot introspect its own inner_block, so at render time a handle has no way to know which panel precedes it, and a panel has no way to know how many siblings it shares the remainder with. Three consequences:

  1. resizable_handle gained orientation, controls, value_now, value_min, value_max and label attrs. orientation is the GROUP's orientation and the component inverts it for aria-orientation (a handle in a horizontal group is a vertical separator). The other three server-render a starting point; the hook restamps all of them, plus aria-orientation and aria-controls (from the preceding panel's id), on mount and after every resize. So the markup contract is always present for SSR and assistive tech, and always correct once the hook is up. label defaults to "Resize panels" and is the accessible name.

  2. Unsized panels render flex: 1 1 0px. An all-unsized group is therefore an exact equal share with no JS at all. When a group MIXES sized and unsized panels the server cannot compute the remainder, so the hook normalises the shares in mounted() before first paint. Documented on the component.

  3. resizable_panel gained an id attr (needed for aria-controls and for reading the panel out of a collapse event).

Everything else follows the sketch: data-pc-resizable-panel / -handle markers, the data-min / -max / -default / -collapsible / -collapsed-size contract, phx-hook="PetalResizable" + data-orientation on the group, and the two petal:* DOM events.

One more small thing: Enter is listed in the issue's keyboard map but not in its "variants & states" checklist; it is implemented (toggle collapse) and tested.

Keyboard map

Focus is on the handle (it is a role="separator" tab stop).

Key Effect
ArrowLeft / ArrowRight resize a vertical separator by 2 points
ArrowUp / ArrowDown resize a horizontal separator by 2 points
Shift + any of the above 10-point step
Home shrink the preceding panel to min_size, or collapse it when collapsible
End grow the preceding panel to max_size
Enter toggle collapse on a collapsible preceding panel

Arrows perpendicular to the separator are a no-op, per the pattern. Keyboard resizes clamp identically to drag and fire the same resize / collapse events.

How sizes persist

They don't, by design (the issue's non-goal). The library stores nothing. On pointer release and on every keyboard commit the group dispatches a bubbling petal:resizable-resize with detail.sizes (percentages in panel order) and, when on_resize is set, pushes %{"sizes" => [..]} to the LiveView. Collapse and expand dispatch petal:resizable-collapse with detail: {panel_id, collapsed}. Session, URL params or localStorage is the app's call. The playground page wires on_resize to a handle_event that renders the last pushed percentages, so the round trip is visible.

What I verified interactively vs by unit test

Driven in a real Chrome against the playground on :4035, reading attributes back out of the live DOM:

  • Arrow resize: sidebar 28% → 46% over 9 ArrowRight presses, aria-valuenow tracking it the whole way and the panel landing on flex: 46 1 0px
  • Pointer drag: real mouse down / move × 3 / up on the separator moved the sidebar 28% → 50.94%, aria-valuenow 51. Pointer capture path exercised end to end
  • Home collapse: flex: 0 1 0px, aria-valuenow 0, sidebar gone (see collapsed screenshot)
  • Enter restore: back to flex: 28 1 0px
  • Perpendicular no-op: ArrowRight on the vertical group's separator left aria-valuenow at 55
  • on_resize round trip: 5 × ArrowUp on the vertical group put "55% / 45%" on the server-rendered panel next to it
  • Light and dark, both by toggling the playground scheme switch

Unit tested rather than driven in the browser: the clamping edge cases (both panels' min and max bounding one drag), non-zero collapsed_size, collapse of the FOLLOWING panel, double-click reset ratios, distributeSizes normalisation, and the nested-group guard (an inner handle's keydown bubbling to the outer hook must not move the outer panels). The JS suite also drives the hook itself with synthetic pointer events for the drag / release / commit sequence.

Test counts

Suite Before After
mix test 913 (0 failures, 1 skipped) 927 (0 failures, 1 skipped)
npm test 157 206

mix credo: 14 refactoring opportunities / 31 readability issues on main, unchanged on this branch. mix format --check-formatted and mix compile --force --warnings-as-errors both clean.

Screenshots

Captured but not attached here (they are local PNGs, and I have not touched pr-assets): light.png, dark.png, resized.png (sidebar keyed out to 46% with the focus ring on the separator, and the vertical group showing "55% / 45%" pushed back from on_resize) and collapsed.png (sidebar collapsed to 0 via Home). Happy to attach them if you want them on the PR.

<.resizable_group> / <.resizable_panel> / <.resizable_handle>: the layout
primitive behind adjustable docs sidebars, IDE workspaces and editor /
preview splits.

- percentages only, rendered as `flex: <pct> 1 0px`, so a window resize
  keeps the split proportional; per-panel default/min/max, collapsible
  with a collapsed_size, both orientations, n-panel groups
- nested groups are first class: the hook resolves `:scope > ...` only,
  so a horizontal split inside a vertical one gets two independent hook
  instances that never touch each other's panels
- WAI-ARIA window splitter: every handle is a role="separator" tab stop
  with live aria-valuenow/valuemin/valuemax, aria-controls and inverted
  aria-orientation. Arrows step 2 (10 with shift), Home shrinks or
  collapses, End grows to max, Enter toggles collapse, perpendicular
  arrows no-op. Double-click resets a divider's pair to their defaults
- drag uses Pointer Events with setPointerCapture so a fast drag cannot
  lose the divider; body cursor + user-select held for the duration
- no persistence: petal:resizable-resize (bubbling, detail.sizes) on
  release and keyboard commit, plus a pushEvent when on_resize is set;
  petal:resizable-collapse on snap shut / open

The sizing maths (distributeSizes, resolveDrag, resolveEdge,
resolveToggle, resolveReset, keyboardDelta) are pure exported functions
so the clamping is unit-testable without a DOM. Zero new dependencies.

pc-resizable CSS section: hairline divider with an 11px hit area, grip
pill on --pc-radius, gray ramp + dark:, focus-visible ring only, and a
prefers-reduced-motion clause.

Showcase: docs sidebar, nested IDE workspace, vertical split.
Playground: /c/resizable with orientation / with_handle / collapsible
dials and a live on_resize round-trip.

14 Elixir tests (913 -> 927), 49 JS tests (157 -> 206), credo unchanged.
Live-verified in Chrome: arrow resize 28% -> 46% with aria-valuenow
tracking, Home collapse to 0, Enter restore, perpendicular no-op,
pointer drag 28% -> 50.9%, and the on_resize payload landing on the
server as 55% / 45%.

Closes #621

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.48%. Comparing base (871b2cf) to head (86ef4a0).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #640      +/-   ##
==========================================
+ Coverage   92.40%   92.48%   +0.08%     
==========================================
  Files         119      121       +2     
  Lines        5066     5126      +60     
==========================================
+ Hits         4681     4741      +60     
  Misses        385      385              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Screenshots

Resized — panels at a clearly non-default split

Resizable, resized

Collapsed

Resizable, collapsed

Light / dark

Resizable, light
Resizable, dark

Verified independently

Check Result
mix test 927 tests, 0 failures, 1 skipped (+14)
npm test 206 passing (+49)
mix format / compile -Werror clean
mix credo zero new entries vs main
new dependencies none
slugs : clauses 61 : 61

This one has the strongest interactive evidence in the batch. Rather than asserting behaviour, the author drove Chrome and read attributes back out of the live DOM: arrow resize 28% → 46% with aria-valuenow tracking, a real pointer drag (mousedown / 3× move / mouseup) 28% → 50.94%, Home collapsing to flex: 0 1 0px, Enter restoring, a perpendicular arrow correctly no-op'ing, and the on_resize round trip landing "55% / 45%" on the server-rendered panel. Worth noting it got a real drag working where the bottom-sheet PR (#637) could not.

The API deviations are forced by a genuine framework constraint and well reasoned: a function component cannot introspect its own inner_block, so a handle cannot know which panel precedes it at render time. Hence the explicit controls/value_* attrs — the server renders the contract and the hook restamps live values.

Unit-tested but not driven in a browser: clamping against both panels' bounds, non-zero collapsed_size, collapse of the following panel, double-click reset, distributeSizes normalisation, and the nested-group guard.

Images live on the pr-assets branch, which exists only to host PR screenshots - it never merges to main and ships in no Hex release.

…amp, SSR ARIA sync

Audit round. (1) Enter on a non-collapsible separator (and arrows at a
hard bound) preventDefault'd, fired petal:resizable-resize and pushed
on_resize with UNCHANGED sizes - a no-op now stays a no-op: the key
rides through and no event fires, spec-pinned. (2) preventDefault on
pointerdown suppresses native focus, so grabbing a divider with the
pointer left keyboard fine-tuning dead - the handle now takes focus
explicitly on grab. (3) distributeSizes clamped initial sizes to max
only; a default_size below min_size painted below the floor on first
render. It clamps to the floor too - and a collapsible panel's floor is
its collapsed size, so starting collapsed stays legal (both pinned).
(4) updated() specs pin the patch-survival contract (flex + ARIA
re-stamped; sizes re-derived on panel-count change). (5) SSR honesty:
every demo/showcase handle now passes value_now/value_min matching its
panel, so first-paint ARIA agrees with the hook's later stamping. (6)
Doctrine: the two /20 dark alphas move to the house /25; the
markup-only --with-handle modifier is documented as a deliberate
override hook. :class attrs on panel and handle gained doc strings for
the MCP surface. 927 Elixir + 212 JS green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Component: Resizable

2 participants