feat(resizable): split panes with draggable, keyboard-operable dividers - #640
feat(resizable): split panes with draggable, keyboard-operable dividers#640mplatts wants to merge 2 commits into
Conversation
<.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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
ScreenshotsResized — panels at a clearly non-default split Collapsed Light / dark Verified independently
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 The API deviations are forced by a genuine framework constraint and well reasoned: a function component cannot introspect its own Unit-tested but not driven in a browser: clamping against both panels' bounds, non-zero Images live on the |
…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.




Closes #621
Summary
<.resizable_group>/<.resizable_panel>/<.resizable_handle>plus thePetalResizablehook, apc-resizableCSS section, both test suites, a showcase module and a/c/resizableplayground page.Panels are percentages of the group, rendered as
flex: <pct> 1 0px, so the split stays proportional through a window resize. Per-paneldefault_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_blockof panels and handles, and the handle carrying livearia-valuenow/aria-valuemin/aria-valuemax/aria-controls. A function component cannot introspect its owninner_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:resizable_handlegainedorientation,controls,value_now,value_min,value_maxandlabelattrs.orientationis the GROUP's orientation and the component inverts it foraria-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, plusaria-orientationandaria-controls(from the preceding panel'sid), 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.labeldefaults to "Resize panels" and is the accessible name.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 inmounted()before first paint. Documented on the component.resizable_panelgained anidattr (needed foraria-controlsand for reading the panel out of a collapse event).Everything else follows the sketch:
data-pc-resizable-panel/-handlemarkers, thedata-min/-max/-default/-collapsible/-collapsed-sizecontract,phx-hook="PetalResizable"+data-orientationon the group, and the twopetal:*DOM events.One more small thing:
Enteris 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).ArrowLeft/ArrowRightArrowUp/ArrowDownShift+ any of the aboveHomemin_size, or collapse it whencollapsibleEndmax_sizeEnterArrows 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-resizewithdetail.sizes(percentages in panel order) and, whenon_resizeis set, pushes%{"sizes" => [..]}to the LiveView. Collapse and expand dispatchpetal:resizable-collapsewithdetail: {panel_id, collapsed}. Session, URL params or localStorage is the app's call. The playground page wireson_resizeto ahandle_eventthat 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:
ArrowRightpresses,aria-valuenowtracking it the whole way and the panel landing onflex: 46 1 0pxmouse down/move× 3 /upon the separator moved the sidebar 28% → 50.94%,aria-valuenow51. Pointer capture path exercised end to endHomecollapse:flex: 0 1 0px,aria-valuenow0, sidebar gone (seecollapsedscreenshot)Enterrestore: back toflex: 28 1 0pxArrowRighton the vertical group's separator leftaria-valuenowat 55on_resizeround trip: 5 ×ArrowUpon the vertical group put "55% / 45%" on the server-rendered panel next to itUnit 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,distributeSizesnormalisation, and the nested-group guard (an inner handle'skeydownbubbling 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
mix testnpm testmix credo: 14 refactoring opportunities / 31 readability issues onmain, unchanged on this branch.mix format --check-formattedandmix compile --force --warnings-as-errorsboth 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 fromon_resize) andcollapsed.png(sidebar collapsed to 0 viaHome). Happy to attach them if you want them on the PR.