Appearance
Resizable — live demo
This is the real @rozie-ui/resizable-vue package running on this page (VitePress is itself a Vue app). Drag the handle between the panels, or focus it (Tab) and use the Arrow keys / Home / End — then watch the two-way bound size percent update and the @resize readout fire. Everything below is driven by the same Resizable.rozie source that compiles to all six frameworks, built on native Pointer Events with no engine and no required CSS — the drag behaviour and a tokenised skin all ship inside the component.
size is two-way bound with v-model:size — the readout updates the instant you drag, and a consumer write (the reset() / applySize() buttons, grabbed through Vue's ref) flows back in. Set direction to 'horizontal' / 'vertical', clamp the range with :min / :max, and listen to @resize for every committed change. See the full API for every prop, event, handle verb, and slot, plus theming and keyboard reference.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
Resizable.rozie — a headless, accessible two-panel splitter / resizable pane.
A pure-Rozie family (NO third-party engine) in the spirit of otp / slider: it
fills a real cross-framework need (every layout re-implements a draggable
split pane) with zero engine dependency. The platform IS the engine: native
Pointer Events + pointer capture do the drag, the keyboard drives the handle
(role="separator"), and a single CSS custom property (`--rozie-resizable-size`)
positions the two panels. Rozie owns the author-side API, the two-way binding,
the clamp/percent math, and the token-themed skin.
TWO PANELS ONLY (v1): a `start` panel, an `end` panel, and one draggable
`handle` between them. N-panel layouts are intentionally out of scope — the
single `size` percent (width of the FIRST panel) is the whole state, which is
what keeps it fully controlled with no draft buffer.
CONTROLLED, NO LOCAL STATE: `size` (the sole model:true prop → Angular
ControlValueAccessor; a splitter position IS a form-controllable value) is the
first panel's percent. The second panel takes the remainder via CSS
(`flex: 1 1 0` / `calc(100% - var(...))`). A `$data.dragging` flag only tracks
the active-drag CSS class; it is NOT the value.
DRAG via TEMPLATE pointer events + pointer capture (NOT a document <listeners>
block): `@pointerdown` captures the pointer on the handle, `@pointermove`
maps the pointer into a first-panel percent off the container rect, and
`@pointerup` releases. This avoids the documented React emitter gap where
`$event` in a <listeners> handler leaks into the useEffect deps array (TS2552);
template `@event` is typed and safe, and pointer capture keeps move/up events
flowing to the handle even when the pointer leaves it mid-drag.
$refs.root is read ONLY inside the pointer handlers and the $expose verbs (all
post-mount → ROZ123-safe), and getBoundingClientRect reaches the container
inside Lit's shadow root too.
Authoring notes (collision classes — see the authoring playbook):
- The expose verbs are `applySize` / `reset`. The natural `setSize` verb
COLLIDES with the React state setter auto-generated for the `size` model
prop (ROZ524) — and the Phase-46 deconfliction pass does NOT rename inside
an `$expose`-verb closure, so it surfaces as an INTERNAL ROZ524 at
compile(react). Renamed to `applySize` (the listbox/data-table `apply<X>`
precedent). `reset` is collision-safe.
- Handler params are LEFT UNTYPED so they neutralize to `any`; reading
`e.clientX` / `e.currentTarget` / `e.pointerId` then typechecks across all
six strict leaves. Never annotate them.
- The clamp/percent math lives in ./internal/resizeMath (vendored into every
leaf, unit-tested once) — the §"branchy math → src/internal helper" rule.
Consumer example:
<Resizable r-model:size="$data.split" :min="20" :max="80" direction="horizontal">
<template #start><nav>…</nav></template>
<template #end><main>…</main></template>
</Resizable>
-->
<rozie name="Resizable">
<props>
{
// The first panel's size as a percent of the container along the split axis
// (width when horizontal, height when vertical). The sole model:true prop →
// drives the Angular ControlValueAccessor. Always clamped to [min, max].
size: {
type: Number,
default: 50,
model: true,
docs: {
description:
'The first (`start`) panel\'s size as a percent of the container along the split axis (its width when `direction="horizontal"`, its height when `"vertical"`). Two-way via `r-model:size`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so the splitter position **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Every commit (drag, keyboard, or a programmatic `applySize`) is clamped to `[min, max]` and written back.',
example: '<Resizable r-model:size="split" :min="20" :max="80" direction="horizontal" />',
},
},
// Split axis. 'horizontal' → side-by-side panels with a vertical handle;
// 'vertical' → stacked panels with a horizontal handle.
direction: {
type: String,
default: 'horizontal',
docs: {
description:
"The split axis. `'horizontal'` (default) lays the two panels out side-by-side with a vertical drag handle between them (`size` is the first panel's **width**); `'vertical'` stacks them with a horizontal handle (`size` is the first panel's **height**). Also sets the handle's `aria-orientation`.",
},
},
// Lower bound on `size` (percent). The first panel can never shrink below this.
min: {
type: Number,
default: 10,
docs: {
description:
'The minimum `size` percent — the first panel can never be dragged or nudged below this. Clamps every commit.',
},
},
// Upper bound on `size` (percent). The first panel can never grow beyond this.
max: {
type: Number,
default: 90,
docs: {
description:
'The maximum `size` percent — the first panel can never be dragged or nudged beyond this (so the second panel keeps at least `100 - max` percent). Clamps every commit.',
},
},
// Disable resizing — the handle becomes non-interactive and the panels lock at
// the current `size`.
disabled: {
type: Boolean,
default: false,
docs: {
description:
'Disable resizing — the handle becomes non-interactive (pointer drag and keyboard are ignored) and the panels lock at the current `size`. Also sets the Angular `ControlValueAccessor` disabled state.',
},
},
}
</props>
<data>
{
// Active-drag flag — drives the `--dragging` CSS class only (NOT the value).
dragging: false,
}
</data>
<script lang="ts">
import { clampPercent, percentFromPointer, nudge } from './internal/resizeMath'
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current size, normalized + clamped. Plain function (called in template
// bindings AND handlers) — never $computed (a $computed is a value on React but
// an accessor on Solid; a plain fn reads uniformly).
const currentSize = () => {
const raw = typeof $props.size === 'number' ? $props.size : $props.min
return clampPercent(raw, $props.min, $props.max)
}
const isVertical = () => $props.direction === 'vertical'
// Inline CSS custom property positioning the panels. Read BARE in the template
// via :style — a plain object literal, recomputed each render.
const sizeStyle = () => ({ '--rozie-resizable-size': currentSize() + '%' })
// ---- write funnel (single $emit site) ----------------------------------
// Clamp, write the model, emit resize. The SOLE $emit('resize') site so the
// React prop-destructure for onResize hoists exactly once.
const commitSize = (raw) => {
const next = clampPercent(raw, $props.min, $props.max)
$model.size = next
$emit('resize', { size: next })
}
// ---- pointer drag (template @event + pointer capture) ------------------
// $refs.root is post-mount-only (ROZ123-safe): read inside these handlers.
const onPointerDown = (e) => {
if ($props.disabled) return
if (e && e.preventDefault) e.preventDefault()
$data.dragging = true
// Capture the pointer on the handle so move/up keep firing on it even when the
// pointer leaves the handle mid-drag.
if (e && e.currentTarget && e.currentTarget.setPointerCapture && e.pointerId != null) {
e.currentTarget.setPointerCapture(e.pointerId)
}
}
const onPointerMove = (e) => {
if (!$data.dragging || $props.disabled) return
const root = $refs.root
if (!root) return
const rect = root.getBoundingClientRect()
const pct = isVertical()
? percentFromPointer(e.clientY, rect.top, rect.height)
: percentFromPointer(e.clientX, rect.left, rect.width)
commitSize(pct)
}
const onPointerUp = (e) => {
if (!$data.dragging) return
$data.dragging = false
if (e && e.currentTarget && e.currentTarget.releasePointerCapture && e.pointerId != null) {
if (e.currentTarget.hasPointerCapture && e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}
}
// ---- keyboard (role="separator") ---------------------------------------
// Arrow keys nudge by 1% (toward/away from the start panel along the axis);
// Home/End jump to min/max. Matches the WAI-ARIA window-splitter pattern.
const onKeydown = (e) => {
if ($props.disabled) return
const key = e ? e.key : ''
const vertical = isVertical()
const decKey = vertical ? 'ArrowUp' : 'ArrowLeft'
const incKey = vertical ? 'ArrowDown' : 'ArrowRight'
if (key === decKey) {
if (e) e.preventDefault()
commitSize(nudge(currentSize(), -1, $props.min, $props.max))
} else if (key === incKey) {
if (e) e.preventDefault()
commitSize(nudge(currentSize(), 1, $props.min, $props.max))
} else if (key === 'Home') {
if (e) e.preventDefault()
commitSize($props.min)
} else if (key === 'End') {
if (e) e.preventDefault()
commitSize($props.max)
}
}
// ---- imperative handle -------------------------------------------------
// applySize(percent) — set the split programmatically (clamped + emits resize).
// reset() — recentre to the midpoint of [min, max].
// COLLISION NOTE: the verb is `applySize`, NOT the natural `setSize` — the model
// prop is `size`, so the React emitter auto-generates a `setSize` state setter.
// A `$expose` verb named `setSize` collapses onto that setter ident and trips
// ROZ524 (it fires as an INTERNAL diagnostic because the Phase-46 deconfliction
// pass does NOT rename inside an `$expose`-verb closure — see the emitter-gap
// note in the family README). `apply<X>` is the listbox/data-table precedent for
// dodging a generated React setter. It is also NOT `resize` (→ ROZ121 emit clash)
// and NOT a host-element member.
const applySize = (percent) => commitSize(percent)
const reset = () => commitSize(($props.min + $props.max) / 2)
$expose({ applySize, reset })
</script>
<template>
<div
class="rozie-resizable"
ref="root"
:class="{ 'rozie-resizable--vertical': isVertical(), 'rozie-resizable--horizontal': !isVertical(), 'rozie-resizable--dragging': $data.dragging, 'rozie-resizable--disabled': $props.disabled }"
:style="sizeStyle()"
>
<!-- start panel — sized by --rozie-resizable-size along the split axis -->
<div class="rozie-resizable-panel rozie-resizable-panel--start">
<slot name="start"></slot>
</div>
<!-- draggable handle. role="separator" + aria-value* is the WAI-ARIA window-
splitter pattern; tabindex=0 makes it keyboard-focusable. The #handle slot
overrides the visual grip but keeps the behavior.
aria-valuenow binds the BARE `$props.size` (a provably-primitive Number)
rather than `currentSize()` — a call expression wraps in `rozieAttr` and
returns `string`, which React's `aria-valuenow: number` rejects (TS2322;
the aria-numeric sibling of listbox's `!!` boolean wrap). Since every
commit writes the model back CLAMPED, `$props.size` is in range in
practice; the layout itself still uses the clamped `currentSize()`. -->
<div
class="rozie-resizable-handle"
role="separator"
tabindex="0"
:aria-orientation="isVertical() ? 'horizontal' : 'vertical'"
:aria-valuenow="$props.size"
:aria-valuemin="$props.min"
:aria-valuemax="$props.max"
:aria-disabled="!!$props.disabled"
@pointerdown="onPointerDown($event)"
@pointermove="onPointerMove($event)"
@pointerup="onPointerUp($event)"
@keydown="onKeydown($event)"
>
<slot name="handle">
<span class="rozie-resizable-grip" aria-hidden="true"></span>
</slot>
</div>
<!-- end panel — takes the remaining space (flex: 1 1 0) -->
<div class="rozie-resizable-panel rozie-resizable-panel--end">
<slot name="end"></slot>
</div>
</div>
</template>
<style>
/*
Fully token-driven (mirrors otp/slider themes): every COSMETIC value is a
`var(--rozie-resizable-*, <fallback>)`, so the component renders with zero
config yet is completely re-skinnable. The shipped themes/*.css presets map
these tokens onto shadcn/Radix, Material 3, Bootstrap 5.
STRUCTURAL rules (the flex container, the --size-driven first-panel basis, the
remainder second panel, the handle hit-area, and the vertical column variant)
are behavior-critical and NOT consumer-overridable.
*/
.rozie-resizable {
display: flex;
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
font: var(--rozie-resizable-font, inherit);
}
.rozie-resizable--horizontal {
flex-direction: row;
}
.rozie-resizable--vertical {
flex-direction: column;
}
/* The first panel is sized to --rozie-resizable-size along the split axis; the
second panel flexes into the remainder. */
.rozie-resizable-panel {
box-sizing: border-box;
overflow: auto;
}
.rozie-resizable-panel--start {
flex: 0 0 auto;
}
.rozie-resizable--horizontal .rozie-resizable-panel--start {
width: var(--rozie-resizable-size, 50%);
height: 100%;
}
.rozie-resizable--vertical .rozie-resizable-panel--start {
height: var(--rozie-resizable-size, 50%);
width: 100%;
}
.rozie-resizable-panel--end {
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
/* The handle — a thin draggable divider with a centered grip. */
.rozie-resizable-handle {
flex: 0 0 var(--rozie-resizable-handle-size, 0.5rem);
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--rozie-resizable-handle-bg, rgba(0, 0, 0, 0.08));
outline: none;
transition: background-color 0.15s;
touch-action: none;
}
.rozie-resizable--horizontal .rozie-resizable-handle {
cursor: col-resize;
align-self: stretch;
}
.rozie-resizable--vertical .rozie-resizable-handle {
cursor: row-resize;
}
.rozie-resizable-handle:hover {
background: var(--rozie-resizable-handle-hover-bg, rgba(0, 0, 0, 0.16));
}
.rozie-resizable-handle:focus-visible {
box-shadow: 0 0 0 var(--rozie-resizable-focus-ring-width, 2px)
var(--rozie-resizable-focus-ring-color, rgba(0, 102, 204, 0.5));
z-index: 1;
}
.rozie-resizable--dragging .rozie-resizable-handle {
background: var(--rozie-resizable-handle-active-bg, var(--rozie-resizable-accent, #0066cc));
}
/* The default grip — a short bar centered in the handle. */
.rozie-resizable-grip {
display: block;
border-radius: 999px;
background: var(--rozie-resizable-grip-bg, rgba(0, 0, 0, 0.35));
}
.rozie-resizable--horizontal .rozie-resizable-grip {
width: var(--rozie-resizable-grip-thickness, 2px);
height: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--vertical .rozie-resizable-grip {
height: var(--rozie-resizable-grip-thickness, 2px);
width: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--disabled .rozie-resizable-handle {
cursor: default;
opacity: var(--rozie-resizable-disabled-opacity, 0.55);
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/resizable-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, parseInlineStyle, rozieAttr, useControllableState } from '@rozie/runtime-react';
import './Resizable.css';
import { clampPercent, percentFromPointer, nudge } from './internal/resizeMath';
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current size, normalized + clamped. Plain function (called in template
// bindings AND handlers) — never $computed (a $computed is a value on React but
// an accessor on Solid; a plain fn reads uniformly).
interface ResizableProps {
/**
* The first (`start`) panel's size as a percent of the container along the split axis (its width when `direction="horizontal"`, its height when `"vertical"`). Two-way via `r-model:size`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so the splitter position **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Every commit (drag, keyboard, or a programmatic `applySize`) is clamped to `[min, max]` and written back.
* @example
* <Resizable r-model:size="split" :min="20" :max="80" direction="horizontal" />
*/
size?: number;
defaultSize?: number;
onSizeChange?: (size: number) => void;
/**
* The split axis. `'horizontal'` (default) lays the two panels out side-by-side with a vertical drag handle between them (`size` is the first panel's **width**); `'vertical'` stacks them with a horizontal handle (`size` is the first panel's **height**). Also sets the handle's `aria-orientation`.
*/
direction?: string;
/**
* The minimum `size` percent — the first panel can never be dragged or nudged below this. Clamps every commit.
*/
min?: number;
/**
* The maximum `size` percent — the first panel can never be dragged or nudged beyond this (so the second panel keeps at least `100 - max` percent). Clamps every commit.
*/
max?: number;
/**
* Disable resizing — the handle becomes non-interactive (pointer drag and keyboard are ignored) and the panels lock at the current `size`. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
onResize?: (...args: any[]) => void;
renderStart?: () => ReactNode;
renderHandle?: () => ReactNode;
renderEnd?: () => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface ResizableHandle {
applySize: (...args: any[]) => any;
reset: (...args: any[]) => any;
}
const Resizable = forwardRef<ResizableHandle, ResizableProps>(function Resizable(_props: ResizableProps, ref): JSX.Element {
const props: Omit<ResizableProps, 'direction' | 'min' | 'max' | 'disabled'> & { direction: string; min: number; max: number; disabled: boolean } = {
..._props,
direction: _props.direction ?? 'horizontal',
min: _props.min ?? 10,
max: _props.max ?? 90,
disabled: _props.disabled ?? false,
};
const attrs: Record<string, unknown> = (() => {
const { size, direction, min, max, disabled, defaultValue, onSizeChange, defaultSize, ...rest } = _props as ResizableProps & Record<string, unknown>;
void size; void direction; void min; void max; void disabled; void defaultValue; void onSizeChange; void defaultSize;
return rest;
})();
const [size, setSize] = useControllableState({
value: props.size,
defaultValue: props.defaultSize ?? 50,
onValueChange: props.onSizeChange,
});
const [dragging, setDragging] = useState(false);
const root = useRef<HTMLDivElement | null>(null);
function currentSize() {
const raw = typeof size === 'number' ? size : props.min;
return clampPercent(raw, props.min, props.max);
}
function isVertical() {
return props.direction === 'vertical';
}
function sizeStyle() {
return {
'--rozie-resizable-size': currentSize() + '%'
};
}
function commitSize(raw: any) {
const next = clampPercent(raw, props.min, props.max);
setSize(next);
props.onResize && props.onResize({
size: next
});
}
const onPointerDown = useCallback((e: any) => {
if (props.disabled) return;
if (e && e.preventDefault) e.preventDefault();
setDragging(true);
// Capture the pointer on the handle so move/up keep firing on it even when the
// pointer leaves the handle mid-drag.
if (e && e.currentTarget && e.currentTarget.setPointerCapture && e.pointerId != null) {
e.currentTarget.setPointerCapture(e.pointerId);
}
}, [props.disabled]);
const onPointerMove = useCallback((e: any) => {
if (!dragging || props.disabled) return;
const root$local = root.current;
if (!root$local) return;
const rect = root$local.getBoundingClientRect();
const pct = isVertical() ? percentFromPointer(e.clientY, rect.top, rect.height) : percentFromPointer(e.clientX, rect.left, rect.width);
commitSize(pct);
}, [commitSize, dragging, isVertical, props.disabled]);
const onPointerUp = useCallback((e: any) => {
if (!dragging) return;
setDragging(false);
if (e && e.currentTarget && e.currentTarget.releasePointerCapture && e.pointerId != null) {
if (e.currentTarget.hasPointerCapture && e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
}, [dragging]);
const onKeydown = useCallback((e: any) => {
if (props.disabled) return;
const key = e ? e.key : '';
const vertical = isVertical();
const decKey = vertical ? 'ArrowUp' : 'ArrowLeft';
const incKey = vertical ? 'ArrowDown' : 'ArrowRight';
if (key === decKey) {
if (e) e.preventDefault();
commitSize(nudge(currentSize(), -1, props.min, props.max));
} else if (key === incKey) {
if (e) e.preventDefault();
commitSize(nudge(currentSize(), 1, props.min, props.max));
} else if (key === 'Home') {
if (e) e.preventDefault();
commitSize(props.min);
} else if (key === 'End') {
if (e) e.preventDefault();
commitSize(props.max);
}
}, [commitSize, currentSize, isVertical, props.disabled, props.max, props.min]);
function applySize(percent: any) {
return commitSize(percent);
}
function reset() {
return commitSize((props.min + props.max) / 2);
}
const _rozieExposeRef = useRef({ applySize, reset });
_rozieExposeRef.current = { applySize, reset };
useImperativeHandle(ref, () => ({ applySize: (...args: Parameters<typeof applySize>): ReturnType<typeof applySize> => _rozieExposeRef.current.applySize(...args), reset: (...args: Parameters<typeof reset>): ReturnType<typeof reset> => _rozieExposeRef.current.reset(...args) }), []);
return (
<>
<div ref={root} style={parseInlineStyle(sizeStyle())} {...attrs} className={clsx(clsx("rozie-resizable", { "rozie-resizable--vertical": isVertical(), "rozie-resizable--horizontal": !isVertical(), "rozie-resizable--dragging": dragging, "rozie-resizable--disabled": props.disabled }), (attrs.className as string | undefined))} data-rozie-s-8330bc5a="">
<div className={"rozie-resizable-panel rozie-resizable-panel--start"} data-rozie-s-8330bc5a="">
{(props.renderStart ?? props.slots?.['start'])?.()}
</div>
<div className={"rozie-resizable-handle"} role="separator" tabIndex={0} aria-orientation={rozieAttr(isVertical() ? 'horizontal' : 'vertical')} aria-valuenow={size} aria-valuemin={props.min} aria-valuemax={props.max} aria-disabled={!!props.disabled} onPointerDown={($event) => { onPointerDown($event); }} onPointerMove={($event) => { onPointerMove($event); }} onPointerUp={($event) => { onPointerUp($event); }} onKeyDown={($event) => { onKeydown($event); }} data-rozie-s-8330bc5a="">
{(props.renderHandle ?? props.slots?.['handle']) ? ((props.renderHandle ?? props.slots?.['handle']) as Function)() : <span className={"rozie-resizable-grip"} aria-hidden="true" data-rozie-s-8330bc5a="" />}
</div>
<div className={"rozie-resizable-panel rozie-resizable-panel--end"} data-rozie-s-8330bc5a="">
{(props.renderEnd ?? props.slots?.['end'])?.()}
</div>
</div>
</>
);
});
export default Resizable;vue
<template>
<div :class="['rozie-resizable', { 'rozie-resizable--vertical': isVertical(), 'rozie-resizable--horizontal': !isVertical(), 'rozie-resizable--dragging': dragging, 'rozie-resizable--disabled': props.disabled }]" ref="rootRef" :style="sizeStyle()" v-bind="$attrs">
<div class="rozie-resizable-panel rozie-resizable-panel--start">
<slot name="start"></slot>
</div>
<div class="rozie-resizable-handle" role="separator" tabindex="0" :aria-orientation="isVertical() ? 'horizontal' : 'vertical'" :aria-valuenow="size" :aria-valuemin="(props.min) ?? undefined" :aria-valuemax="(props.max) ?? undefined" :aria-disabled="!!props.disabled" @pointerdown="onPointerDown($event)" @pointermove="onPointerMove($event)" @pointerup="onPointerUp($event)" @keydown="onKeydown($event)">
<slot name="handle">
<span class="rozie-resizable-grip" aria-hidden="true"></span>
</slot>
</div>
<div class="rozie-resizable-panel rozie-resizable-panel--end">
<slot name="end"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = withDefaults(
defineProps<{
/**
* The split axis. `'horizontal'` (default) lays the two panels out side-by-side with a vertical drag handle between them (`size` is the first panel's **width**); `'vertical'` stacks them with a horizontal handle (`size` is the first panel's **height**). Also sets the handle's `aria-orientation`.
*/
direction?: string;
/**
* The minimum `size` percent — the first panel can never be dragged or nudged below this. Clamps every commit.
*/
min?: number;
/**
* The maximum `size` percent — the first panel can never be dragged or nudged beyond this (so the second panel keeps at least `100 - max` percent). Clamps every commit.
*/
max?: number;
/**
* Disable resizing — the handle becomes non-interactive (pointer drag and keyboard are ignored) and the panels lock at the current `size`. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
}>(),
{ direction: 'horizontal', min: 10, max: 90, disabled: false }
);
/**
* The first (`start`) panel's size as a percent of the container along the split axis (its width when `direction="horizontal"`, its height when `"vertical"`). Two-way via `r-model:size`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so the splitter position **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Every commit (drag, keyboard, or a programmatic `applySize`) is clamped to `[min, max]` and written back.
* @example
* <Resizable r-model:size="split" :min="20" :max="80" direction="horizontal" />
*/
const size = defineModel<number>('size', { default: 50 });
const emit = defineEmits<{
resize: [...args: any[]];
}>();
defineSlots<{
start(props: { }): any;
handle(props: { }): any;
end(props: { }): any;
}>();
const dragging = ref(false);
const rootRef = ref<HTMLElement>();
import { clampPercent, percentFromPointer, nudge } from './internal/resizeMath';
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current size, normalized + clamped. Plain function (called in template
// bindings AND handlers) — never $computed (a $computed is a value on React but
// an accessor on Solid; a plain fn reads uniformly).
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current size, normalized + clamped. Plain function (called in template
// bindings AND handlers) — never $computed (a $computed is a value on React but
// an accessor on Solid; a plain fn reads uniformly).
const currentSize = () => {
const raw = typeof size.value === 'number' ? size.value : props.min;
return clampPercent(raw, props.min, props.max);
};
const isVertical = () => props.direction === 'vertical';
// Inline CSS custom property positioning the panels. Read BARE in the template
// via :style — a plain object literal, recomputed each render.
// Inline CSS custom property positioning the panels. Read BARE in the template
// via :style — a plain object literal, recomputed each render.
const sizeStyle = () => ({
'--rozie-resizable-size': currentSize() + '%'
});
// ---- write funnel (single $emit site) ----------------------------------
// Clamp, write the model, emit resize. The SOLE $emit('resize') site so the
// React prop-destructure for onResize hoists exactly once.
// ---- write funnel (single $emit site) ----------------------------------
// Clamp, write the model, emit resize. The SOLE $emit('resize') site so the
// React prop-destructure for onResize hoists exactly once.
const commitSize = (raw: any) => {
const next = clampPercent(raw, props.min, props.max);
size.value = next;
emit('resize', {
size: next
});
};
// ---- pointer drag (template @event + pointer capture) ------------------
// $refs.root is post-mount-only (ROZ123-safe): read inside these handlers.
// ---- pointer drag (template @event + pointer capture) ------------------
// $refs.root is post-mount-only (ROZ123-safe): read inside these handlers.
const onPointerDown = (e: any) => {
if (props.disabled) return;
if (e && e.preventDefault) e.preventDefault();
dragging.value = true;
// Capture the pointer on the handle so move/up keep firing on it even when the
// pointer leaves the handle mid-drag.
if (e && e.currentTarget && e.currentTarget.setPointerCapture && e.pointerId != null) {
e.currentTarget.setPointerCapture(e.pointerId);
}
};
const onPointerMove = (e: any) => {
if (!dragging.value || props.disabled) return;
const root = rootRef.value;
if (!root) return;
const rect = root.getBoundingClientRect();
const pct = isVertical() ? percentFromPointer(e.clientY, rect.top, rect.height) : percentFromPointer(e.clientX, rect.left, rect.width);
commitSize(pct);
};
const onPointerUp = (e: any) => {
if (!dragging.value) return;
dragging.value = false;
if (e && e.currentTarget && e.currentTarget.releasePointerCapture && e.pointerId != null) {
if (e.currentTarget.hasPointerCapture && e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
};
// ---- keyboard (role="separator") ---------------------------------------
// Arrow keys nudge by 1% (toward/away from the start panel along the axis);
// Home/End jump to min/max. Matches the WAI-ARIA window-splitter pattern.
// ---- keyboard (role="separator") ---------------------------------------
// Arrow keys nudge by 1% (toward/away from the start panel along the axis);
// Home/End jump to min/max. Matches the WAI-ARIA window-splitter pattern.
const onKeydown = (e: any) => {
if (props.disabled) return;
const key = e ? e.key : '';
const vertical = isVertical();
const decKey = vertical ? 'ArrowUp' : 'ArrowLeft';
const incKey = vertical ? 'ArrowDown' : 'ArrowRight';
if (key === decKey) {
if (e) e.preventDefault();
commitSize(nudge(currentSize(), -1, props.min, props.max));
} else if (key === incKey) {
if (e) e.preventDefault();
commitSize(nudge(currentSize(), 1, props.min, props.max));
} else if (key === 'Home') {
if (e) e.preventDefault();
commitSize(props.min);
} else if (key === 'End') {
if (e) e.preventDefault();
commitSize(props.max);
}
};
// ---- imperative handle -------------------------------------------------
// applySize(percent) — set the split programmatically (clamped + emits resize).
// reset() — recentre to the midpoint of [min, max].
// COLLISION NOTE: the verb is `applySize`, NOT the natural `setSize` — the model
// prop is `size`, so the React emitter auto-generates a `setSize` state setter.
// A `$expose` verb named `setSize` collapses onto that setter ident and trips
// ROZ524 (it fires as an INTERNAL diagnostic because the Phase-46 deconfliction
// pass does NOT rename inside an `$expose`-verb closure — see the emitter-gap
// note in the family README). `apply<X>` is the listbox/data-table precedent for
// dodging a generated React setter. It is also NOT `resize` (→ ROZ121 emit clash)
// and NOT a host-element member.
// ---- imperative handle -------------------------------------------------
// applySize(percent) — set the split programmatically (clamped + emits resize).
// reset() — recentre to the midpoint of [min, max].
// COLLISION NOTE: the verb is `applySize`, NOT the natural `setSize` — the model
// prop is `size`, so the React emitter auto-generates a `setSize` state setter.
// A `$expose` verb named `setSize` collapses onto that setter ident and trips
// ROZ524 (it fires as an INTERNAL diagnostic because the Phase-46 deconfliction
// pass does NOT rename inside an `$expose`-verb closure — see the emitter-gap
// note in the family README). `apply<X>` is the listbox/data-table precedent for
// dodging a generated React setter. It is also NOT `resize` (→ ROZ121 emit clash)
// and NOT a host-element member.
const applySize = (percent: any) => commitSize(percent);
const reset = () => commitSize((props.min + props.max) / 2);
defineExpose({ applySize, reset });
</script>
<style scoped>
.rozie-resizable {
display: flex;
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
font: var(--rozie-resizable-font, inherit);
}
.rozie-resizable--horizontal {
flex-direction: row;
}
.rozie-resizable--vertical {
flex-direction: column;
}
.rozie-resizable-panel {
box-sizing: border-box;
overflow: auto;
}
.rozie-resizable-panel--start {
flex: 0 0 auto;
}
.rozie-resizable--horizontal .rozie-resizable-panel--start {
width: var(--rozie-resizable-size, 50%);
height: 100%;
}
.rozie-resizable--vertical .rozie-resizable-panel--start {
height: var(--rozie-resizable-size, 50%);
width: 100%;
}
.rozie-resizable-panel--end {
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.rozie-resizable-handle {
flex: 0 0 var(--rozie-resizable-handle-size, 0.5rem);
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--rozie-resizable-handle-bg, rgba(0, 0, 0, 0.08));
outline: none;
transition: background-color 0.15s;
touch-action: none;
}
.rozie-resizable--horizontal .rozie-resizable-handle {
cursor: col-resize;
align-self: stretch;
}
.rozie-resizable--vertical .rozie-resizable-handle {
cursor: row-resize;
}
.rozie-resizable-handle:hover {
background: var(--rozie-resizable-handle-hover-bg, rgba(0, 0, 0, 0.16));
}
.rozie-resizable-handle:focus-visible {
box-shadow: 0 0 0 var(--rozie-resizable-focus-ring-width, 2px)
var(--rozie-resizable-focus-ring-color, rgba(0, 102, 204, 0.5));
z-index: 1;
}
.rozie-resizable--dragging .rozie-resizable-handle {
background: var(--rozie-resizable-handle-active-bg, var(--rozie-resizable-accent, #0066cc));
}
.rozie-resizable-grip {
display: block;
border-radius: 999px;
background: var(--rozie-resizable-grip-bg, rgba(0, 0, 0, 0.35));
}
.rozie-resizable--horizontal .rozie-resizable-grip {
width: var(--rozie-resizable-grip-thickness, 2px);
height: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--vertical .rozie-resizable-grip {
height: var(--rozie-resizable-grip-thickness, 2px);
width: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--disabled .rozie-resizable-handle {
cursor: default;
opacity: var(--rozie-resizable-disabled-opacity, 0.55);
}
</style>svelte
<script lang="ts">
import { applyListeners, rozieAttr, rozieStyle } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
* The first (`start`) panel's size as a percent of the container along the split axis (its width when `direction="horizontal"`, its height when `"vertical"`). Two-way via `r-model:size`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so the splitter position **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Every commit (drag, keyboard, or a programmatic `applySize`) is clamped to `[min, max]` and written back.
* @example
* <Resizable r-model:size="split" :min="20" :max="80" direction="horizontal" />
*/
size?: number;
/**
* The split axis. `'horizontal'` (default) lays the two panels out side-by-side with a vertical drag handle between them (`size` is the first panel's **width**); `'vertical'` stacks them with a horizontal handle (`size` is the first panel's **height**). Also sets the handle's `aria-orientation`.
*/
direction?: string;
/**
* The minimum `size` percent — the first panel can never be dragged or nudged below this. Clamps every commit.
*/
min?: number;
/**
* The maximum `size` percent — the first panel can never be dragged or nudged beyond this (so the second panel keeps at least `100 - max` percent). Clamps every commit.
*/
max?: number;
/**
* Disable resizing — the handle becomes non-interactive (pointer drag and keyboard are ignored) and the panels lock at the current `size`. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
start?: Snippet;
handle?: Snippet;
end?: Snippet;
snippets?: Record<string, any>;
onresize?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let {
size = $bindable(50),
direction = 'horizontal',
min = 10,
max = 90,
disabled = false,
start: __startProp,
handle: __handleProp,
end: __endProp,
snippets,
onresize,
...__rozieAttrs
}: Props = $props();
const start = $derived(__startProp ?? snippets?.start);
const handle = $derived(__handleProp ?? snippets?.handle);
const end = $derived(__endProp ?? snippets?.end);
let dragging = $state(false);
let root = $state<HTMLElement | undefined>(undefined);
import { clampPercent, percentFromPointer, nudge } from './internal/resizeMath';
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current size, normalized + clamped. Plain function (called in template
// bindings AND handlers) — never $computed (a $computed is a value on React but
// an accessor on Solid; a plain fn reads uniformly).
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current size, normalized + clamped. Plain function (called in template
// bindings AND handlers) — never $computed (a $computed is a value on React but
// an accessor on Solid; a plain fn reads uniformly).
const currentSize = () => {
const raw = typeof size === 'number' ? size : min;
return clampPercent(raw, min, max);
};
const isVertical = () => direction === 'vertical';
// Inline CSS custom property positioning the panels. Read BARE in the template
// via :style — a plain object literal, recomputed each render.
// Inline CSS custom property positioning the panels. Read BARE in the template
// via :style — a plain object literal, recomputed each render.
const sizeStyle = () => ({
'--rozie-resizable-size': currentSize() + '%'
});
// ---- write funnel (single $emit site) ----------------------------------
// Clamp, write the model, emit resize. The SOLE $emit('resize') site so the
// React prop-destructure for onResize hoists exactly once.
// ---- write funnel (single $emit site) ----------------------------------
// Clamp, write the model, emit resize. The SOLE $emit('resize') site so the
// React prop-destructure for onResize hoists exactly once.
const commitSize = (raw: any) => {
const next = clampPercent(raw, min, max);
size = next;
onresize?.({
size: next
});
};
// ---- pointer drag (template @event + pointer capture) ------------------
// $refs.root is post-mount-only (ROZ123-safe): read inside these handlers.
// ---- pointer drag (template @event + pointer capture) ------------------
// $refs.root is post-mount-only (ROZ123-safe): read inside these handlers.
const onPointerDown = (e: any) => {
if (disabled) return;
if (e && e.preventDefault) e.preventDefault();
dragging = true;
// Capture the pointer on the handle so move/up keep firing on it even when the
// pointer leaves the handle mid-drag.
if (e && e.currentTarget && e.currentTarget.setPointerCapture && e.pointerId != null) {
e.currentTarget.setPointerCapture(e.pointerId);
}
};
const onPointerMove = (e: any) => {
if (!dragging || disabled) return;
const root$local = root;
if (!root$local) return;
const rect = root$local.getBoundingClientRect();
const pct = isVertical() ? percentFromPointer(e.clientY, rect.top, rect.height) : percentFromPointer(e.clientX, rect.left, rect.width);
commitSize(pct);
};
const onPointerUp = (e: any) => {
if (!dragging) return;
dragging = false;
if (e && e.currentTarget && e.currentTarget.releasePointerCapture && e.pointerId != null) {
if (e.currentTarget.hasPointerCapture && e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
};
// ---- keyboard (role="separator") ---------------------------------------
// Arrow keys nudge by 1% (toward/away from the start panel along the axis);
// Home/End jump to min/max. Matches the WAI-ARIA window-splitter pattern.
// ---- keyboard (role="separator") ---------------------------------------
// Arrow keys nudge by 1% (toward/away from the start panel along the axis);
// Home/End jump to min/max. Matches the WAI-ARIA window-splitter pattern.
const onKeydown = (e: any) => {
if (disabled) return;
const key = e ? e.key : '';
const vertical = isVertical();
const decKey = vertical ? 'ArrowUp' : 'ArrowLeft';
const incKey = vertical ? 'ArrowDown' : 'ArrowRight';
if (key === decKey) {
if (e) e.preventDefault();
commitSize(nudge(currentSize(), -1, min, max));
} else if (key === incKey) {
if (e) e.preventDefault();
commitSize(nudge(currentSize(), 1, min, max));
} else if (key === 'Home') {
if (e) e.preventDefault();
commitSize(min);
} else if (key === 'End') {
if (e) e.preventDefault();
commitSize(max);
}
};
// ---- imperative handle -------------------------------------------------
// applySize(percent) — set the split programmatically (clamped + emits resize).
// reset() — recentre to the midpoint of [min, max].
// COLLISION NOTE: the verb is `applySize`, NOT the natural `setSize` — the model
// prop is `size`, so the React emitter auto-generates a `setSize` state setter.
// A `$expose` verb named `setSize` collapses onto that setter ident and trips
// ROZ524 (it fires as an INTERNAL diagnostic because the Phase-46 deconfliction
// pass does NOT rename inside an `$expose`-verb closure — see the emitter-gap
// note in the family README). `apply<X>` is the listbox/data-table precedent for
// dodging a generated React setter. It is also NOT `resize` (→ ROZ121 emit clash)
// and NOT a host-element member.
// ---- imperative handle -------------------------------------------------
// applySize(percent) — set the split programmatically (clamped + emits resize).
// reset() — recentre to the midpoint of [min, max].
// COLLISION NOTE: the verb is `applySize`, NOT the natural `setSize` — the model
// prop is `size`, so the React emitter auto-generates a `setSize` state setter.
// A `$expose` verb named `setSize` collapses onto that setter ident and trips
// ROZ524 (it fires as an INTERNAL diagnostic because the Phase-46 deconfliction
// pass does NOT rename inside an `$expose`-verb closure — see the emitter-gap
// note in the family README). `apply<X>` is the listbox/data-table precedent for
// dodging a generated React setter. It is also NOT `resize` (→ ROZ121 emit clash)
// and NOT a host-element member.
export const applySize = (percent: any) => commitSize(percent);
export const reset = () => commitSize((min + max) / 2);
</script>
<div bind:this={root} style={rozieStyle(sizeStyle())} {...__rozieAttrs} class={["rozie-resizable", { 'rozie-resizable--vertical': isVertical(), 'rozie-resizable--horizontal': !isVertical(), 'rozie-resizable--dragging': dragging, 'rozie-resizable--disabled': disabled }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-8330bc5a><div class="rozie-resizable-panel rozie-resizable-panel--start" data-rozie-s-8330bc5a>{@render start?.()}</div><div class="rozie-resizable-handle" role="separator" tabindex="0" aria-orientation={rozieAttr(isVertical() ? 'horizontal' : 'vertical')} aria-valuenow={size} aria-valuemin={min} aria-valuemax={max} aria-disabled={!!disabled} onpointerdown={($event) => { onPointerDown($event); }} onpointermove={($event) => { onPointerMove($event); }} onpointerup={($event) => { onPointerUp($event); }} onkeydown={($event) => { onKeydown($event); }} data-rozie-s-8330bc5a>{#if handle}{@render handle()}{:else}<span class="rozie-resizable-grip" aria-hidden="true" data-rozie-s-8330bc5a></span>{/if}</div><div class="rozie-resizable-panel rozie-resizable-panel--end" data-rozie-s-8330bc5a>{@render end?.()}</div></div>
<style>
:global {
.rozie-resizable[data-rozie-s-8330bc5a] {
display: flex;
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
font: var(--rozie-resizable-font, inherit);
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] {
flex-direction: row;
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] {
flex-direction: column;
}
.rozie-resizable-panel[data-rozie-s-8330bc5a] {
box-sizing: border-box;
overflow: auto;
}
.rozie-resizable-panel--start[data-rozie-s-8330bc5a] {
flex: 0 0 auto;
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] .rozie-resizable-panel--start[data-rozie-s-8330bc5a] {
width: var(--rozie-resizable-size, 50%);
height: 100%;
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] .rozie-resizable-panel--start[data-rozie-s-8330bc5a] {
height: var(--rozie-resizable-size, 50%);
width: 100%;
}
.rozie-resizable-panel--end[data-rozie-s-8330bc5a] {
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.rozie-resizable-handle[data-rozie-s-8330bc5a] {
flex: 0 0 var(--rozie-resizable-handle-size, 0.5rem);
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--rozie-resizable-handle-bg, rgba(0, 0, 0, 0.08));
outline: none;
transition: background-color 0.15s;
touch-action: none;
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
cursor: col-resize;
align-self: stretch;
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
cursor: row-resize;
}
.rozie-resizable-handle[data-rozie-s-8330bc5a]:hover {
background: var(--rozie-resizable-handle-hover-bg, rgba(0, 0, 0, 0.16));
}
.rozie-resizable-handle[data-rozie-s-8330bc5a]:focus-visible {
box-shadow: 0 0 0 var(--rozie-resizable-focus-ring-width, 2px)
var(--rozie-resizable-focus-ring-color, rgba(0, 102, 204, 0.5));
z-index: 1;
}
.rozie-resizable--dragging[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
background: var(--rozie-resizable-handle-active-bg, var(--rozie-resizable-accent, #0066cc));
}
.rozie-resizable-grip[data-rozie-s-8330bc5a] {
display: block;
border-radius: 999px;
background: var(--rozie-resizable-grip-bg, rgba(0, 0, 0, 0.35));
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] .rozie-resizable-grip[data-rozie-s-8330bc5a] {
width: var(--rozie-resizable-grip-thickness, 2px);
height: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] .rozie-resizable-grip[data-rozie-s-8330bc5a] {
height: var(--rozie-resizable-grip-thickness, 2px);
width: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--disabled[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
cursor: default;
opacity: var(--rozie-resizable-disabled-opacity, 0.55);
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { clampPercent, percentFromPointer, nudge } from './internal/resizeMath';
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current size, normalized + clamped. Plain function (called in template
// bindings AND handlers) — never $computed (a $computed is a value on React but
// an accessor on Solid; a plain fn reads uniformly).
interface StartCtx {}
interface HandleCtx {}
interface EndCtx {}
function __rozieDisplay(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
try {
return JSON.stringify(v, null, 2);
} catch {
// Circular structure or a non-serialisable value (BigInt nested in an
// object). Degrade to a non-throwing form so the wrap never crashes the
// render — that is the entire point of "safe" interpolation (SPEC-1).
return String(v);
}
}
return String(v);
}
function __rozieAttr(v: unknown): string | null {
return v == null ? null : __rozieDisplay(v);
}
@Component({
selector: 'rozie-resizable',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<div class="rozie-resizable" [ngClass]="{ 'rozie-resizable--vertical': isVertical(), 'rozie-resizable--horizontal': !isVertical(), 'rozie-resizable--dragging': dragging(), 'rozie-resizable--disabled': (disabled() || this.__rozieCvaDisabled()) }" #root [style]="sizeStyle()" #rozieSpread_0 #rozieListenersTarget_1>
<div class="rozie-resizable-panel rozie-resizable-panel--start">
<ng-container *ngTemplateOutlet="(startTpl ?? templates()?.['start'])" />
</div>
<div class="rozie-resizable-handle" role="separator" tabindex="0" [attr.aria-orientation]="rozieAttr(isVertical() ? 'horizontal' : 'vertical')" [attr.aria-valuenow]="size()" [attr.aria-valuemin]="min()" [attr.aria-valuemax]="max()" [attr.aria-disabled]="!!(disabled() || this.__rozieCvaDisabled())" (pointerdown)="onPointerDown($event)" (pointermove)="onPointerMove($event)" (pointerup)="onPointerUp($event)" (keydown)="onKeydown($event)">
@if ((handleTpl ?? templates()?.['handle'])) {
<ng-container *ngTemplateOutlet="(handleTpl ?? templates()?.['handle'])" />
} @else {
<span class="rozie-resizable-grip" aria-hidden="true"></span>
}
</div>
<div class="rozie-resizable-panel rozie-resizable-panel--end">
<ng-container *ngTemplateOutlet="(endTpl ?? templates()?.['end'])" />
</div>
</div>
`,
styles: [`
.rozie-resizable {
display: flex;
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
font: var(--rozie-resizable-font, inherit);
}
.rozie-resizable--horizontal {
flex-direction: row;
}
.rozie-resizable--vertical {
flex-direction: column;
}
.rozie-resizable-panel {
box-sizing: border-box;
overflow: auto;
}
.rozie-resizable-panel--start {
flex: 0 0 auto;
}
.rozie-resizable--horizontal .rozie-resizable-panel--start {
width: var(--rozie-resizable-size, 50%);
height: 100%;
}
.rozie-resizable--vertical .rozie-resizable-panel--start {
height: var(--rozie-resizable-size, 50%);
width: 100%;
}
.rozie-resizable-panel--end {
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.rozie-resizable-handle {
flex: 0 0 var(--rozie-resizable-handle-size, 0.5rem);
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--rozie-resizable-handle-bg, rgba(0, 0, 0, 0.08));
outline: none;
transition: background-color 0.15s;
touch-action: none;
}
.rozie-resizable--horizontal .rozie-resizable-handle {
cursor: col-resize;
align-self: stretch;
}
.rozie-resizable--vertical .rozie-resizable-handle {
cursor: row-resize;
}
.rozie-resizable-handle:hover {
background: var(--rozie-resizable-handle-hover-bg, rgba(0, 0, 0, 0.16));
}
.rozie-resizable-handle:focus-visible {
box-shadow: 0 0 0 var(--rozie-resizable-focus-ring-width, 2px)
var(--rozie-resizable-focus-ring-color, rgba(0, 102, 204, 0.5));
z-index: 1;
}
.rozie-resizable--dragging .rozie-resizable-handle {
background: var(--rozie-resizable-handle-active-bg, var(--rozie-resizable-accent, #0066cc));
}
.rozie-resizable-grip {
display: block;
border-radius: 999px;
background: var(--rozie-resizable-grip-bg, rgba(0, 0, 0, 0.35));
}
.rozie-resizable--horizontal .rozie-resizable-grip {
width: var(--rozie-resizable-grip-thickness, 2px);
height: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--vertical .rozie-resizable-grip {
height: var(--rozie-resizable-grip-thickness, 2px);
width: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--disabled .rozie-resizable-handle {
cursor: default;
opacity: var(--rozie-resizable-disabled-opacity, 0.55);
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Resizable),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Resizable {
/**
* The first (`start`) panel's size as a percent of the container along the split axis (its width when `direction="horizontal"`, its height when `"vertical"`). Two-way via `r-model:size`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so the splitter position **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Every commit (drag, keyboard, or a programmatic `applySize`) is clamped to `[min, max]` and written back.
* @example
* <Resizable r-model:size="split" :min="20" :max="80" direction="horizontal" />
*/
size = model<number>(50);
/**
* The split axis. `'horizontal'` (default) lays the two panels out side-by-side with a vertical drag handle between them (`size` is the first panel's **width**); `'vertical'` stacks them with a horizontal handle (`size` is the first panel's **height**). Also sets the handle's `aria-orientation`.
*/
direction = input<string>('horizontal');
/**
* The minimum `size` percent — the first panel can never be dragged or nudged below this. Clamps every commit.
*/
min = input<number>(10);
/**
* The maximum `size` percent — the first panel can never be dragged or nudged beyond this (so the second panel keeps at least `100 - max` percent). Clamps every commit.
*/
max = input<number>(90);
/**
* Disable resizing — the handle becomes non-interactive (pointer drag and keyboard are ignored) and the panels lock at the current `size`. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled = input<boolean>(false);
dragging = signal(false);
root = viewChild<ElementRef<HTMLDivElement>>('root');
resize = output<unknown>();
@ContentChild('start', { read: TemplateRef }) startTpl?: TemplateRef<StartCtx>;
@ContentChild('handle', { read: TemplateRef }) handleTpl?: TemplateRef<HandleCtx>;
@ContentChild('end', { read: TemplateRef }) endTpl?: TemplateRef<EndCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
currentSize = () => {
const __size = this.size();
const __min = this.min();
const raw = typeof __size === 'number' ? __size : __min;
return clampPercent(raw, __min, this.max());
};
isVertical = () => this.direction() === 'vertical';
sizeStyle = () => ({
'--rozie-resizable-size': this.currentSize() + '%'
});
commitSize = (raw: any) => {
const next = clampPercent(raw, this.min(), this.max());
this.size.set(next), this.__rozieCvaOnChange(next);
this.resize.emit({
size: next
});
};
onPointerDown = (e: any) => {
if ((this.disabled() || this.__rozieCvaDisabled())) return;
if (e && e.preventDefault) e.preventDefault();
this.dragging.set(true);
// Capture the pointer on the handle so move/up keep firing on it even when the
// pointer leaves the handle mid-drag.
if (e && e.currentTarget && e.currentTarget.setPointerCapture && e.pointerId != null) {
e.currentTarget.setPointerCapture(e.pointerId);
}
};
onPointerMove = (e: any) => {
if (!this.dragging() || (this.disabled() || this.__rozieCvaDisabled())) return;
const root = this.root()?.nativeElement;
if (!root) return;
const rect = root.getBoundingClientRect();
const pct = this.isVertical() ? percentFromPointer(e.clientY, rect.top, rect.height) : percentFromPointer(e.clientX, rect.left, rect.width);
this.commitSize(pct);
};
onPointerUp = (e: any) => {
if (!this.dragging()) return;
this.dragging.set(false);
if (e && e.currentTarget && e.currentTarget.releasePointerCapture && e.pointerId != null) {
if (e.currentTarget.hasPointerCapture && e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
};
onKeydown = (e: any) => {
const __min = this.min();
const __max = this.max();
if ((this.disabled() || this.__rozieCvaDisabled())) return;
const key = e ? e.key : '';
const vertical = this.isVertical();
const decKey = vertical ? 'ArrowUp' : 'ArrowLeft';
const incKey = vertical ? 'ArrowDown' : 'ArrowRight';
if (key === decKey) {
if (e) e.preventDefault();
this.commitSize(nudge(this.currentSize(), -1, __min, __max));
} else if (key === incKey) {
if (e) e.preventDefault();
this.commitSize(nudge(this.currentSize(), 1, __min, __max));
} else if (key === 'Home') {
if (e) e.preventDefault();
this.commitSize(__min);
} else if (key === 'End') {
if (e) e.preventDefault();
this.commitSize(__max);
}
};
applySize = (percent: any) => this.commitSize(percent);
reset = () => this.commitSize((this.min() + this.max()) / 2);
private __rozieCvaOnChange: (v: number) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: number | null): void {
this.size.set(v ?? 50);
}
registerOnChange(fn: (v: number) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
static ngTemplateContextGuard(
_dir: Resizable,
_ctx: unknown,
): _ctx is StartCtx | HandleCtx | EndCtx {
return true;
}
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default Resizable;tsx
import type { JSX } from 'solid-js';
import { createSignal, mergeProps, onMount, splitProps } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, parseInlineStyle, rozieAttr, rozieClass } from '@rozie/runtime-solid';
import { clampPercent, percentFromPointer, nudge } from './internal/resizeMath';
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current size, normalized + clamped. Plain function (called in template
// bindings AND handlers) — never $computed (a $computed is a value on React but
// an accessor on Solid; a plain fn reads uniformly).
__rozieInjectStyle('Resizable-8330bc5a', `.rozie-resizable[data-rozie-s-8330bc5a] {
display: flex;
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
font: var(--rozie-resizable-font, inherit);
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] {
flex-direction: row;
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] {
flex-direction: column;
}
.rozie-resizable-panel[data-rozie-s-8330bc5a] {
box-sizing: border-box;
overflow: auto;
}
.rozie-resizable-panel--start[data-rozie-s-8330bc5a] {
flex: 0 0 auto;
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] .rozie-resizable-panel--start[data-rozie-s-8330bc5a] {
width: var(--rozie-resizable-size, 50%);
height: 100%;
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] .rozie-resizable-panel--start[data-rozie-s-8330bc5a] {
height: var(--rozie-resizable-size, 50%);
width: 100%;
}
.rozie-resizable-panel--end[data-rozie-s-8330bc5a] {
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.rozie-resizable-handle[data-rozie-s-8330bc5a] {
flex: 0 0 var(--rozie-resizable-handle-size, 0.5rem);
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--rozie-resizable-handle-bg, rgba(0, 0, 0, 0.08));
outline: none;
transition: background-color 0.15s;
touch-action: none;
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
cursor: col-resize;
align-self: stretch;
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
cursor: row-resize;
}
.rozie-resizable-handle[data-rozie-s-8330bc5a]:hover {
background: var(--rozie-resizable-handle-hover-bg, rgba(0, 0, 0, 0.16));
}
.rozie-resizable-handle[data-rozie-s-8330bc5a]:focus-visible {
box-shadow: 0 0 0 var(--rozie-resizable-focus-ring-width, 2px)
var(--rozie-resizable-focus-ring-color, rgba(0, 102, 204, 0.5));
z-index: 1;
}
.rozie-resizable--dragging[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
background: var(--rozie-resizable-handle-active-bg, var(--rozie-resizable-accent, #0066cc));
}
.rozie-resizable-grip[data-rozie-s-8330bc5a] {
display: block;
border-radius: 999px;
background: var(--rozie-resizable-grip-bg, rgba(0, 0, 0, 0.35));
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] .rozie-resizable-grip[data-rozie-s-8330bc5a] {
width: var(--rozie-resizable-grip-thickness, 2px);
height: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] .rozie-resizable-grip[data-rozie-s-8330bc5a] {
height: var(--rozie-resizable-grip-thickness, 2px);
width: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--disabled[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
cursor: default;
opacity: var(--rozie-resizable-disabled-opacity, 0.55);
}`);
interface ResizableProps {
/**
* The first (`start`) panel's size as a percent of the container along the split axis (its width when `direction="horizontal"`, its height when `"vertical"`). Two-way via `r-model:size`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so the splitter position **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Every commit (drag, keyboard, or a programmatic `applySize`) is clamped to `[min, max]` and written back.
* @example
* <Resizable r-model:size="split" :min="20" :max="80" direction="horizontal" />
*/
size?: number;
defaultSize?: number;
onSizeChange?: (size: number) => void;
/**
* The split axis. `'horizontal'` (default) lays the two panels out side-by-side with a vertical drag handle between them (`size` is the first panel's **width**); `'vertical'` stacks them with a horizontal handle (`size` is the first panel's **height**). Also sets the handle's `aria-orientation`.
*/
direction?: string;
/**
* The minimum `size` percent — the first panel can never be dragged or nudged below this. Clamps every commit.
*/
min?: number;
/**
* The maximum `size` percent — the first panel can never be dragged or nudged beyond this (so the second panel keeps at least `100 - max` percent). Clamps every commit.
*/
max?: number;
/**
* Disable resizing — the handle becomes non-interactive (pointer drag and keyboard are ignored) and the panels lock at the current `size`. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
onResize?: (...args: unknown[]) => void;
startSlot?: JSX.Element;
handleSlot?: JSX.Element;
endSlot?: JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: ResizableHandle) => void;
}
export interface ResizableHandle {
applySize: (...args: any[]) => any;
reset: (...args: any[]) => any;
}
export default function Resizable(_props: ResizableProps): JSX.Element {
const _merged = mergeProps({ direction: 'horizontal', min: 10, max: 90, disabled: false }, _props);
const [local, attrs] = splitProps(_merged, ['size', 'direction', 'min', 'max', 'disabled', 'ref']);
onMount(() => { local.ref?.({ applySize, reset }); });
const [size, setSize] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'size', 50);
const [dragging, setDragging] = createSignal(false);
let rootRef: HTMLElement | null = null;
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current size, normalized + clamped. Plain function (called in template
// bindings AND handlers) — never $computed (a $computed is a value on React but
// an accessor on Solid; a plain fn reads uniformly).
function currentSize() {
const raw = typeof size() === 'number' ? size() : local.min;
return clampPercent(raw, local.min, local.max);
}
function isVertical() {
return local.direction === 'vertical';
}
// Inline CSS custom property positioning the panels. Read BARE in the template
// via :style — a plain object literal, recomputed each render.
function sizeStyle() {
return {
'--rozie-resizable-size': currentSize() + '%'
};
}
// ---- write funnel (single $emit site) ----------------------------------
// Clamp, write the model, emit resize. The SOLE $emit('resize') site so the
// React prop-destructure for onResize hoists exactly once.
function commitSize(raw: any) {
const next = clampPercent(raw, local.min, local.max);
setSize(next);
_props.onResize?.({
size: next
});
}
// ---- pointer drag (template @event + pointer capture) ------------------
// $refs.root is post-mount-only (ROZ123-safe): read inside these handlers.
function onPointerDown(e: any) {
if (local.disabled) return;
if (e && e.preventDefault) e.preventDefault();
setDragging(true);
// Capture the pointer on the handle so move/up keep firing on it even when the
// pointer leaves the handle mid-drag.
if (e && e.currentTarget && e.currentTarget.setPointerCapture && e.pointerId != null) {
e.currentTarget.setPointerCapture(e.pointerId);
}
}
function onPointerMove(e: any) {
if (!dragging() || local.disabled) return;
const root = rootRef;
if (!root) return;
const rect = root.getBoundingClientRect();
const pct = isVertical() ? percentFromPointer(e.clientY, rect.top, rect.height) : percentFromPointer(e.clientX, rect.left, rect.width);
commitSize(pct);
}
function onPointerUp(e: any) {
if (!dragging()) return;
setDragging(false);
if (e && e.currentTarget && e.currentTarget.releasePointerCapture && e.pointerId != null) {
if (e.currentTarget.hasPointerCapture && e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
}
// ---- keyboard (role="separator") ---------------------------------------
// Arrow keys nudge by 1% (toward/away from the start panel along the axis);
// Home/End jump to min/max. Matches the WAI-ARIA window-splitter pattern.
function onKeydown(e: any) {
if (local.disabled) return;
const key = e ? e.key : '';
const vertical = isVertical();
const decKey = vertical ? 'ArrowUp' : 'ArrowLeft';
const incKey = vertical ? 'ArrowDown' : 'ArrowRight';
if (key === decKey) {
if (e) e.preventDefault();
commitSize(nudge(currentSize(), -1, local.min, local.max));
} else if (key === incKey) {
if (e) e.preventDefault();
commitSize(nudge(currentSize(), 1, local.min, local.max));
} else if (key === 'Home') {
if (e) e.preventDefault();
commitSize(local.min);
} else if (key === 'End') {
if (e) e.preventDefault();
commitSize(local.max);
}
}
// ---- imperative handle -------------------------------------------------
// applySize(percent) — set the split programmatically (clamped + emits resize).
// reset() — recentre to the midpoint of [min, max].
// COLLISION NOTE: the verb is `applySize`, NOT the natural `setSize` — the model
// prop is `size`, so the React emitter auto-generates a `setSize` state setter.
// A `$expose` verb named `setSize` collapses onto that setter ident and trips
// ROZ524 (it fires as an INTERNAL diagnostic because the Phase-46 deconfliction
// pass does NOT rename inside an `$expose`-verb closure — see the emitter-gap
// note in the family README). `apply<X>` is the listbox/data-table precedent for
// dodging a generated React setter. It is also NOT `resize` (→ ROZ121 emit clash)
// and NOT a host-element member.
function applySize(percent: any) {
return commitSize(percent);
}
function reset() {
return commitSize((local.min + local.max) / 2);
}
return (
<>
<div ref={(el) => { rootRef = el as HTMLElement; }} style={parseInlineStyle(sizeStyle())} {...attrs} class={"rozie-resizable" + " " + rozieClass({ 'rozie-resizable--vertical': isVertical(), 'rozie-resizable--horizontal': !isVertical(), 'rozie-resizable--dragging': dragging(), 'rozie-resizable--disabled': local.disabled }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-8330bc5a="">
<div class={"rozie-resizable-panel rozie-resizable-panel--start"} data-rozie-s-8330bc5a="">
{(_props.startSlot ?? _props.slots?.['start']?.({}))}
</div>
<div role="separator" aria-orientation={rozieAttr(isVertical() ? 'horizontal' : 'vertical')} aria-valuenow={size()} aria-valuemin={local.min} aria-valuemax={local.max} aria-disabled={!!local.disabled} class={"rozie-resizable-handle"} tabIndex={0} onPointerDown={($event) => { onPointerDown($event); }} onPointerMove={($event) => { onPointerMove($event); }} onPointerUp={($event) => { onPointerUp($event); }} onKeyDown={($event) => { onKeydown($event); }} data-rozie-s-8330bc5a="">
{(_props.handleSlot ?? _props.slots?.['handle']?.({})) ?? <span class={"rozie-resizable-grip"} aria-hidden="true" data-rozie-s-8330bc5a="" />}
</div>
<div class={"rozie-resizable-panel rozie-resizable-panel--end"} data-rozie-s-8330bc5a="">
{(_props.endSlot ?? _props.slots?.['end']?.({}))}
</div>
</div>
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieListeners, rozieSpread, rozieStyle } from '@rozie/runtime-lit';
import { clampPercent, percentFromPointer, nudge } from './internal/resizeMath';
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current size, normalized + clamped. Plain function (called in template
// bindings AND handlers) — never $computed (a $computed is a value on React but
// an accessor on Solid; a plain fn reads uniformly).
@customElement('rozie-resizable')
export default class Resizable extends SignalWatcher(LitElement) {
static styles = css`
.rozie-resizable[data-rozie-s-8330bc5a] {
display: flex;
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
font: var(--rozie-resizable-font, inherit);
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] {
flex-direction: row;
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] {
flex-direction: column;
}
.rozie-resizable-panel[data-rozie-s-8330bc5a] {
box-sizing: border-box;
overflow: auto;
}
.rozie-resizable-panel--start[data-rozie-s-8330bc5a] {
flex: 0 0 auto;
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] .rozie-resizable-panel--start[data-rozie-s-8330bc5a] {
width: var(--rozie-resizable-size, 50%);
height: 100%;
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] .rozie-resizable-panel--start[data-rozie-s-8330bc5a] {
height: var(--rozie-resizable-size, 50%);
width: 100%;
}
.rozie-resizable-panel--end[data-rozie-s-8330bc5a] {
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.rozie-resizable-handle[data-rozie-s-8330bc5a] {
flex: 0 0 var(--rozie-resizable-handle-size, 0.5rem);
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--rozie-resizable-handle-bg, rgba(0, 0, 0, 0.08));
outline: none;
transition: background-color 0.15s;
touch-action: none;
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
cursor: col-resize;
align-self: stretch;
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
cursor: row-resize;
}
.rozie-resizable-handle[data-rozie-s-8330bc5a]:hover {
background: var(--rozie-resizable-handle-hover-bg, rgba(0, 0, 0, 0.16));
}
.rozie-resizable-handle[data-rozie-s-8330bc5a]:focus-visible {
box-shadow: 0 0 0 var(--rozie-resizable-focus-ring-width, 2px)
var(--rozie-resizable-focus-ring-color, rgba(0, 102, 204, 0.5));
z-index: 1;
}
.rozie-resizable--dragging[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
background: var(--rozie-resizable-handle-active-bg, var(--rozie-resizable-accent, #0066cc));
}
.rozie-resizable-grip[data-rozie-s-8330bc5a] {
display: block;
border-radius: 999px;
background: var(--rozie-resizable-grip-bg, rgba(0, 0, 0, 0.35));
}
.rozie-resizable--horizontal[data-rozie-s-8330bc5a] .rozie-resizable-grip[data-rozie-s-8330bc5a] {
width: var(--rozie-resizable-grip-thickness, 2px);
height: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--vertical[data-rozie-s-8330bc5a] .rozie-resizable-grip[data-rozie-s-8330bc5a] {
height: var(--rozie-resizable-grip-thickness, 2px);
width: var(--rozie-resizable-grip-length, 1.5rem);
}
.rozie-resizable--disabled[data-rozie-s-8330bc5a] .rozie-resizable-handle[data-rozie-s-8330bc5a] {
cursor: default;
opacity: var(--rozie-resizable-disabled-opacity, 0.55);
}
`;
/**
* The first (`start`) panel's size as a percent of the container along the split axis (its width when `direction="horizontal"`, its height when `"vertical"`). Two-way via `r-model:size`. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so the splitter position **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Every commit (drag, keyboard, or a programmatic `applySize`) is clamped to `[min, max]` and written back.
* @example
* <Resizable r-model:size="split" :min="20" :max="80" direction="horizontal" />
*/
@property({ type: Number, attribute: 'size' }) _size_attr: number = 50;
private _sizeControllable = createLitControllableProperty<number>({ host: this, eventName: 'size-change', defaultValue: 50, initialControlledValue: undefined });
/**
* The split axis. `'horizontal'` (default) lays the two panels out side-by-side with a vertical drag handle between them (`size` is the first panel's **width**); `'vertical'` stacks them with a horizontal handle (`size` is the first panel's **height**). Also sets the handle's `aria-orientation`.
*/
@property({ type: String, reflect: true }) direction: string = 'horizontal';
/**
* The minimum `size` percent — the first panel can never be dragged or nudged below this. Clamps every commit.
*/
@property({ type: Number, reflect: true }) min: number = 10;
/**
* The maximum `size` percent — the first panel can never be dragged or nudged beyond this (so the second panel keeps at least `100 - max` percent). Clamps every commit.
*/
@property({ type: Number, reflect: true }) max: number = 90;
/**
* Disable resizing — the handle becomes non-interactive (pointer drag and keyboard are ignored) and the panels lock at the current `size`. Also sets the Angular `ControlValueAccessor` disabled state.
*/
@property({ type: Boolean, reflect: true }) disabled: boolean = false;
private _dragging = signal(false);
@query('[data-rozie-ref="root"]') private _refRoot!: HTMLElement;
@state() private _hasSlotStart = false;
@queryAssignedElements({ slot: 'start', flatten: true }) private _slotStartElements!: Element[];
@state() private _hasSlotHandle = false;
@queryAssignedElements({ slot: 'handle', flatten: true }) private _slotHandleElements!: Element[];
@state() private _hasSlotEnd = false;
@queryAssignedElements({ slot: 'end', flatten: true }) private _slotEndElements!: Element[];
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot[name="start"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotStart = this._slotStartElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="handle"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotHandle = this._slotHandleElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="end"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotEnd = this._slotEndElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotStart = Array.from(this.children).some((el) => el.getAttribute('slot') === 'start');
this._hasSlotHandle = Array.from(this.children).some((el) => el.getAttribute('slot') === 'handle');
this._hasSlotEnd = Array.from(this.children).some((el) => el.getAttribute('slot') === 'end');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'size') this._sizeControllable.notifyAttributeChange(value === null ? 50 : Number(value));
}
render() {
return html`
<div class="${Object.entries({ "rozie-resizable": true, 'rozie-resizable--vertical': this.isVertical(), 'rozie-resizable--horizontal': !this.isVertical(), 'rozie-resizable--dragging': this._dragging.value, 'rozie-resizable--disabled': this.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" style=${rozieStyle(this.sizeStyle())} ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="root" data-rozie-s-8330bc5a>
<div class="rozie-resizable-panel rozie-resizable-panel--start" data-rozie-s-8330bc5a>
<slot name="start"></slot>
</div>
<div class="rozie-resizable-handle" role="separator" tabindex="0" aria-orientation=${rozieAttr(this.isVertical() ? 'horizontal' : 'vertical')} aria-valuenow=${this.size} aria-valuemin=${this.min} aria-valuemax=${this.max} aria-disabled=${!!this.disabled} @pointerdown=${($event: Event) => { this.onPointerDown($event); }} @pointermove=${($event: Event) => { this.onPointerMove($event); }} @pointerup=${($event: Event) => { this.onPointerUp($event); }} @keydown=${($event: Event) => { this.onKeydown($event); }} data-rozie-s-8330bc5a>
<slot name="handle">
<span class="rozie-resizable-grip" aria-hidden="true" data-rozie-s-8330bc5a></span>
</slot>
</div>
<div class="rozie-resizable-panel rozie-resizable-panel--end" data-rozie-s-8330bc5a>
<slot name="end"></slot>
</div>
</div>
`;
}
currentSize = () => {
const raw = typeof this.size === 'number' ? this.size : this.min;
return clampPercent(raw, this.min, this.max);
};
isVertical = () => this.direction === 'vertical';
sizeStyle = () => ({
'--rozie-resizable-size': this.currentSize() + '%'
});
commitSize = (raw: any) => {
const next = clampPercent(raw, this.min, this.max);
this._sizeControllable.write(next);
this.dispatchEvent(new CustomEvent("resize", {
detail: {
size: next
},
bubbles: true,
composed: true
}));
};
onPointerDown = (e: any) => {
if (this.disabled) return;
if (e && e.preventDefault) e.preventDefault();
this._dragging.value = true;
// Capture the pointer on the handle so move/up keep firing on it even when the
// pointer leaves the handle mid-drag.
if (e && e.currentTarget && e.currentTarget.setPointerCapture && e.pointerId != null) {
e.currentTarget.setPointerCapture(e.pointerId);
}
};
onPointerMove = (e: any) => {
if (!this._dragging.value || this.disabled) return;
const root = this._refRoot;
if (!root) return;
const rect = root.getBoundingClientRect();
const pct = this.isVertical() ? percentFromPointer(e.clientY, rect.top, rect.height) : percentFromPointer(e.clientX, rect.left, rect.width);
this.commitSize(pct);
};
onPointerUp = (e: any) => {
if (!this._dragging.value) return;
this._dragging.value = false;
if (e && e.currentTarget && e.currentTarget.releasePointerCapture && e.pointerId != null) {
if (e.currentTarget.hasPointerCapture && e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
};
onKeydown = (e: any) => {
if (this.disabled) return;
const key = e ? e.key : '';
const vertical = this.isVertical();
const decKey = vertical ? 'ArrowUp' : 'ArrowLeft';
const incKey = vertical ? 'ArrowDown' : 'ArrowRight';
if (key === decKey) {
if (e) e.preventDefault();
this.commitSize(nudge(this.currentSize(), -1, this.min, this.max));
} else if (key === incKey) {
if (e) e.preventDefault();
this.commitSize(nudge(this.currentSize(), 1, this.min, this.max));
} else if (key === 'Home') {
if (e) e.preventDefault();
this.commitSize(this.min);
} else if (key === 'End') {
if (e) e.preventDefault();
this.commitSize(this.max);
}
};
applySize = (percent: any) => this.commitSize(percent);
reset = () => this.commitSize((this.min + this.max) / 2);
get size(): number { return this._sizeControllable.read(); }
set size(v: number) { this._sizeControllable.notifyPropertyWrite(v); }
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['size', 'direction', 'min', 'max', 'disabled']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component (with ControlValueAccessor), a Solid component, and a Lit custom element. Same props, same resize event, same two-way size, same imperative handle, same slots — all from the one source above, built on native Pointer Events with no third-party engine behind it.
See also
- Resizable — showcase & API — install, quick start, theming, keyboard, and the full reference.
- Headless split-pane comparison — how
@rozie-ui/resizablestacks up against react-resizable-panels, splitpanes, and the per-framework split-pane libraries.