feat(ui): draggable Splitter component for resizable panes

WAI-ARIA "window splitter" pattern (role=separator, keyboard-operable) --
reusable divider used by the resizable/hideable pane layout.
This commit is contained in:
iamdoubz committed 2026-07-11 00:12:01 -05:00
1 parent 864cd33a5f
commit 7dfcbdf247
1 file changed
+105
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
// Draggable divider between two panes (FR-UX-1). Reports a delta in
// pixels via onResize as the pointer moves; the parent owns the actual
// size state and clamping. onResizeEnd fires once per drag (and per arrow
// keypress) so the parent can persist without writing on every pixel.
let {
orientation = "vertical",
onResize,
onResizeEnd,
label,
}: {
orientation?: "vertical" | "horizontal";
onResize: (deltaPx: number) => void;
onResizeEnd?: () => void;
label: string;
} = $props();
let dragging = $state(false);
let lastPos = 0;
function posOf(e: PointerEvent): number {
return orientation === "vertical" ? e.clientX : e.clientY;
}
function onPointerDown(e: PointerEvent) {
dragging = true;
lastPos = posOf(e);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const pos = posOf(e);
onResize(pos - lastPos);
lastPos = pos;
}
function onPointerUp(e: PointerEvent) {
if (!dragging) return;
dragging = false;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
onResizeEnd?.();
}
function onKeydown(e: KeyboardEvent) {
const step = e.shiftKey ? 40 : 12;
const negKey = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
const posKey = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
if (e.key === negKey) onResize(-step);
else if (e.key === posKey) onResize(step);
else return;
e.preventDefault();
onResizeEnd?.();
}
</script>
<!-- WAI-ARIA "window splitter" pattern: a focusable, keyboard-operable
role="separator" is the correct/standard shape for a resize handle —
the a11y linter's generic "non-interactive element" rule doesn't know
about this pattern specifically. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="splitter {orientation}"
class:dragging
role="separator"
aria-orientation={orientation}
aria-label={label}
tabindex="0"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onkeydown={onKeydown}
></div>
<style>
.splitter {
flex: none;
background: transparent;
position: relative;
}
.splitter.vertical {
width: 5px;
cursor: col-resize;
}
.splitter.horizontal {
height: 5px;
cursor: row-resize;
}
/* Wider invisible hit-area than the visible line — a 5px target is too
thin to reliably grab (touch-target-size / no-precision-required). */
.splitter::after {
content: "";
position: absolute;
}
.splitter.vertical::after {
inset: 0 -4px;
}
.splitter.horizontal::after {
inset: -4px 0;
}
.splitter:hover,
.splitter:focus-visible,
.splitter.dragging {
background: var(--accent-soft);
outline: none;
}
</style>