Appearance
SortableList (drag & drop)
A data-bound port of SortableJS. SortableJS reorders DOM nodes directly on drop — which is precisely what every framework's reconciler then fights, producing snap-back, lost focus, and broken animations. Every framework has a wrapper that solves this independently (react-sortablejs, vuedraggable, ngx-sortablejs, svelte-sortablejs); SortableList.rozie solves it once and compiles to all six.
What the wrapper demonstrates:
model: truetwo-way bind on an array — consumers pass an array and get the reordered array back viar-model:items="…", with noonChange → setStatewiring. (ContrastFlatpickr's scalarr-model:date.)- The DOM-reconciler workaround — on
onUpdate, the wrapper restores the pre-drag DOM order before writing the model. The framework then sees a model change against an unchanged DOM and reconciles cleanly, moving keyed nodes with proper transitions. This is the bug every per-framework wrapper has to fix; here it is fixed once. - A default scoped slot (
:item/:index) so consumers render whatever they like per row,$watchreconcilingdisabled/group/handleinto the live instance viainstance.option(), and$emitforstart/end/change.
This page shows the live demo plus the per-target compiled output. For the full API reference, recipes, and the published @rozie-ui/sortable-list-* packages (one pre-compiled, per-framework install — no Rozie toolchain required), see the SortableList showcase + package docs.
Live demo
SortableListDemo.rozie is the companion consumer. Grab a row by its handle (⋮⋮) and drop it elsewhere — the bound array on the right updates in real time. Add and remove rows; the list and its bound state stay in sync.
Source — SortableList.rozie
rozie
<!--
SortableList.rozie — data-bound port of SortableJS.
The "killer demo" companion to the simpler Sortable.rozie (which just
hosts a SortableJS instance over consumer-supplied DOM). This version
owns the list rendering and gives consumers the experience they actually
want: pass an array, hand back a re-ordered array, render whatever you
like per row via the default slot.
Why this is a worthwhile demo:
- Replaces four hand-maintained wrapper libraries (react-sortablejs,
vuedraggable, ngx-sortablejs, svelte-sortablejs) with a single source.
- The SortableJS-vs-framework-reconciler dance — and the hardening
against SortableJS's fragile fallback-mode event shapes — is
encapsulated in `useSortableJS()` (`./internal/useSortableJS`).
Producer code below stays purely declarative.
- Two-way binds the underlying array so consumers don't manually wire
`onChange → setState → re-render`. Use `r-model:items="$data.list"`.
Consumer example:
<SortableList r-model:items="$data.todos" itemKey="id" :handle="$classSelector('grip')">
<template #default="{ item }">
<span class="grip">⋮⋮</span>
<span>{{ item.text }}</span>
</template>
</SortableList>
-->
<rozie name="SortableList">
<props>
{
items: {
type: Array,
default: () => [],
model: true,
docs: {
description:
'The bound items array. The sole `model: true` prop — two-way bind it (`r-model:items` / `v-model:items` / `bind:items` / `[(items)]`) and SortableList writes the re-ordered array back whenever a drag, cross-list move, or keyboard reorder commits, with no manual `onChange → setState` wiring.',
example: '<SortableList r-model:items="$data.todos" itemKey="id" />',
},
},
// String property name OR a (item, index) => key function for the per-row key.
// With neither, id-less object items get a stable synthetic key via an internal
// WeakMap (survives reorder); primitive items fall back to index.
itemKey: {
type: [String, Function],
default: null,
docs: {
description:
'The per-row key the framework reconciler tracks each item by across a reorder — either a property name (e.g. `itemKey="id"` reads `item.id`) or an `(item, index) => key` function. With neither, id-less object items get a stable synthetic key via an internal `WeakMap` (survives reorder by object identity); primitive items fall back to index — pass a function for reorderable duplicate primitives.',
},
},
handle: {
type: String,
default: null,
docs: {
description:
'CSS selector identifying the per-row drag handle, so a drag starts only from that element rather than anywhere in the row. Authored class names render literally on every target (React included), so a plain `.grip` works; `$classSelector(\'grip\')` is an optional, typo-checked way to author it.',
},
},
group: {
type: String,
default: null,
docs: {
description:
'SortableJS group name enabling cross-list drag — two lists sharing a `group` accept items between each other (the source fires `remove`, the destination fires `add`). Set `cloneable: true` to flip a string group into clone-mode.',
},
},
animation: {
type: Number,
default: 150,
docs: {
description:
'Reorder animation duration in milliseconds. `0` disables the animation. Runtime-updatable.',
},
},
disabled: {
type: Boolean,
default: false,
docs: {
description:
'Temporarily disable dragging without unmounting — reapplied live via `instance.option(\'disabled\', v)` (no remount). Also suppresses keyboard reordering: a disabled list is not sortable by any input, so rows lose their `tabindex` and the keydown handler no-ops.',
},
},
// Keyboard reordering (Space lift / Arrow move / Esc cancel / Enter drop) is
// on by default. Set `disableKeyboard: true` to remove it entirely — rows lose
// their tabindex (so they drop out of the tab order) and onRowKeyDown no-ops.
// `disabled` ALSO suppresses keyboard reordering: a disabled list is not
// sortable by ANY input, so keyboard access is gated on `!disabled && !disableKeyboard`.
disableKeyboard: {
type: Boolean,
default: false,
docs: {
description:
'Opt out of keyboard reordering (Space lift / Arrow move / Esc cancel / Enter drop) while leaving pointer drag enabled. Rows drop out of the tab order (no `tabindex`) and the keydown handler no-ops. Keyboard access is gated on `!disabled && !disableKeyboard`.',
},
},
options: {
type: Object,
default: () => ({}),
docs: {
description:
'Verbatim SortableJS options pass-through for anything not covered by the named props. The named props win on key conflict but `options` lands AFTER them in the merge so consumers can override defaults; handler keys (`onStart`, `onEnd`, `onUpdate`, `onAdd`, `onRemove`, `onClone`) are stripped — the helper owns those paths.',
},
},
labelFor: {
type: Function,
default: null,
docs: {
description:
'Optional `(item, idx) => string` returning the screen-reader label for the aria-live announcer during keyboard drag. Defaults to `item.label` (or `String(item)` when no `label` field exists).',
},
},
// Runtime-updatable SortableJS pass-through props (forwarded via the
// inline options literal in $onMount + reapplied through $watch +
// instance.option(name, v) on change).
ghostClass: {
type: String,
default: null,
docs: {
description:
'Class name applied to the drop-placeholder (ghost) element while dragging. Forwarded live via `instance.option`, so toggling it at runtime takes effect without a remount.',
},
},
chosenClass: {
type: String,
default: null,
docs: {
description:
'Class name applied to the currently-chosen item while dragging. Forwarded live via `instance.option` (no remount needed to change it).',
},
},
dragClass: {
type: String,
default: null,
docs: {
description:
'Class name applied to the dragging element. Only takes effect in fallback mode (`forceFallback: true`). Forwarded live via `instance.option`.',
},
},
filter: {
type: String,
default: null,
docs: {
description:
'CSS selector that prevents drag initiation on matching rows (locked items). SortableJS checks it at `mousedown`/`touchstart` and aborts the drag if it matches. A `data-*` attribute selector (e.g. `[data-locked]`) is the most robust choice across all targets.',
},
},
easing: {
type: String,
default: null,
docs: {
description:
'CSS easing function for the reorder animation (e.g. `\'ease-in\'`, `\'cubic-bezier(0.4, 0, 0.2, 1)\'`). Runtime-updatable.',
},
},
// Construction-time-only SortableJS knobs — changing these at runtime
// requires destroying and recreating the SortableJS instance. The
// SortableListShowcaseDemo bridges this by re-keying its <SortableList>
// instance on a string of these values; consumers wanting per-knob
// runtime updates must do the same.
forceFallback: {
type: Boolean,
default: false,
docs: {
description:
'Force SortableJS\'s mouse-event drag path over HTML5 DnD — useful for touch devices, consistent cross-browser behavior, and synthetic test drivers (and `dragClass` only applies in this mode). **Construction-time only**: SortableJS reads it once at construction, so re-key the `<SortableList>` to toggle it at runtime.',
},
},
swapThreshold: {
type: Number,
default: 1,
docs: {
description:
'SortableJS swap threshold (0..1) — a lower value makes rows swap earlier as the dragged item overlaps a neighbor. **Construction-time only**: re-key the `<SortableList>` to change it at runtime.',
},
},
// High-level prop that REPLACES a string `group` with the SortableJS
// clone-mode object form `{ name, pull: 'clone', put: true }`. When
// `cloneable: true` AND `$props.group` is null, the group is left
// untouched (a `cloneable` flag without a `group` name is not
// meaningful — see docs/guide/sortable-list.md).
cloneable: {
type: Boolean,
default: false,
docs: {
description:
'High-level prop that REPLACES a string `group` with SortableJS\'s `{ name, pull: \'clone\', put: true }` clone-mode object form — the source deposits a COPY onto the destination and keeps its own array unchanged (the palette → canvas pattern). With `group: null` it is a no-op (a clone-mode list with no group name has no peer to clone into). **Construction-time only**: re-key the `<SortableList>` to change it at runtime.',
},
},
// Class hooks — extra class(es) merged onto the list container and every
// item row. Accept String | Array | Object (the cross-target rozieClass
// normalizer shipped in 260620-kby); bound DIRECTLY as an array/object
// :class so the base rozie-sortable-list / rozie-sortable-item classes are
// preserved and the default '' adds no stray/trailing class on any target.
// itemClass ALSO accepts a (item, index) => class function for per-row classes.
listClass: {
type: [String, Array, Object],
default: '',
docs: {
description:
'Extra class(es) merged onto the list container (the SortableJS root) alongside the base `rozie-sortable-list` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding), normalized identically across all six targets — the hook for bridging a CSS framework (`.list-group`) or a flex/grid parent onto the component.',
},
},
itemClass: {
type: [String, Array, Object, Function],
default: '',
docs: {
description:
'Extra class(es) merged onto every item row alongside the base `rozie-sortable-item` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding) applied uniformly, OR an `(item, index) => class` function for per-row classes evaluated at render time. Normalized identically across all six targets.',
},
},
// Per-row inline style applied to the .rozie-sortable-item wrapper. Accepts a
// string, a flat style object (Record<string, string | number>), or a
// (item, index) => string | object function for per-row styling. Bound as a
// dynamic :style, so each target normalizes it (React/Solid parseInlineStyle,
// Lit/Svelte rozieStyle [260620-rta], Vue/Angular native). Because it lands on
// the wrapper — the direct child of the list container — it can drive CSS-grid
// placement (grid-column / grid-row / align-self) when the consumer sets
// display:grid via listClass. null / empty → no style attribute on any target.
itemStyle: {
type: [String, Object, Function],
default: null,
docs: {
description:
'Per-row inline style applied to the `.rozie-sortable-item` wrapper. Accepts a CSS `String`, a flat style object (`Record<string, string | number>`), or an `(item, index) => string | object` function for per-row styling. Because it lands on the wrapper — the direct child of the list container — it can drive CSS-grid placement (`grid-column` / `grid-row` / `align-self`) when `listClass` sets `display: grid`. Normalized per target; `null` / empty drops the attribute.',
},
},
}
</props>
<data>
{
liftedIndex: null,
ariaLiveText: '',
}
</data>
<script>
import { useSortableJS } from './internal/useSortableJS'
let instance = null
// Instance-scoped synthetic-id store for id-less object items. Keyed by object
// IDENTITY, so the same object keeps its synthetic id across a reorder (the
// framework reconciler then rebinds the row component instance to its ORIGINAL
// item, not its slot position — the data-corruption fix). BOTH the WeakMap and
// the monotonic counter live in ONE member-mutated fresh-instance object so the
// React emitter hoists the whole thing to a single useMemo(() => …, []) (the
// setup-once-persistence guarantee). Folding the counter in is deliberate: a
// bare `let __rowKeySeq = 0` mutated only inside the non-hook keyFor helper is
// NOT caught by React's hoistModuleLet (it resets every render → an item added
// in a later render collides on an already-issued synthetic id → corruption).
// new WeakMap()/seq inside one object dodges that emitter gap. Verified in codegen.
const __rowKey = { map: new WeakMap(), seq: 0 }
// 4-tier per-row key precedence. Its return feeds BOTH :key and :data-id.
const keyFor = (item, index) => {
// (a) function itemKey: consumer-supplied (item, index) => key.
if (typeof $props.itemKey === 'function') {
return $props.itemKey(item, index)
}
// (b) string itemKey: a property name on a non-null object item.
if (
typeof $props.itemKey === 'string' &&
item !== null &&
typeof item === 'object' &&
item[$props.itemKey] != null
) {
return item[$props.itemKey]
}
// (c) id-less object (or function) item: assign-on-first-sight WeakMap
// synthetic id. Survives reorder because it is keyed by object identity.
if ((item !== null && typeof item === 'object') || typeof item === 'function') {
if (!__rowKey.map.has(item)) {
__rowKey.map.set(item, '__rk' + __rowKey.seq++)
}
return __rowKey.map.get(item)
}
// (d) primitive item: fall back to index. NOTE: duplicate primitives are
// unsafe to reorder this way — pass a function itemKey for those.
return index
}
// Resolve itemClass for a row: a static value (string | array | object) OR a
// per-row (item, index) => class function. The result is fed into the :class
// array and normalized by each target's class path (rozieClass / clsx / native).
const itemClassFor = (item, index) => {
const v = $props.itemClass
return typeof v === 'function' ? v(item, index) : v
}
// Resolve itemStyle for a row: a static value (string | object) OR a per-row
// (item, index) => style function. Returns string | object | null; the dynamic
// :style binding normalizes it per target. null / empty → attribute dropped.
const itemStyleFor = (item, index) => {
const s = typeof $props.itemStyle === 'function' ? $props.itemStyle(item, index) : $props.itemStyle
return s == null || s === '' ? null : s
}
// Read the display label for an item — used by the aria-live announcer.
// Phase 16 R7 / D-08: $props.labelFor reads as `null` on all 6 targets when
// the consumer omits it (Plan 16-01 prop-default coercion fix); the check is
// a plain null compare — NO runtime callable-type coercion.
const getLabel = (idx) => {
const item = $props.items[idx]
if ($props.labelFor !== null) return $props.labelFor(item, idx)
if (item !== null && typeof item === 'object' && 'label' in item) return item.label
return String(item)
}
// Keyboard handler (Phase 16 R7): Space lifts/drops, ArrowDown/ArrowUp move
// the lifted row, Escape cancels, Enter is an alternate drop trigger. After
// any array-reorder write, $restoreFocus('[role="listitem"]', newIdx) keeps
// focus on the moved row across the React/Vue/Angular vs Svelte/Solid/Lit
// keyed-reconciler divide (Plan 16-03 sigil — no-op on the first three;
// queueMicrotask + querySelectorAll + .focus() on the latter three).
//
// Note: `index` is passed directly as a number. Plan 16-02 (Solid call-arg
// accessor unwrap) ensures Solid's <For> alias unwraps to `index()` at the
// call site — no runtime callable-type coercion needed in user source.
// Keyboard reordering is available only when the list is not disabled AND the
// `disableKeyboard` opt-out is off. Drives BOTH the row tabindex (rows are
// focusable only when reorderable) and the onRowKeyDown guard below. Reads
// straight off $props so the tabindex binding re-evaluates reactively when
// `disabled`/`disableKeyboard` toggle at runtime.
const keyboardEnabled = () => !$props.disabled && !$props.disableKeyboard
const onRowKeyDown = ($event, index) => {
// Defense-in-depth: when keyboard reordering is off the rows carry no
// tabindex and can't receive focus, but a consumer-focused row (or a
// programmatic .focus()) must still no-op here rather than reorder.
if (!keyboardEnabled()) return
const key = $event.key
// Space (' ' on browsers; KeyboardEvent.key === ' ') OR Enter — lift/drop.
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
$event.preventDefault()
if ($data.liftedIndex === null) {
// LIFT
$data.liftedIndex = index
$data.ariaLiveText = 'Lifted ' + getLabel(index)
return
}
// DROP
const dropped = getLabel($data.liftedIndex)
const at = $data.liftedIndex
$data.liftedIndex = null
$data.ariaLiveText = 'Dropped ' + dropped + ' at position ' + (at + 1)
return
}
if (key === 'Escape') {
if ($data.liftedIndex === null) return
$event.preventDefault()
const cancelled = getLabel($data.liftedIndex)
$data.liftedIndex = null
$data.ariaLiveText = 'Cancelled lift of ' + cancelled
return
}
if (key === 'ArrowDown' || key === 'ArrowUp') {
if ($data.liftedIndex === null) return
$event.preventDefault()
const dir = key === 'ArrowDown' ? 1 : -1
const from = $data.liftedIndex
const to = from + dir
if (to < 0 || to >= $props.items.length) return
const next = [...$props.items]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
$model.items = next
$data.liftedIndex = to
$data.ariaLiveText = 'Moved ' + getLabel(to) + ' to position ' + (to + 1)
// After the keyed reorder write, restore focus to the moved row. No-op
// on React/Vue/Angular (DOM identity preserved); queueMicrotask +
// querySelectorAll + .focus() on Svelte/Solid/Lit (DOM re-created).
$restoreFocus('[role="listitem"]', to)
$emit('change', { oldIndex: from, newIndex: to, item: moved })
}
}
// SortableJS wiring lives in `useSortableJS()` (./internal/useSortableJS).
// The helper owns the SortableJS-vs-reconciler dance — DOM-restore hardening
// against fragile-event paths, identity-based item lookup over fragile
// `e.oldIndex`, and the single-onEnd disambiguation that collapses
// onUpdate / onAdd / onRemove into one handler.
//
// What stays here is purely declarative: which array to read, what to write
// back, what to emit, and how to bridge `afterCommit` to the Lit-only
// `$reconcileAfterDomMutation()` sigil.
$onMount(() => {
// Named `sortable` (not `handle`) to avoid shadowing `$props.handle`
// when the options object below references it.
const sortable = useSortableJS($refs.listEl, {
items: () => $props.items,
onCommit: (next) => { $model.items = next },
options: {
animation: $props.animation,
disabled: $props.disabled,
// `cloneable` is a high-level Rozie prop that REPLACES a string
// `group` with SortableJS's `{ name, pull: 'clone', put: true }`
// object form. When `cloneable:false`, pass `$props.group` through
// verbatim. When `cloneable:true` AND `$props.group` is null,
// leave it null — a clone-mode list without a group name is not
// meaningful (no peer list can join the cross-list flow).
group: $props.cloneable && typeof $props.group === 'string'
? { name: $props.group, pull: 'clone', put: true }
: $props.group,
handle: $props.handle,
ghostClass: $props.ghostClass,
chosenClass: $props.chosenClass,
dragClass: $props.dragClass,
filter: $props.filter,
forceFallback: $props.forceFallback,
swapThreshold: $props.swapThreshold,
easing: $props.easing,
...$props.options,
},
// Lit lit-html `repeat` directive caches its part array by sentinel-
// comment node identity; SortableJS's physical DOM mutation desyncs
// that cache. The sigil lowers to `__rozieReconcileAfterDomMutation(this)`
// on Lit (real call) and `void 0` on the other 5 targets (no-op).
afterCommit: () => $reconcileAfterDomMutation(),
onChange: ({ kind, oldIndex, newIndex, item }) => {
if (kind === 'reorder') $emit('change', { oldIndex, newIndex, item })
else if (kind === 'add') $emit('add', { newIndex, item })
else if (kind === 'remove') $emit('remove', { oldIndex, item })
},
onStart: (e) => $emit('start', e),
onEnd: (e) => $emit('end', e),
})
instance = sortable.instance
// $onMount's cleanup-return: closing over a setup-local (`sortable`) does
// not survive the Solid emitter's setup/cleanup split — it scopes cleanup
// outside the setup IIFE. Closing over `instance` (a module-scope `let`)
// works on every target.
return () => instance?.destroy()
})
// Reconcile prop changes into the live SortableJS instance without
// re-creating it. instance.option(name, value) is SortableJS's supported
// runtime update path for these fields.
//
// Construction-time-only knobs (forceFallback, swapThreshold, cloneable)
// are NOT reapplied here — changing them at runtime would require
// destroying and recreating the SortableJS instance. Consumers that need
// per-knob runtime updates should re-key the <SortableList> on those
// values (see SortableListShowcaseDemo for the canonical pattern).
$watch(() => $props.disabled, (v) => instance?.option('disabled', v))
$watch(() => $props.group, (v) => instance?.option('group', v))
$watch(() => $props.handle, (v) => instance?.option('handle', v))
$watch(() => $props.ghostClass, (v) => instance?.option('ghostClass', v))
$watch(() => $props.chosenClass, (v) => instance?.option('chosenClass', v))
$watch(() => $props.dragClass, (v) => instance?.option('dragClass', v))
$watch(() => $props.filter, (v) => instance?.option('filter', v))
$watch(() => $props.easing, (v) => instance?.option('easing', v))
// Imperative handle (Phase 21 $expose). The SortableJS imperative surface a
// consumer can't drive through props alone — exposed uniformly to all 6 targets.
// Each guards the pre-mount/destroyed `instance = null`. Collision-clear: none of
// the 4 verb names collide with the 16 props or the 5 events — `option` is a
// distinct identifier from the `options` prop, so ROZ121 is clear.
function getInstance() { return instance }
// toArray()/sort() operate on SortableJS's data-id ordering — every row carries
// :data-id="keyFor(item, index)", so toArray() returns the current key order and
// sort(order) reorders by those keys (set itemKey for stable object-list keys).
function toArray() { return instance ? instance.toArray() : [] }
function sort(order, useAnimation = true) { instance?.sort(order, useAnimation) }
// option(name) reads a live SortableJS option; option(name, value) sets one — the
// runtime escape hatch for any SortableJS option beyond the curated props.
function option(name, value) {
if (!instance) return undefined
if (value === undefined) return instance.option(name)
instance.option(name, value)
return value
}
$expose({ getInstance, toArray, sort, option })
</script>
<template>
<div class="rozie-sortable-wrap">
<div :class="['rozie-sortable-list', $props.listClass]" ref="listEl" r-external part="list">
<slot name="header" />
<div
r-for="item, index in $props.items"
:key="keyFor(item, index)"
:class="['rozie-sortable-item', itemClassFor(item, index), { 'rozie-sortable-item-lifted': $data.liftedIndex === index }]"
:style="itemStyleFor(item, index)"
:data-id="keyFor(item, index)"
role="listitem"
:tabindex="keyboardEnabled() ? 0 : null"
@keydown="onRowKeyDown($event, index)"
>
<slot :item="item" :index="index" />
</div>
<slot name="footer" />
</div>
<div
class="rozie-sortable-aria-live"
data-rozie-sortable-aria-live
aria-live="polite"
aria-atomic="true"
>{{ $data.ariaLiveText }}</div>
</div>
</template>
<style>
.rozie-sortable-wrap { display: block; }
.rozie-sortable-list { display: block; }
.rozie-sortable-item { display: block; outline: none; }
.rozie-sortable-item:focus { outline: 2px solid rgba(0, 102, 204, 0.6); outline-offset: -2px; }
.rozie-sortable-item-lifted {
background: rgba(0, 102, 204, 0.08);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.4) inset;
}
/* aria-live announcer — visually hidden, accessible to screen readers. */
.rozie-sortable-aria-live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
</rozie>Compiled output
vue
<template>
<div class="rozie-sortable-wrap" ref="__rozieRootRef" v-bind="$attrs">
<div :class="['rozie-sortable-list', props.listClass]" ref="listElRef" part="list">
<slot name="header"></slot>
<div v-for="(item, index) in items" :key="keyFor(item, index)" :class="['rozie-sortable-item', itemClassFor(item, index), { 'rozie-sortable-item-lifted': liftedIndex === index }]" :style="itemStyleFor(item, index)" :data-id="keyFor(item, index)" role="listitem" :tabindex="(keyboardEnabled() ? 0 : undefined) ?? undefined" @keydown="onRowKeyDown($event, index)">
<slot :item="item" :index="index"></slot>
</div>
<slot name="footer"></slot>
</div>
<div class="rozie-sortable-aria-live" data-rozie-sortable-aria-live="" aria-live="polite" aria-atomic="true">{{ ariaLiveText }}</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* The per-row key the framework reconciler tracks each item by across a reorder — either a property name (e.g. `itemKey="id"` reads `item.id`) or an `(item, index) => key` function. With neither, id-less object items get a stable synthetic key via an internal `WeakMap` (survives reorder by object identity); primitive items fall back to index — pass a function for reorderable duplicate primitives.
*/
itemKey?: string | ((...args: any[]) => any) | null;
/**
* CSS selector identifying the per-row drag handle, so a drag starts only from that element rather than anywhere in the row. Authored class names render literally on every target (React included), so a plain `.grip` works; `$classSelector('grip')` is an optional, typo-checked way to author it.
*/
handle?: string | null;
/**
* SortableJS group name enabling cross-list drag — two lists sharing a `group` accept items between each other (the source fires `remove`, the destination fires `add`). Set `cloneable: true` to flip a string group into clone-mode.
*/
group?: string | null;
/**
* Reorder animation duration in milliseconds. `0` disables the animation. Runtime-updatable.
*/
animation?: number;
/**
* Temporarily disable dragging without unmounting — reapplied live via `instance.option('disabled', v)` (no remount). Also suppresses keyboard reordering: a disabled list is not sortable by any input, so rows lose their `tabindex` and the keydown handler no-ops.
*/
disabled?: boolean;
/**
* Opt out of keyboard reordering (Space lift / Arrow move / Esc cancel / Enter drop) while leaving pointer drag enabled. Rows drop out of the tab order (no `tabindex`) and the keydown handler no-ops. Keyboard access is gated on `!disabled && !disableKeyboard`.
*/
disableKeyboard?: boolean;
/**
* Verbatim SortableJS options pass-through for anything not covered by the named props. The named props win on key conflict but `options` lands AFTER them in the merge so consumers can override defaults; handler keys (`onStart`, `onEnd`, `onUpdate`, `onAdd`, `onRemove`, `onClone`) are stripped — the helper owns those paths.
*/
options?: Record<string, any>;
/**
* Optional `(item, idx) => string` returning the screen-reader label for the aria-live announcer during keyboard drag. Defaults to `item.label` (or `String(item)` when no `label` field exists).
*/
labelFor?: ((...args: any[]) => any) | null;
/**
* Class name applied to the drop-placeholder (ghost) element while dragging. Forwarded live via `instance.option`, so toggling it at runtime takes effect without a remount.
*/
ghostClass?: string | null;
/**
* Class name applied to the currently-chosen item while dragging. Forwarded live via `instance.option` (no remount needed to change it).
*/
chosenClass?: string | null;
/**
* Class name applied to the dragging element. Only takes effect in fallback mode (`forceFallback: true`). Forwarded live via `instance.option`.
*/
dragClass?: string | null;
/**
* CSS selector that prevents drag initiation on matching rows (locked items). SortableJS checks it at `mousedown`/`touchstart` and aborts the drag if it matches. A `data-*` attribute selector (e.g. `[data-locked]`) is the most robust choice across all targets.
*/
filter?: string | null;
/**
* CSS easing function for the reorder animation (e.g. `'ease-in'`, `'cubic-bezier(0.4, 0, 0.2, 1)'`). Runtime-updatable.
*/
easing?: string | null;
/**
* Force SortableJS's mouse-event drag path over HTML5 DnD — useful for touch devices, consistent cross-browser behavior, and synthetic test drivers (and `dragClass` only applies in this mode). **Construction-time only**: SortableJS reads it once at construction, so re-key the `<SortableList>` to toggle it at runtime.
*/
forceFallback?: boolean;
/**
* SortableJS swap threshold (0..1) — a lower value makes rows swap earlier as the dragged item overlaps a neighbor. **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
swapThreshold?: number;
/**
* High-level prop that REPLACES a string `group` with SortableJS's `{ name, pull: 'clone', put: true }` clone-mode object form — the source deposits a COPY onto the destination and keeps its own array unchanged (the palette → canvas pattern). With `group: null` it is a no-op (a clone-mode list with no group name has no peer to clone into). **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
cloneable?: boolean;
/**
* Extra class(es) merged onto the list container (the SortableJS root) alongside the base `rozie-sortable-list` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding), normalized identically across all six targets — the hook for bridging a CSS framework (`.list-group`) or a flex/grid parent onto the component.
*/
listClass?: string | any[] | Record<string, any>;
/**
* Extra class(es) merged onto every item row alongside the base `rozie-sortable-item` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding) applied uniformly, OR an `(item, index) => class` function for per-row classes evaluated at render time. Normalized identically across all six targets.
*/
itemClass?: string | any[] | Record<string, any> | ((...args: any[]) => any) | null;
/**
* Per-row inline style applied to the `.rozie-sortable-item` wrapper. Accepts a CSS `String`, a flat style object (`Record<string, string | number>`), or an `(item, index) => string | object` function for per-row styling. Because it lands on the wrapper — the direct child of the list container — it can drive CSS-grid placement (`grid-column` / `grid-row` / `align-self`) when `listClass` sets `display: grid`. Normalized per target; `null` / empty drops the attribute.
*/
itemStyle?: string | Record<string, any> | ((...args: any[]) => any) | null;
}>(),
{ itemKey: null, handle: null, group: null, animation: 150, disabled: false, disableKeyboard: false, options: () => ({}), labelFor: null, ghostClass: null, chosenClass: null, dragClass: null, filter: null, easing: null, forceFallback: false, swapThreshold: 1, cloneable: false, listClass: '', itemClass: '', itemStyle: null }
);
/**
* The bound items array. The sole `model: true` prop — two-way bind it (`r-model:items` / `v-model:items` / `bind:items` / `[(items)]`) and SortableList writes the re-ordered array back whenever a drag, cross-list move, or keyboard reorder commits, with no manual `onChange → setState` wiring.
* @example
* <SortableList r-model:items="$data.todos" itemKey="id" />
*/
const items = defineModel<any[]>('items', { default: () => [] });
const emit = defineEmits<{
change: [...args: any[]];
add: [...args: any[]];
remove: [...args: any[]];
start: [...args: any[]];
end: [...args: any[]];
}>();
defineSlots<{
header(props: { }): any;
default(props: { item: any; index: any }): any;
footer(props: { }): any;
}>();
const liftedIndex = ref<any>(null);
const ariaLiveText = ref('');
const listElRef = ref<HTMLElement>();
const __rozieRootRef = ref<HTMLElement>();
import { useSortableJS } from './internal/useSortableJS';
let instance: any = null;
// Instance-scoped synthetic-id store for id-less object items. Keyed by object
// IDENTITY, so the same object keeps its synthetic id across a reorder (the
// framework reconciler then rebinds the row component instance to its ORIGINAL
// item, not its slot position — the data-corruption fix). BOTH the WeakMap and
// the monotonic counter live in ONE member-mutated fresh-instance object so the
// React emitter hoists the whole thing to a single useMemo(() => …, []) (the
// setup-once-persistence guarantee). Folding the counter in is deliberate: a
// bare `let __rowKeySeq = 0` mutated only inside the non-hook keyFor helper is
// NOT caught by React's hoistModuleLet (it resets every render → an item added
// in a later render collides on an already-issued synthetic id → corruption).
// new WeakMap()/seq inside one object dodges that emitter gap. Verified in codegen.
// Instance-scoped synthetic-id store for id-less object items. Keyed by object
// IDENTITY, so the same object keeps its synthetic id across a reorder (the
// framework reconciler then rebinds the row component instance to its ORIGINAL
// item, not its slot position — the data-corruption fix). BOTH the WeakMap and
// the monotonic counter live in ONE member-mutated fresh-instance object so the
// React emitter hoists the whole thing to a single useMemo(() => …, []) (the
// setup-once-persistence guarantee). Folding the counter in is deliberate: a
// bare `let __rowKeySeq = 0` mutated only inside the non-hook keyFor helper is
// NOT caught by React's hoistModuleLet (it resets every render → an item added
// in a later render collides on an already-issued synthetic id → corruption).
// new WeakMap()/seq inside one object dodges that emitter gap. Verified in codegen.
const __rowKey = {
map: new WeakMap(),
seq: 0
};
// 4-tier per-row key precedence. Its return feeds BOTH :key and :data-id.
// 4-tier per-row key precedence. Its return feeds BOTH :key and :data-id.
const keyFor = (item: any, index: any) => {
// (a) function itemKey: consumer-supplied (item, index) => key.
if (typeof props.itemKey === 'function') {
return props.itemKey(item, index);
}
// (b) string itemKey: a property name on a non-null object item.
if (typeof props.itemKey === 'string' && item !== null && typeof item === 'object' && item[props.itemKey] != null) {
return item[props.itemKey];
}
// (c) id-less object (or function) item: assign-on-first-sight WeakMap
// synthetic id. Survives reorder because it is keyed by object identity.
if (item !== null && typeof item === 'object' || typeof item === 'function') {
if (!__rowKey.map.has(item)) {
__rowKey.map.set(item, '__rk' + __rowKey.seq++);
}
return __rowKey.map.get(item);
}
// (d) primitive item: fall back to index. NOTE: duplicate primitives are
// unsafe to reorder this way — pass a function itemKey for those.
return index;
};
// Resolve itemClass for a row: a static value (string | array | object) OR a
// per-row (item, index) => class function. The result is fed into the :class
// array and normalized by each target's class path (rozieClass / clsx / native).
// Resolve itemClass for a row: a static value (string | array | object) OR a
// per-row (item, index) => class function. The result is fed into the :class
// array and normalized by each target's class path (rozieClass / clsx / native).
const itemClassFor = (item: any, index: any) => {
const v = props.itemClass;
return typeof v === 'function' ? v(item, index) : v;
};
// Resolve itemStyle for a row: a static value (string | object) OR a per-row
// (item, index) => style function. Returns string | object | null; the dynamic
// :style binding normalizes it per target. null / empty → attribute dropped.
// Resolve itemStyle for a row: a static value (string | object) OR a per-row
// (item, index) => style function. Returns string | object | null; the dynamic
// :style binding normalizes it per target. null / empty → attribute dropped.
const itemStyleFor = (item: any, index: any) => {
const s = typeof props.itemStyle === 'function' ? props.itemStyle(item, index) : props.itemStyle;
return s == null || s === '' ? null : s;
};
// Read the display label for an item — used by the aria-live announcer.
// Phase 16 R7 / D-08: $props.labelFor reads as `null` on all 6 targets when
// the consumer omits it (Plan 16-01 prop-default coercion fix); the check is
// a plain null compare — NO runtime callable-type coercion.
// Read the display label for an item — used by the aria-live announcer.
// Phase 16 R7 / D-08: $props.labelFor reads as `null` on all 6 targets when
// the consumer omits it (Plan 16-01 prop-default coercion fix); the check is
// a plain null compare — NO runtime callable-type coercion.
const getLabel = (idx: any) => {
const item = items.value[idx];
if (props.labelFor !== null) return props.labelFor(item, idx);
if (item !== null && typeof item === 'object' && 'label' in item) return item.label;
return String(item);
};
// Keyboard handler (Phase 16 R7): Space lifts/drops, ArrowDown/ArrowUp move
// the lifted row, Escape cancels, Enter is an alternate drop trigger. After
// any array-reorder write, $restoreFocus('[role="listitem"]', newIdx) keeps
// focus on the moved row across the React/Vue/Angular vs Svelte/Solid/Lit
// keyed-reconciler divide (Plan 16-03 sigil — no-op on the first three;
// queueMicrotask + querySelectorAll + .focus() on the latter three).
//
// Note: `index` is passed directly as a number. Plan 16-02 (Solid call-arg
// accessor unwrap) ensures Solid's <For> alias unwraps to `index()` at the
// call site — no runtime callable-type coercion needed in user source.
// Keyboard reordering is available only when the list is not disabled AND the
// `disableKeyboard` opt-out is off. Drives BOTH the row tabindex (rows are
// focusable only when reorderable) and the onRowKeyDown guard below. Reads
// straight off $props so the tabindex binding re-evaluates reactively when
// `disabled`/`disableKeyboard` toggle at runtime.
// Keyboard handler (Phase 16 R7): Space lifts/drops, ArrowDown/ArrowUp move
// the lifted row, Escape cancels, Enter is an alternate drop trigger. After
// any array-reorder write, $restoreFocus('[role="listitem"]', newIdx) keeps
// focus on the moved row across the React/Vue/Angular vs Svelte/Solid/Lit
// keyed-reconciler divide (Plan 16-03 sigil — no-op on the first three;
// queueMicrotask + querySelectorAll + .focus() on the latter three).
//
// Note: `index` is passed directly as a number. Plan 16-02 (Solid call-arg
// accessor unwrap) ensures Solid's <For> alias unwraps to `index()` at the
// call site — no runtime callable-type coercion needed in user source.
// Keyboard reordering is available only when the list is not disabled AND the
// `disableKeyboard` opt-out is off. Drives BOTH the row tabindex (rows are
// focusable only when reorderable) and the onRowKeyDown guard below. Reads
// straight off $props so the tabindex binding re-evaluates reactively when
// `disabled`/`disableKeyboard` toggle at runtime.
const keyboardEnabled = () => !props.disabled && !props.disableKeyboard;
const onRowKeyDown = ($event: any, index: any) => {
// Defense-in-depth: when keyboard reordering is off the rows carry no
// tabindex and can't receive focus, but a consumer-focused row (or a
// programmatic .focus()) must still no-op here rather than reorder.
if (!keyboardEnabled()) return;
const key = $event.key;
// Space (' ' on browsers; KeyboardEvent.key === ' ') OR Enter — lift/drop.
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
$event.preventDefault();
if (liftedIndex.value === null) {
// LIFT
liftedIndex.value = index;
ariaLiveText.value = 'Lifted ' + getLabel(index);
return;
}
// DROP
const dropped = getLabel(liftedIndex.value);
const at = liftedIndex.value;
liftedIndex.value = null;
ariaLiveText.value = 'Dropped ' + dropped + ' at position ' + (at + 1);
return;
}
if (key === 'Escape') {
if (liftedIndex.value === null) return;
$event.preventDefault();
const cancelled = getLabel(liftedIndex.value);
liftedIndex.value = null;
ariaLiveText.value = 'Cancelled lift of ' + cancelled;
return;
}
if (key === 'ArrowDown' || key === 'ArrowUp') {
if (liftedIndex.value === null) return;
$event.preventDefault();
const dir = key === 'ArrowDown' ? 1 : -1;
const from = liftedIndex.value;
const to = from + dir;
if (to < 0 || to >= items.value.length) return;
const next = [...items.value];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
items.value = next;
liftedIndex.value = to;
ariaLiveText.value = 'Moved ' + getLabel(to) + ' to position ' + (to + 1);
// After the keyed reorder write, restore focus to the moved row. No-op
// on React/Vue/Angular (DOM identity preserved); queueMicrotask +
// querySelectorAll + .focus() on Svelte/Solid/Lit (DOM re-created).
void 0;
emit('change', {
oldIndex: from,
newIndex: to,
item: moved
});
}
};
// SortableJS wiring lives in `useSortableJS()` (./internal/useSortableJS).
// The helper owns the SortableJS-vs-reconciler dance — DOM-restore hardening
// against fragile-event paths, identity-based item lookup over fragile
// `e.oldIndex`, and the single-onEnd disambiguation that collapses
// onUpdate / onAdd / onRemove into one handler.
//
// What stays here is purely declarative: which array to read, what to write
// back, what to emit, and how to bridge `afterCommit` to the Lit-only
// `$reconcileAfterDomMutation()` sigil.
// Imperative handle (Phase 21 $expose). The SortableJS imperative surface a
// consumer can't drive through props alone — exposed uniformly to all 6 targets.
// Each guards the pre-mount/destroyed `instance = null`. Collision-clear: none of
// the 4 verb names collide with the 16 props or the 5 events — `option` is a
// distinct identifier from the `options` prop, so ROZ121 is clear.
function getInstance() {
return instance;
}
// toArray()/sort() operate on SortableJS's data-id ordering — every row carries
// :data-id="keyFor(item, index)", so toArray() returns the current key order and
// sort(order) reorders by those keys (set itemKey for stable object-list keys).
// toArray()/sort() operate on SortableJS's data-id ordering — every row carries
// :data-id="keyFor(item, index)", so toArray() returns the current key order and
// sort(order) reorders by those keys (set itemKey for stable object-list keys).
function toArray() {
return instance ? instance.toArray() : [];
}
function sort(order: any, useAnimation = true) {
instance?.sort(order, useAnimation);
}
// option(name) reads a live SortableJS option; option(name, value) sets one — the
// runtime escape hatch for any SortableJS option beyond the curated props.
// option(name) reads a live SortableJS option; option(name, value) sets one — the
// runtime escape hatch for any SortableJS option beyond the curated props.
function option(name: any, value: any) {
if (!instance) return undefined;
if (value === undefined) return instance.option(name);
instance.option(name, value);
return value;
}
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
// Named `sortable` (not `handle`) to avoid shadowing `$props.handle`
// when the options object below references it.
const sortable = useSortableJS(listElRef.value!, {
items: () => items.value,
onCommit: (next: any) => {
items.value = next;
},
options: {
animation: props.animation,
disabled: props.disabled,
// `cloneable` is a high-level Rozie prop that REPLACES a string
// `group` with SortableJS's `{ name, pull: 'clone', put: true }`
// object form. When `cloneable:false`, pass `$props.group` through
// verbatim. When `cloneable:true` AND `$props.group` is null,
// leave it null — a clone-mode list without a group name is not
// meaningful (no peer list can join the cross-list flow).
group: props.cloneable && typeof props.group === 'string' ? {
name: props.group,
pull: 'clone',
put: true
} : props.group,
handle: props.handle,
ghostClass: props.ghostClass,
chosenClass: props.chosenClass,
dragClass: props.dragClass,
filter: props.filter,
forceFallback: props.forceFallback,
swapThreshold: props.swapThreshold,
easing: props.easing,
...props.options
},
// Lit lit-html `repeat` directive caches its part array by sentinel-
// comment node identity; SortableJS's physical DOM mutation desyncs
// that cache. The sigil lowers to `__rozieReconcileAfterDomMutation(this)`
// on Lit (real call) and `void 0` on the other 5 targets (no-op).
afterCommit: () => void 0,
onChange: ({
kind,
oldIndex,
newIndex,
item
}: any) => {
if (kind === 'reorder') emit('change', {
oldIndex,
newIndex,
item
});else if (kind === 'add') emit('add', {
newIndex,
item
});else if (kind === 'remove') emit('remove', {
oldIndex,
item
});
},
onStart: (e: any) => emit('start', e),
onEnd: (e: any) => emit('end', e)
});
instance = sortable.instance;
// $onMount's cleanup-return: closing over a setup-local (`sortable`) does
// not survive the Solid emitter's setup/cleanup split — it scopes cleanup
// outside the setup IIFE. Closing over `instance` (a module-scope `let`)
// works on every target.
_cleanup_0 = () => instance?.destroy();
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => props.disabled, (v: any) => instance?.option('disabled', v));
watch(() => props.group, (v: any) => instance?.option('group', v));
watch(() => props.handle, (v: any) => instance?.option('handle', v));
watch(() => props.ghostClass, (v: any) => instance?.option('ghostClass', v));
watch(() => props.chosenClass, (v: any) => instance?.option('chosenClass', v));
watch(() => props.dragClass, (v: any) => instance?.option('dragClass', v));
watch(() => props.filter, (v: any) => instance?.option('filter', v));
watch(() => props.easing, (v: any) => instance?.option('easing', v));
defineExpose({ getInstance, toArray, sort, option });
</script>
<style scoped>
.rozie-sortable-wrap { display: block; }
.rozie-sortable-list { display: block; }
.rozie-sortable-item { display: block; outline: none; }
.rozie-sortable-item:focus { outline: 2px solid rgba(0, 102, 204, 0.6); outline-offset: -2px; }
.rozie-sortable-item-lifted {
background: rgba(0, 102, 204, 0.08);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.4) inset;
}
.rozie-sortable-aria-live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, parseInlineStyle, rozieAttr, useControllableState } from '@rozie/runtime-react';
import './SortableList.css';
import { useSortableJS } from './internal/useSortableJS';
interface ChildrenCtx { item: any; index: any; }
interface SortableListProps {
/**
* The bound items array. The sole `model: true` prop — two-way bind it (`r-model:items` / `v-model:items` / `bind:items` / `[(items)]`) and SortableList writes the re-ordered array back whenever a drag, cross-list move, or keyboard reorder commits, with no manual `onChange → setState` wiring.
* @example
* <SortableList r-model:items="$data.todos" itemKey="id" />
*/
items?: any[];
defaultItems?: any[];
onItemsChange?: (items: any[]) => void;
/**
* The per-row key the framework reconciler tracks each item by across a reorder — either a property name (e.g. `itemKey="id"` reads `item.id`) or an `(item, index) => key` function. With neither, id-less object items get a stable synthetic key via an internal `WeakMap` (survives reorder by object identity); primitive items fall back to index — pass a function for reorderable duplicate primitives.
*/
itemKey?: (string | ((...args: any[]) => any)) | null;
/**
* CSS selector identifying the per-row drag handle, so a drag starts only from that element rather than anywhere in the row. Authored class names render literally on every target (React included), so a plain `.grip` works; `$classSelector('grip')` is an optional, typo-checked way to author it.
*/
handle?: (string) | null;
/**
* SortableJS group name enabling cross-list drag — two lists sharing a `group` accept items between each other (the source fires `remove`, the destination fires `add`). Set `cloneable: true` to flip a string group into clone-mode.
*/
group?: (string) | null;
/**
* Reorder animation duration in milliseconds. `0` disables the animation. Runtime-updatable.
*/
animation?: number;
/**
* Temporarily disable dragging without unmounting — reapplied live via `instance.option('disabled', v)` (no remount). Also suppresses keyboard reordering: a disabled list is not sortable by any input, so rows lose their `tabindex` and the keydown handler no-ops.
*/
disabled?: boolean;
/**
* Opt out of keyboard reordering (Space lift / Arrow move / Esc cancel / Enter drop) while leaving pointer drag enabled. Rows drop out of the tab order (no `tabindex`) and the keydown handler no-ops. Keyboard access is gated on `!disabled && !disableKeyboard`.
*/
disableKeyboard?: boolean;
/**
* Verbatim SortableJS options pass-through for anything not covered by the named props. The named props win on key conflict but `options` lands AFTER them in the merge so consumers can override defaults; handler keys (`onStart`, `onEnd`, `onUpdate`, `onAdd`, `onRemove`, `onClone`) are stripped — the helper owns those paths.
*/
options?: Record<string, any>;
/**
* Optional `(item, idx) => string` returning the screen-reader label for the aria-live announcer during keyboard drag. Defaults to `item.label` (or `String(item)` when no `label` field exists).
*/
labelFor?: ((...args: any[]) => any) | null;
/**
* Class name applied to the drop-placeholder (ghost) element while dragging. Forwarded live via `instance.option`, so toggling it at runtime takes effect without a remount.
*/
ghostClass?: (string) | null;
/**
* Class name applied to the currently-chosen item while dragging. Forwarded live via `instance.option` (no remount needed to change it).
*/
chosenClass?: (string) | null;
/**
* Class name applied to the dragging element. Only takes effect in fallback mode (`forceFallback: true`). Forwarded live via `instance.option`.
*/
dragClass?: (string) | null;
/**
* CSS selector that prevents drag initiation on matching rows (locked items). SortableJS checks it at `mousedown`/`touchstart` and aborts the drag if it matches. A `data-*` attribute selector (e.g. `[data-locked]`) is the most robust choice across all targets.
*/
filter?: (string) | null;
/**
* CSS easing function for the reorder animation (e.g. `'ease-in'`, `'cubic-bezier(0.4, 0, 0.2, 1)'`). Runtime-updatable.
*/
easing?: (string) | null;
/**
* Force SortableJS's mouse-event drag path over HTML5 DnD — useful for touch devices, consistent cross-browser behavior, and synthetic test drivers (and `dragClass` only applies in this mode). **Construction-time only**: SortableJS reads it once at construction, so re-key the `<SortableList>` to toggle it at runtime.
*/
forceFallback?: boolean;
/**
* SortableJS swap threshold (0..1) — a lower value makes rows swap earlier as the dragged item overlaps a neighbor. **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
swapThreshold?: number;
/**
* High-level prop that REPLACES a string `group` with SortableJS's `{ name, pull: 'clone', put: true }` clone-mode object form — the source deposits a COPY onto the destination and keeps its own array unchanged (the palette → canvas pattern). With `group: null` it is a no-op (a clone-mode list with no group name has no peer to clone into). **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
cloneable?: boolean;
/**
* Extra class(es) merged onto the list container (the SortableJS root) alongside the base `rozie-sortable-list` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding), normalized identically across all six targets — the hook for bridging a CSS framework (`.list-group`) or a flex/grid parent onto the component.
*/
listClass?: string | any[] | Record<string, any>;
/**
* Extra class(es) merged onto every item row alongside the base `rozie-sortable-item` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding) applied uniformly, OR an `(item, index) => class` function for per-row classes evaluated at render time. Normalized identically across all six targets.
*/
itemClass?: string | any[] | Record<string, any> | ((...args: any[]) => any);
/**
* Per-row inline style applied to the `.rozie-sortable-item` wrapper. Accepts a CSS `String`, a flat style object (`Record<string, string | number>`), or an `(item, index) => string | object` function for per-row styling. Because it lands on the wrapper — the direct child of the list container — it can drive CSS-grid placement (`grid-column` / `grid-row` / `align-self`) when `listClass` sets `display: grid`. Normalized per target; `null` / empty drops the attribute.
*/
itemStyle?: (string | Record<string, any> | ((...args: any[]) => any)) | null;
onChange?: (...args: any[]) => void;
onAdd?: (...args: any[]) => void;
onRemove?: (...args: any[]) => void;
onStart?: (...args: any[]) => void;
onEnd?: (...args: any[]) => void;
renderHeader?: () => ReactNode;
children?: ReactNode | ((ctx: ChildrenCtx) => ReactNode);
renderFooter?: () => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface SortableListHandle {
getInstance: (...args: any[]) => any;
toArray: (...args: any[]) => any;
sort: (...args: any[]) => any;
option: (...args: any[]) => any;
}
const SortableList = forwardRef<SortableListHandle, SortableListProps>(function SortableList(_props: SortableListProps, ref): JSX.Element {
const __defaultOptions = useState(() => (() => ({}))())[0];
const props: Omit<SortableListProps, 'itemKey' | 'handle' | 'group' | 'animation' | 'disabled' | 'disableKeyboard' | 'options' | 'labelFor' | 'ghostClass' | 'chosenClass' | 'dragClass' | 'filter' | 'easing' | 'forceFallback' | 'swapThreshold' | 'cloneable' | 'listClass' | 'itemClass' | 'itemStyle'> & { itemKey: (string | ((...args: any[]) => any)) | null; handle: (string) | null; group: (string) | null; animation: number; disabled: boolean; disableKeyboard: boolean; options: Record<string, any>; labelFor: ((...args: any[]) => any) | null; ghostClass: (string) | null; chosenClass: (string) | null; dragClass: (string) | null; filter: (string) | null; easing: (string) | null; forceFallback: boolean; swapThreshold: number; cloneable: boolean; listClass: string | any[] | Record<string, any>; itemClass: string | any[] | Record<string, any> | ((...args: any[]) => any); itemStyle: (string | Record<string, any> | ((...args: any[]) => any)) | null } = {
..._props,
itemKey: _props.itemKey ?? null,
handle: _props.handle ?? null,
group: _props.group ?? null,
animation: _props.animation ?? 150,
disabled: _props.disabled ?? false,
disableKeyboard: _props.disableKeyboard ?? false,
options: _props.options ?? __defaultOptions,
labelFor: _props.labelFor ?? null,
ghostClass: _props.ghostClass ?? null,
chosenClass: _props.chosenClass ?? null,
dragClass: _props.dragClass ?? null,
filter: _props.filter ?? null,
easing: _props.easing ?? null,
forceFallback: _props.forceFallback ?? false,
swapThreshold: _props.swapThreshold ?? 1,
cloneable: _props.cloneable ?? false,
listClass: _props.listClass ?? '',
itemClass: _props.itemClass ?? '',
itemStyle: _props.itemStyle ?? null,
};
const attrs: Record<string, unknown> = (() => {
const { items, itemKey, handle, group, animation, disabled, disableKeyboard, options, labelFor, ghostClass, chosenClass, dragClass, filter, easing, forceFallback, swapThreshold, cloneable, listClass, itemClass, itemStyle, defaultValue, onItemsChange, defaultItems, ...rest } = _props as SortableListProps & Record<string, unknown>;
void items; void itemKey; void handle; void group; void animation; void disabled; void disableKeyboard; void options; void labelFor; void ghostClass; void chosenClass; void dragClass; void filter; void easing; void forceFallback; void swapThreshold; void cloneable; void listClass; void itemClass; void itemStyle; void defaultValue; void onItemsChange; void defaultItems;
return rest;
})();
const instance = useRef<any>(null);
const [items, setItems] = useControllableState({
value: props.items,
defaultValue: props.defaultItems ?? (() => [])(),
onValueChange: props.onItemsChange,
});
const _chosenClassRef = useRef(props.chosenClass);
_chosenClassRef.current = props.chosenClass;
const _disabledRef = useRef(props.disabled);
_disabledRef.current = props.disabled;
const _dragClassRef = useRef(props.dragClass);
_dragClassRef.current = props.dragClass;
const _easingRef = useRef(props.easing);
_easingRef.current = props.easing;
const _filterRef = useRef(props.filter);
_filterRef.current = props.filter;
const _ghostClassRef = useRef(props.ghostClass);
_ghostClassRef.current = props.ghostClass;
const _groupRef = useRef(props.group);
_groupRef.current = props.group;
const _handleRef = useRef(props.handle);
_handleRef.current = props.handle;
const _itemsRef = useRef(items);
_itemsRef.current = items;
const [liftedIndex, setLiftedIndex] = useState<any>(null);
const [ariaLiveText, setAriaLiveText] = useState('');
const listEl = useRef<HTMLDivElement | null>(null);
const __rozieRoot = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
const _watch3First = useRef(true);
const _watch4First = useRef(true);
const _watch5First = useRef(true);
const _watch6First = useRef(true);
const _watch7First = useRef(true);
const __rowKey = useMemo(() => ({
map: new WeakMap(),
seq: 0
}), []);
function keyFor(item: any, index: any) {
// (a) function itemKey: consumer-supplied (item, index) => key.
if (typeof props.itemKey === 'function') {
return props.itemKey(item, index);
}
// (b) string itemKey: a property name on a non-null object item.
if (typeof props.itemKey === 'string' && item !== null && typeof item === 'object' && item[props.itemKey] != null) {
return item[props.itemKey];
}
// (c) id-less object (or function) item: assign-on-first-sight WeakMap
// synthetic id. Survives reorder because it is keyed by object identity.
if (item !== null && typeof item === 'object' || typeof item === 'function') {
if (!__rowKey.map.has(item)) {
__rowKey.map.set(item, '__rk' + __rowKey.seq++);
}
return __rowKey.map.get(item);
}
// (d) primitive item: fall back to index. NOTE: duplicate primitives are
// unsafe to reorder this way — pass a function itemKey for those.
return index;
}
function itemClassFor(item: any, index: any) {
const v = props.itemClass;
return typeof v === 'function' ? v(item, index) : v;
}
function itemStyleFor(item: any, index: any) {
const s = typeof props.itemStyle === 'function' ? props.itemStyle(item, index) : props.itemStyle;
return s == null || s === '' ? null : s;
}
function getLabel(idx: any) {
const item = items[idx];
if (props.labelFor !== null) return props.labelFor(item, idx);
if (item !== null && typeof item === 'object' && 'label' in item) return item.label;
return String(item);
}
function keyboardEnabled() {
return !props.disabled && !props.disableKeyboard;
}
const { onChange: _rozieProp_onChange } = props;
const onRowKeyDown = useCallback(($event: any, index: any) => {
// Defense-in-depth: when keyboard reordering is off the rows carry no
// tabindex and can't receive focus, but a consumer-focused row (or a
// programmatic .focus()) must still no-op here rather than reorder.
if (!keyboardEnabled()) return;
const key = $event.key;
// Space (' ' on browsers; KeyboardEvent.key === ' ') OR Enter — lift/drop.
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
$event.preventDefault();
if (liftedIndex === null) {
// LIFT
setLiftedIndex(index);
setAriaLiveText('Lifted ' + getLabel(index));
return;
}
// DROP
const dropped = getLabel(liftedIndex);
const at = liftedIndex;
setLiftedIndex(null);
setAriaLiveText('Dropped ' + dropped + ' at position ' + (at + 1));
return;
}
if (key === 'Escape') {
if (liftedIndex === null) return;
$event.preventDefault();
const cancelled = getLabel(liftedIndex);
setLiftedIndex(null);
setAriaLiveText('Cancelled lift of ' + cancelled);
return;
}
if (key === 'ArrowDown' || key === 'ArrowUp') {
if (liftedIndex === null) return;
$event.preventDefault();
const dir = key === 'ArrowDown' ? 1 : -1;
const from = liftedIndex;
const to = from + dir;
if (to < 0 || to >= items.length) return;
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
setItems(next);
setLiftedIndex(to);
setAriaLiveText('Moved ' + getLabel(to) + ' to position ' + (to + 1));
// After the keyed reorder write, restore focus to the moved row. No-op
// on React/Vue/Angular (DOM identity preserved); queueMicrotask +
// querySelectorAll + .focus() on Svelte/Solid/Lit (DOM re-created).
void 0;
_rozieProp_onChange && _rozieProp_onChange({
oldIndex: from,
newIndex: to,
item: moved
});
}
}, [_rozieProp_onChange, getLabel, items, keyboardEnabled, liftedIndex, setItems]);
// Imperative handle (Phase 21 $expose). The SortableJS imperative surface a
// consumer can't drive through props alone — exposed uniformly to all 6 targets.
// Each guards the pre-mount/destroyed `instance = null`. Collision-clear: none of
// the 4 verb names collide with the 16 props or the 5 events — `option` is a
// distinct identifier from the `options` prop, so ROZ121 is clear.
function getInstance() {
return instance.current;
}
// toArray()/sort() operate on SortableJS's data-id ordering — every row carries
// :data-id="keyFor(item, index)", so toArray() returns the current key order and
// sort(order) reorders by those keys (set itemKey for stable object-list keys).
// toArray()/sort() operate on SortableJS's data-id ordering — every row carries
// :data-id="keyFor(item, index)", so toArray() returns the current key order and
// sort(order) reorders by those keys (set itemKey for stable object-list keys).
function toArray() {
return instance.current ? instance.current.toArray() : [];
}
function sort(order: any, useAnimation = true) {
instance.current?.sort(order, useAnimation);
}
// option(name) reads a live SortableJS option; option(name, value) sets one — the
// runtime escape hatch for any SortableJS option beyond the curated props.
// option(name) reads a live SortableJS option; option(name, value) sets one — the
// runtime escape hatch for any SortableJS option beyond the curated props.
function option(name: any, value: any) {
if (!instance.current) return undefined;
if (value === undefined) return instance.current.option(name);
instance.current.option(name, value);
return value;
}
useEffect(() => {
// Named `sortable` (not `handle`) to avoid shadowing `$props.handle`
// when the options object below references it.
const sortable = useSortableJS(listEl.current!, {
items: () => _itemsRef.current,
onCommit: (next: any) => {
setItems(next);
},
options: {
animation: props.animation,
disabled: _disabledRef.current,
// `cloneable` is a high-level Rozie prop that REPLACES a string
// `group` with SortableJS's `{ name, pull: 'clone', put: true }`
// object form. When `cloneable:false`, pass `$props.group` through
// verbatim. When `cloneable:true` AND `$props.group` is null,
// leave it null — a clone-mode list without a group name is not
// meaningful (no peer list can join the cross-list flow).
group: props.cloneable && typeof _groupRef.current === 'string' ? {
name: _groupRef.current,
pull: 'clone',
put: true
} : _groupRef.current,
handle: _handleRef.current,
ghostClass: _ghostClassRef.current,
chosenClass: _chosenClassRef.current,
dragClass: _dragClassRef.current,
filter: _filterRef.current,
forceFallback: props.forceFallback,
swapThreshold: props.swapThreshold,
easing: _easingRef.current,
...props.options
},
// Lit lit-html `repeat` directive caches its part array by sentinel-
// comment node identity; SortableJS's physical DOM mutation desyncs
// that cache. The sigil lowers to `__rozieReconcileAfterDomMutation(this)`
// on Lit (real call) and `void 0` on the other 5 targets (no-op).
afterCommit: () => void 0,
onChange: ({
kind,
oldIndex,
newIndex,
item
}: any) => {
if (kind === 'reorder') props.onChange && props.onChange({
oldIndex,
newIndex,
item
});else if (kind === 'add') props.onAdd && props.onAdd({
newIndex,
item
});else if (kind === 'remove') props.onRemove && props.onRemove({
oldIndex,
item
});
},
onStart: (e: any) => props.onStart && props.onStart(e),
onEnd: (e: any) => props.onEnd && props.onEnd(e)
});
instance.current = sortable.instance;
// $onMount's cleanup-return: closing over a setup-local (`sortable`) does
// not survive the Solid emitter's setup/cleanup split — it scopes cleanup
// outside the setup IIFE. Closing over `instance` (a module-scope `let`)
// works on every target.
return () => instance.current?.destroy();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
const v = props.disabled;
instance.current?.option('disabled', v);
}, [props.disabled]);
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
const v = props.group;
instance.current?.option('group', v);
}, [props.group]);
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
const v = props.handle;
instance.current?.option('handle', v);
}, [props.handle]);
useEffect(() => {
if (_watch3First.current) { _watch3First.current = false; return; }
const v = props.ghostClass;
instance.current?.option('ghostClass', v);
}, [props.ghostClass]);
useEffect(() => {
if (_watch4First.current) { _watch4First.current = false; return; }
const v = props.chosenClass;
instance.current?.option('chosenClass', v);
}, [props.chosenClass]);
useEffect(() => {
if (_watch5First.current) { _watch5First.current = false; return; }
const v = props.dragClass;
instance.current?.option('dragClass', v);
}, [props.dragClass]);
useEffect(() => {
if (_watch6First.current) { _watch6First.current = false; return; }
const v = props.filter;
instance.current?.option('filter', v);
}, [props.filter]);
useEffect(() => {
if (_watch7First.current) { _watch7First.current = false; return; }
const v = props.easing;
instance.current?.option('easing', v);
}, [props.easing]);
const _rozieExposeRef = useRef({ getInstance, toArray, sort, option });
_rozieExposeRef.current = { getInstance, toArray, sort, option };
useImperativeHandle(ref, () => ({ getInstance: (...args: Parameters<typeof getInstance>): ReturnType<typeof getInstance> => _rozieExposeRef.current.getInstance(...args), toArray: (...args: Parameters<typeof toArray>): ReturnType<typeof toArray> => _rozieExposeRef.current.toArray(...args), sort: (...args: Parameters<typeof sort>): ReturnType<typeof sort> => _rozieExposeRef.current.sort(...args), option: (...args: Parameters<typeof option>): ReturnType<typeof option> => _rozieExposeRef.current.option(...args) }), []);
return (
<>
<div ref={__rozieRoot} {...attrs} className={clsx("rozie-sortable-wrap", (attrs.className as string | undefined))} data-rozie-s-0af24eae="">
<div className={clsx(['rozie-sortable-list', props.listClass])} ref={listEl} part="list" data-rozie-s-0af24eae="">
{(props.renderHeader ?? props.slots?.['header'])?.()}
{items.map((item, index) => <div key={keyFor(item, index)} className={clsx(['rozie-sortable-item', itemClassFor(item, index), { 'rozie-sortable-item-lifted': liftedIndex === index }])} style={parseInlineStyle(itemStyleFor(item, index))} data-id={rozieAttr(keyFor(item, index))} role="listitem" tabIndex={(keyboardEnabled() ? 0 : undefined) ?? undefined} onKeyDown={($event) => { onRowKeyDown($event, index); }} data-rozie-s-0af24eae="">
{typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)({ item, index }) : (props.children ?? props.slots?.[''])}
</div>)}
{(props.renderFooter ?? props.slots?.['footer'])?.()}
</div>
<div className={"rozie-sortable-aria-live"} data-rozie-sortable-aria-live="" aria-live="polite" aria-atomic="true" data-rozie-s-0af24eae="">{ariaLiveText}</div>
</div>
</>
);
});
export default SortableList;svelte
<script lang="ts">
import { applyListeners, rozieAttr, rozieClass, rozieStyle } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { onMount, untrack } from 'svelte';
interface Props {
/**
* The bound items array. The sole `model: true` prop — two-way bind it (`r-model:items` / `v-model:items` / `bind:items` / `[(items)]`) and SortableList writes the re-ordered array back whenever a drag, cross-list move, or keyboard reorder commits, with no manual `onChange → setState` wiring.
* @example
* <SortableList r-model:items="$data.todos" itemKey="id" />
*/
items?: any[];
/**
* The per-row key the framework reconciler tracks each item by across a reorder — either a property name (e.g. `itemKey="id"` reads `item.id`) or an `(item, index) => key` function. With neither, id-less object items get a stable synthetic key via an internal `WeakMap` (survives reorder by object identity); primitive items fall back to index — pass a function for reorderable duplicate primitives.
*/
itemKey?: (string | ((...args: any[]) => any)) | null;
/**
* CSS selector identifying the per-row drag handle, so a drag starts only from that element rather than anywhere in the row. Authored class names render literally on every target (React included), so a plain `.grip` works; `$classSelector('grip')` is an optional, typo-checked way to author it.
*/
handle?: (string) | null;
/**
* SortableJS group name enabling cross-list drag — two lists sharing a `group` accept items between each other (the source fires `remove`, the destination fires `add`). Set `cloneable: true` to flip a string group into clone-mode.
*/
group?: (string) | null;
/**
* Reorder animation duration in milliseconds. `0` disables the animation. Runtime-updatable.
*/
animation?: number;
/**
* Temporarily disable dragging without unmounting — reapplied live via `instance.option('disabled', v)` (no remount). Also suppresses keyboard reordering: a disabled list is not sortable by any input, so rows lose their `tabindex` and the keydown handler no-ops.
*/
disabled?: boolean;
/**
* Opt out of keyboard reordering (Space lift / Arrow move / Esc cancel / Enter drop) while leaving pointer drag enabled. Rows drop out of the tab order (no `tabindex`) and the keydown handler no-ops. Keyboard access is gated on `!disabled && !disableKeyboard`.
*/
disableKeyboard?: boolean;
/**
* Verbatim SortableJS options pass-through for anything not covered by the named props. The named props win on key conflict but `options` lands AFTER them in the merge so consumers can override defaults; handler keys (`onStart`, `onEnd`, `onUpdate`, `onAdd`, `onRemove`, `onClone`) are stripped — the helper owns those paths.
*/
options?: any;
/**
* Optional `(item, idx) => string` returning the screen-reader label for the aria-live announcer during keyboard drag. Defaults to `item.label` (or `String(item)` when no `label` field exists).
*/
labelFor?: ((...args: any[]) => any) | null;
/**
* Class name applied to the drop-placeholder (ghost) element while dragging. Forwarded live via `instance.option`, so toggling it at runtime takes effect without a remount.
*/
ghostClass?: (string) | null;
/**
* Class name applied to the currently-chosen item while dragging. Forwarded live via `instance.option` (no remount needed to change it).
*/
chosenClass?: (string) | null;
/**
* Class name applied to the dragging element. Only takes effect in fallback mode (`forceFallback: true`). Forwarded live via `instance.option`.
*/
dragClass?: (string) | null;
/**
* CSS selector that prevents drag initiation on matching rows (locked items). SortableJS checks it at `mousedown`/`touchstart` and aborts the drag if it matches. A `data-*` attribute selector (e.g. `[data-locked]`) is the most robust choice across all targets.
*/
filter?: (string) | null;
/**
* CSS easing function for the reorder animation (e.g. `'ease-in'`, `'cubic-bezier(0.4, 0, 0.2, 1)'`). Runtime-updatable.
*/
easing?: (string) | null;
/**
* Force SortableJS's mouse-event drag path over HTML5 DnD — useful for touch devices, consistent cross-browser behavior, and synthetic test drivers (and `dragClass` only applies in this mode). **Construction-time only**: SortableJS reads it once at construction, so re-key the `<SortableList>` to toggle it at runtime.
*/
forceFallback?: boolean;
/**
* SortableJS swap threshold (0..1) — a lower value makes rows swap earlier as the dragged item overlaps a neighbor. **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
swapThreshold?: number;
/**
* High-level prop that REPLACES a string `group` with SortableJS's `{ name, pull: 'clone', put: true }` clone-mode object form — the source deposits a COPY onto the destination and keeps its own array unchanged (the palette → canvas pattern). With `group: null` it is a no-op (a clone-mode list with no group name has no peer to clone into). **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
cloneable?: boolean;
/**
* Extra class(es) merged onto the list container (the SortableJS root) alongside the base `rozie-sortable-list` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding), normalized identically across all six targets — the hook for bridging a CSS framework (`.list-group`) or a flex/grid parent onto the component.
*/
listClass?: string | any[] | any;
/**
* Extra class(es) merged onto every item row alongside the base `rozie-sortable-item` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding) applied uniformly, OR an `(item, index) => class` function for per-row classes evaluated at render time. Normalized identically across all six targets.
*/
itemClass?: string | any[] | any | ((...args: any[]) => any);
/**
* Per-row inline style applied to the `.rozie-sortable-item` wrapper. Accepts a CSS `String`, a flat style object (`Record<string, string | number>`), or an `(item, index) => string | object` function for per-row styling. Because it lands on the wrapper — the direct child of the list container — it can drive CSS-grid placement (`grid-column` / `grid-row` / `align-self`) when `listClass` sets `display: grid`. Normalized per target; `null` / empty drops the attribute.
*/
itemStyle?: (string | any | ((...args: any[]) => any)) | null;
header?: Snippet;
children?: Snippet<[{ item: any; index: any }]>;
footer?: Snippet;
snippets?: Record<string, any>;
onchange?: (...args: unknown[]) => void;
onadd?: (...args: unknown[]) => void;
onremove?: (...args: unknown[]) => void;
onstart?: (...args: unknown[]) => void;
onend?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let __defaultOptions = (() => ({}))();
let {
items = $bindable((() => [])()),
itemKey = null,
handle = null,
group = null,
animation = 150,
disabled = false,
disableKeyboard = false,
options = __defaultOptions,
labelFor = null,
ghostClass = null,
chosenClass = null,
dragClass = null,
filter = null,
easing = null,
forceFallback = false,
swapThreshold = 1,
cloneable = false,
listClass = '',
itemClass = '',
itemStyle = null,
header: __headerProp,
children: __childrenProp,
footer: __footerProp,
snippets,
onchange,
onadd,
onremove,
onstart,
onend,
...__rozieAttrs
}: Props = $props();
const header = $derived(__headerProp ?? snippets?.header);
const children = $derived(__childrenProp ?? snippets?.children);
const footer = $derived(__footerProp ?? snippets?.footer);
let liftedIndex: any = $state(null);
let ariaLiveText = $state('');
let listEl = $state<HTMLElement | undefined>(undefined);
let __rozieRoot = $state<HTMLElement | undefined>(undefined);
import { useSortableJS } from './internal/useSortableJS';
let instance: any = null;
// Instance-scoped synthetic-id store for id-less object items. Keyed by object
// IDENTITY, so the same object keeps its synthetic id across a reorder (the
// framework reconciler then rebinds the row component instance to its ORIGINAL
// item, not its slot position — the data-corruption fix). BOTH the WeakMap and
// the monotonic counter live in ONE member-mutated fresh-instance object so the
// React emitter hoists the whole thing to a single useMemo(() => …, []) (the
// setup-once-persistence guarantee). Folding the counter in is deliberate: a
// bare `let __rowKeySeq = 0` mutated only inside the non-hook keyFor helper is
// NOT caught by React's hoistModuleLet (it resets every render → an item added
// in a later render collides on an already-issued synthetic id → corruption).
// new WeakMap()/seq inside one object dodges that emitter gap. Verified in codegen.
// Instance-scoped synthetic-id store for id-less object items. Keyed by object
// IDENTITY, so the same object keeps its synthetic id across a reorder (the
// framework reconciler then rebinds the row component instance to its ORIGINAL
// item, not its slot position — the data-corruption fix). BOTH the WeakMap and
// the monotonic counter live in ONE member-mutated fresh-instance object so the
// React emitter hoists the whole thing to a single useMemo(() => …, []) (the
// setup-once-persistence guarantee). Folding the counter in is deliberate: a
// bare `let __rowKeySeq = 0` mutated only inside the non-hook keyFor helper is
// NOT caught by React's hoistModuleLet (it resets every render → an item added
// in a later render collides on an already-issued synthetic id → corruption).
// new WeakMap()/seq inside one object dodges that emitter gap. Verified in codegen.
const __rowKey = {
map: new WeakMap(),
seq: 0
};
// 4-tier per-row key precedence. Its return feeds BOTH :key and :data-id.
// 4-tier per-row key precedence. Its return feeds BOTH :key and :data-id.
const keyFor = (item: any, index: any) => {
// (a) function itemKey: consumer-supplied (item, index) => key.
if (typeof itemKey === 'function') {
return itemKey(item, index);
}
// (b) string itemKey: a property name on a non-null object item.
if (typeof itemKey === 'string' && item !== null && typeof item === 'object' && item[itemKey] != null) {
return item[itemKey];
}
// (c) id-less object (or function) item: assign-on-first-sight WeakMap
// synthetic id. Survives reorder because it is keyed by object identity.
if (item !== null && typeof item === 'object' || typeof item === 'function') {
if (!__rowKey.map.has(item)) {
__rowKey.map.set(item, '__rk' + __rowKey.seq++);
}
return __rowKey.map.get(item);
}
// (d) primitive item: fall back to index. NOTE: duplicate primitives are
// unsafe to reorder this way — pass a function itemKey for those.
return index;
};
// Resolve itemClass for a row: a static value (string | array | object) OR a
// per-row (item, index) => class function. The result is fed into the :class
// array and normalized by each target's class path (rozieClass / clsx / native).
// Resolve itemClass for a row: a static value (string | array | object) OR a
// per-row (item, index) => class function. The result is fed into the :class
// array and normalized by each target's class path (rozieClass / clsx / native).
const itemClassFor = (item: any, index: any) => {
const v = itemClass;
return typeof v === 'function' ? v(item, index) : v;
};
// Resolve itemStyle for a row: a static value (string | object) OR a per-row
// (item, index) => style function. Returns string | object | null; the dynamic
// :style binding normalizes it per target. null / empty → attribute dropped.
// Resolve itemStyle for a row: a static value (string | object) OR a per-row
// (item, index) => style function. Returns string | object | null; the dynamic
// :style binding normalizes it per target. null / empty → attribute dropped.
const itemStyleFor = (item: any, index: any) => {
const s = typeof itemStyle === 'function' ? itemStyle(item, index) : itemStyle;
return s == null || s === '' ? null : s;
};
// Read the display label for an item — used by the aria-live announcer.
// Phase 16 R7 / D-08: $props.labelFor reads as `null` on all 6 targets when
// the consumer omits it (Plan 16-01 prop-default coercion fix); the check is
// a plain null compare — NO runtime callable-type coercion.
// Read the display label for an item — used by the aria-live announcer.
// Phase 16 R7 / D-08: $props.labelFor reads as `null` on all 6 targets when
// the consumer omits it (Plan 16-01 prop-default coercion fix); the check is
// a plain null compare — NO runtime callable-type coercion.
const getLabel = (idx: any) => {
const item = items[idx];
if (labelFor !== null) return labelFor(item, idx);
if (item !== null && typeof item === 'object' && 'label' in item) return item.label;
return String(item);
};
// Keyboard handler (Phase 16 R7): Space lifts/drops, ArrowDown/ArrowUp move
// the lifted row, Escape cancels, Enter is an alternate drop trigger. After
// any array-reorder write, $restoreFocus('[role="listitem"]', newIdx) keeps
// focus on the moved row across the React/Vue/Angular vs Svelte/Solid/Lit
// keyed-reconciler divide (Plan 16-03 sigil — no-op on the first three;
// queueMicrotask + querySelectorAll + .focus() on the latter three).
//
// Note: `index` is passed directly as a number. Plan 16-02 (Solid call-arg
// accessor unwrap) ensures Solid's <For> alias unwraps to `index()` at the
// call site — no runtime callable-type coercion needed in user source.
// Keyboard reordering is available only when the list is not disabled AND the
// `disableKeyboard` opt-out is off. Drives BOTH the row tabindex (rows are
// focusable only when reorderable) and the onRowKeyDown guard below. Reads
// straight off $props so the tabindex binding re-evaluates reactively when
// `disabled`/`disableKeyboard` toggle at runtime.
// Keyboard handler (Phase 16 R7): Space lifts/drops, ArrowDown/ArrowUp move
// the lifted row, Escape cancels, Enter is an alternate drop trigger. After
// any array-reorder write, $restoreFocus('[role="listitem"]', newIdx) keeps
// focus on the moved row across the React/Vue/Angular vs Svelte/Solid/Lit
// keyed-reconciler divide (Plan 16-03 sigil — no-op on the first three;
// queueMicrotask + querySelectorAll + .focus() on the latter three).
//
// Note: `index` is passed directly as a number. Plan 16-02 (Solid call-arg
// accessor unwrap) ensures Solid's <For> alias unwraps to `index()` at the
// call site — no runtime callable-type coercion needed in user source.
// Keyboard reordering is available only when the list is not disabled AND the
// `disableKeyboard` opt-out is off. Drives BOTH the row tabindex (rows are
// focusable only when reorderable) and the onRowKeyDown guard below. Reads
// straight off $props so the tabindex binding re-evaluates reactively when
// `disabled`/`disableKeyboard` toggle at runtime.
const keyboardEnabled = () => !disabled && !disableKeyboard;
const onRowKeyDown = ($event: any, index: any) => {
// Defense-in-depth: when keyboard reordering is off the rows carry no
// tabindex and can't receive focus, but a consumer-focused row (or a
// programmatic .focus()) must still no-op here rather than reorder.
if (!keyboardEnabled()) return;
const key = $event.key;
// Space (' ' on browsers; KeyboardEvent.key === ' ') OR Enter — lift/drop.
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
$event.preventDefault();
if (liftedIndex === null) {
// LIFT
liftedIndex = index;
ariaLiveText = 'Lifted ' + getLabel(index);
return;
}
// DROP
const dropped = getLabel(liftedIndex);
const at = liftedIndex;
liftedIndex = null;
ariaLiveText = 'Dropped ' + dropped + ' at position ' + (at + 1);
return;
}
if (key === 'Escape') {
if (liftedIndex === null) return;
$event.preventDefault();
const cancelled = getLabel(liftedIndex);
liftedIndex = null;
ariaLiveText = 'Cancelled lift of ' + cancelled;
return;
}
if (key === 'ArrowDown' || key === 'ArrowUp') {
if (liftedIndex === null) return;
$event.preventDefault();
const dir = key === 'ArrowDown' ? 1 : -1;
const from = liftedIndex;
const to = from + dir;
if (to < 0 || to >= items.length) return;
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
items = next;
liftedIndex = to;
ariaLiveText = 'Moved ' + getLabel(to) + ' to position ' + (to + 1);
// After the keyed reorder write, restore focus to the moved row. No-op
// on React/Vue/Angular (DOM identity preserved); queueMicrotask +
// querySelectorAll + .focus() on Svelte/Solid/Lit (DOM re-created).
queueMicrotask(() => (__rozieRoot!.querySelectorAll('[role="listitem"]')?.[to] as HTMLElement | undefined)?.focus?.());
onchange?.({
oldIndex: from,
newIndex: to,
item: moved
});
}
};
// SortableJS wiring lives in `useSortableJS()` (./internal/useSortableJS).
// The helper owns the SortableJS-vs-reconciler dance — DOM-restore hardening
// against fragile-event paths, identity-based item lookup over fragile
// `e.oldIndex`, and the single-onEnd disambiguation that collapses
// onUpdate / onAdd / onRemove into one handler.
//
// What stays here is purely declarative: which array to read, what to write
// back, what to emit, and how to bridge `afterCommit` to the Lit-only
// `$reconcileAfterDomMutation()` sigil.
// Imperative handle (Phase 21 $expose). The SortableJS imperative surface a
// consumer can't drive through props alone — exposed uniformly to all 6 targets.
// Each guards the pre-mount/destroyed `instance = null`. Collision-clear: none of
// the 4 verb names collide with the 16 props or the 5 events — `option` is a
// distinct identifier from the `options` prop, so ROZ121 is clear.
export function getInstance() {
return instance;
}
// toArray()/sort() operate on SortableJS's data-id ordering — every row carries
// :data-id="keyFor(item, index)", so toArray() returns the current key order and
// sort(order) reorders by those keys (set itemKey for stable object-list keys).
// toArray()/sort() operate on SortableJS's data-id ordering — every row carries
// :data-id="keyFor(item, index)", so toArray() returns the current key order and
// sort(order) reorders by those keys (set itemKey for stable object-list keys).
export function toArray() {
return instance ? instance.toArray() : [];
}
export function sort(order: any, useAnimation = true) {
instance?.sort(order, useAnimation);
}
// option(name) reads a live SortableJS option; option(name, value) sets one — the
// runtime escape hatch for any SortableJS option beyond the curated props.
// option(name) reads a live SortableJS option; option(name, value) sets one — the
// runtime escape hatch for any SortableJS option beyond the curated props.
export function option(name: any, value: any) {
if (!instance) return undefined;
if (value === undefined) return instance.option(name);
instance.option(name, value);
return value;
}
onMount(() => {
// Named `sortable` (not `handle`) to avoid shadowing `$props.handle`
// when the options object below references it.
const sortable = useSortableJS(listEl!, {
items: () => items,
onCommit: (next: any) => {
items = next;
},
options: {
animation: animation,
disabled: disabled,
// `cloneable` is a high-level Rozie prop that REPLACES a string
// `group` with SortableJS's `{ name, pull: 'clone', put: true }`
// object form. When `cloneable:false`, pass `$props.group` through
// verbatim. When `cloneable:true` AND `$props.group` is null,
// leave it null — a clone-mode list without a group name is not
// meaningful (no peer list can join the cross-list flow).
group: cloneable && typeof group === 'string' ? {
name: group,
pull: 'clone',
put: true
} : group,
handle: handle,
ghostClass: ghostClass,
chosenClass: chosenClass,
dragClass: dragClass,
filter: filter,
forceFallback: forceFallback,
swapThreshold: swapThreshold,
easing: easing,
...options
},
// Lit lit-html `repeat` directive caches its part array by sentinel-
// comment node identity; SortableJS's physical DOM mutation desyncs
// that cache. The sigil lowers to `__rozieReconcileAfterDomMutation(this)`
// on Lit (real call) and `void 0` on the other 5 targets (no-op).
afterCommit: () => void 0,
onChange: ({
kind,
oldIndex,
newIndex,
item
}: any) => {
if (kind === 'reorder') onchange?.({
oldIndex,
newIndex,
item
});else if (kind === 'add') onadd?.({
newIndex,
item
});else if (kind === 'remove') onremove?.({
oldIndex,
item
});
},
onStart: (e: any) => onstart?.(e),
onEnd: (e: any) => onend?.(e)
});
instance = sortable.instance;
// $onMount's cleanup-return: closing over a setup-local (`sortable`) does
// not survive the Solid emitter's setup/cleanup split — it scopes cleanup
// outside the setup IIFE. Closing over `instance` (a module-scope `let`)
// works on every target.
return () => instance?.destroy();
});
let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => disabled)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => instance?.option('disabled', v))(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { const __watchVal = (() => group)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } ((v: any) => instance?.option('group', v))(__watchVal); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { const __watchVal = (() => handle)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } ((v: any) => instance?.option('handle', v))(__watchVal); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { const __watchVal = (() => ghostClass)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } ((v: any) => instance?.option('ghostClass', v))(__watchVal); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { const __watchVal = (() => chosenClass)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } ((v: any) => instance?.option('chosenClass', v))(__watchVal); }); });
let __rozieWatchInitial_5 = true;
$effect(() => { const __watchVal = (() => dragClass)(); untrack(() => { if (__rozieWatchInitial_5) { __rozieWatchInitial_5 = false; return; } ((v: any) => instance?.option('dragClass', v))(__watchVal); }); });
let __rozieWatchInitial_6 = true;
$effect(() => { const __watchVal = (() => filter)(); untrack(() => { if (__rozieWatchInitial_6) { __rozieWatchInitial_6 = false; return; } ((v: any) => instance?.option('filter', v))(__watchVal); }); });
let __rozieWatchInitial_7 = true;
$effect(() => { const __watchVal = (() => easing)(); untrack(() => { if (__rozieWatchInitial_7) { __rozieWatchInitial_7 = false; return; } ((v: any) => instance?.option('easing', v))(__watchVal); }); });
</script>
<div bind:this={__rozieRoot} {...__rozieAttrs} class={["rozie-sortable-wrap", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-0af24eae><div class={rozieClass(['rozie-sortable-list', listClass])} bind:this={listEl} part="list" data-rozie-s-0af24eae>{@render header?.()}{#each items as item, index (keyFor(item, index))}<div class={rozieClass(['rozie-sortable-item', itemClassFor(item, index), { 'rozie-sortable-item-lifted': liftedIndex === index }])} style={rozieStyle(itemStyleFor(item, index))} data-id={rozieAttr(keyFor(item, index))} role="listitem" tabindex={rozieAttr(keyboardEnabled() ? 0 : null)} onkeydown={($event) => { onRowKeyDown($event, index); }} data-rozie-s-0af24eae>{@render children?.({ item, index })}</div>{/each}{@render footer?.()}</div><div class="rozie-sortable-aria-live" data-rozie-sortable-aria-live="" aria-live="polite" aria-atomic="true" data-rozie-s-0af24eae>{ariaLiveText}</div></div>
<style>
:global {
.rozie-sortable-wrap[data-rozie-s-0af24eae] { display: block; }
.rozie-sortable-list[data-rozie-s-0af24eae] { display: block; }
.rozie-sortable-item[data-rozie-s-0af24eae] { display: block; outline: none; }
.rozie-sortable-item[data-rozie-s-0af24eae]:focus { outline: 2px solid rgba(0, 102, 204, 0.6); outline-offset: -2px; }
.rozie-sortable-item-lifted[data-rozie-s-0af24eae] {
background: rgba(0, 102, 204, 0.08);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.4) inset;
}
.rozie-sortable-aria-live[data-rozie-s-0af24eae] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { useSortableJS } from './internal/useSortableJS';
interface HeaderCtx {}
interface DefaultCtx {
$implicit: { item: any; index: any };
item: any;
index: any;
}
interface FooterCtx {}
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-sortable-list',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<div class="rozie-sortable-wrap" #__rozieRoot #rozieSpread_0 #rozieListenersTarget_1>
<div [class]="['rozie-sortable-list', listClass()]" #listEl part="list">
<ng-container *ngTemplateOutlet="(headerTpl ?? templates()?.['header'])" />
@for (item of items(); track keyFor(item, index); let index = $index) {
<div [class]="['rozie-sortable-item', itemClassFor(item, index), { 'rozie-sortable-item-lifted': liftedIndex() === index }]" [style]="itemStyleFor(item, index)" [attr.data-id]="rozieAttr(keyFor(item, index))" role="listitem" [attr.tabindex]="rozieAttr(keyboardEnabled() ? 0 : null)" (keydown)="onRowKeyDown($event, index)">
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot']); context: { $implicit: { item: item, index: index }, item: item, index: index }" />
</div>
}
<ng-container *ngTemplateOutlet="(footerTpl ?? templates()?.['footer'])" />
</div>
<div class="rozie-sortable-aria-live" data-rozie-sortable-aria-live="" aria-live="polite" aria-atomic="true">{{ ariaLiveText() }}</div>
</div>
`,
styles: [`
.rozie-sortable-wrap { display: block; }
.rozie-sortable-list { display: block; }
.rozie-sortable-item { display: block; outline: none; }
.rozie-sortable-item:focus { outline: 2px solid rgba(0, 102, 204, 0.6); outline-offset: -2px; }
.rozie-sortable-item-lifted {
background: rgba(0, 102, 204, 0.08);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.4) inset;
}
.rozie-sortable-aria-live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SortableList),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class SortableList {
/**
* The bound items array. The sole `model: true` prop — two-way bind it (`r-model:items` / `v-model:items` / `bind:items` / `[(items)]`) and SortableList writes the re-ordered array back whenever a drag, cross-list move, or keyboard reorder commits, with no manual `onChange → setState` wiring.
* @example
* <SortableList r-model:items="$data.todos" itemKey="id" />
*/
items = model<any[]>((() => [])());
/**
* The per-row key the framework reconciler tracks each item by across a reorder — either a property name (e.g. `itemKey="id"` reads `item.id`) or an `(item, index) => key` function. With neither, id-less object items get a stable synthetic key via an internal `WeakMap` (survives reorder by object identity); primitive items fall back to index — pass a function for reorderable duplicate primitives.
*/
itemKey = input<(string | ((...args: unknown[]) => unknown)) | null>(null);
/**
* CSS selector identifying the per-row drag handle, so a drag starts only from that element rather than anywhere in the row. Authored class names render literally on every target (React included), so a plain `.grip` works; `$classSelector('grip')` is an optional, typo-checked way to author it.
*/
handle = input<(string) | null>(null);
/**
* SortableJS group name enabling cross-list drag — two lists sharing a `group` accept items between each other (the source fires `remove`, the destination fires `add`). Set `cloneable: true` to flip a string group into clone-mode.
*/
group = input<(string) | null>(null);
/**
* Reorder animation duration in milliseconds. `0` disables the animation. Runtime-updatable.
*/
animation = input<number>(150);
/**
* Temporarily disable dragging without unmounting — reapplied live via `instance.option('disabled', v)` (no remount). Also suppresses keyboard reordering: a disabled list is not sortable by any input, so rows lose their `tabindex` and the keydown handler no-ops.
*/
disabled = input<boolean>(false);
/**
* Opt out of keyboard reordering (Space lift / Arrow move / Esc cancel / Enter drop) while leaving pointer drag enabled. Rows drop out of the tab order (no `tabindex`) and the keydown handler no-ops. Keyboard access is gated on `!disabled && !disableKeyboard`.
*/
disableKeyboard = input<boolean>(false);
/**
* Verbatim SortableJS options pass-through for anything not covered by the named props. The named props win on key conflict but `options` lands AFTER them in the merge so consumers can override defaults; handler keys (`onStart`, `onEnd`, `onUpdate`, `onAdd`, `onRemove`, `onClone`) are stripped — the helper owns those paths.
*/
options = input<Record<string, any>>((() => ({}))());
/**
* Optional `(item, idx) => string` returning the screen-reader label for the aria-live announcer during keyboard drag. Defaults to `item.label` (or `String(item)` when no `label` field exists).
*/
labelFor = input<((...args: unknown[]) => unknown) | null>(null);
/**
* Class name applied to the drop-placeholder (ghost) element while dragging. Forwarded live via `instance.option`, so toggling it at runtime takes effect without a remount.
*/
ghostClass = input<(string) | null>(null);
/**
* Class name applied to the currently-chosen item while dragging. Forwarded live via `instance.option` (no remount needed to change it).
*/
chosenClass = input<(string) | null>(null);
/**
* Class name applied to the dragging element. Only takes effect in fallback mode (`forceFallback: true`). Forwarded live via `instance.option`.
*/
dragClass = input<(string) | null>(null);
/**
* CSS selector that prevents drag initiation on matching rows (locked items). SortableJS checks it at `mousedown`/`touchstart` and aborts the drag if it matches. A `data-*` attribute selector (e.g. `[data-locked]`) is the most robust choice across all targets.
*/
filter = input<(string) | null>(null);
/**
* CSS easing function for the reorder animation (e.g. `'ease-in'`, `'cubic-bezier(0.4, 0, 0.2, 1)'`). Runtime-updatable.
*/
easing = input<(string) | null>(null);
/**
* Force SortableJS's mouse-event drag path over HTML5 DnD — useful for touch devices, consistent cross-browser behavior, and synthetic test drivers (and `dragClass` only applies in this mode). **Construction-time only**: SortableJS reads it once at construction, so re-key the `<SortableList>` to toggle it at runtime.
*/
forceFallback = input<boolean>(false);
/**
* SortableJS swap threshold (0..1) — a lower value makes rows swap earlier as the dragged item overlaps a neighbor. **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
swapThreshold = input<number>(1);
/**
* High-level prop that REPLACES a string `group` with SortableJS's `{ name, pull: 'clone', put: true }` clone-mode object form — the source deposits a COPY onto the destination and keeps its own array unchanged (the palette → canvas pattern). With `group: null` it is a no-op (a clone-mode list with no group name has no peer to clone into). **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
cloneable = input<boolean>(false);
/**
* Extra class(es) merged onto the list container (the SortableJS root) alongside the base `rozie-sortable-list` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding), normalized identically across all six targets — the hook for bridging a CSS framework (`.list-group`) or a flex/grid parent onto the component.
*/
listClass = input<string | any[] | Record<string, any>>('');
/**
* Extra class(es) merged onto every item row alongside the base `rozie-sortable-item` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding) applied uniformly, OR an `(item, index) => class` function for per-row classes evaluated at render time. Normalized identically across all six targets.
*/
itemClass = input<string | any[] | Record<string, any> | ((...args: unknown[]) => unknown)>('');
/**
* Per-row inline style applied to the `.rozie-sortable-item` wrapper. Accepts a CSS `String`, a flat style object (`Record<string, string | number>`), or an `(item, index) => string | object` function for per-row styling. Because it lands on the wrapper — the direct child of the list container — it can drive CSS-grid placement (`grid-column` / `grid-row` / `align-self`) when `listClass` sets `display: grid`. Normalized per target; `null` / empty drops the attribute.
*/
itemStyle = input<(string | Record<string, any> | ((...args: unknown[]) => unknown)) | null>(null);
liftedIndex = signal<any>(null);
ariaLiveText = signal('');
listEl = viewChild<ElementRef<HTMLDivElement>>('listEl');
__rozieRoot = viewChild<ElementRef<HTMLDivElement>>('__rozieRoot');
change = output<unknown>();
add = output<unknown>();
remove = output<unknown>();
start = output<unknown>();
end = output<unknown>();
@ContentChild('header', { read: TemplateRef }) headerTpl?: TemplateRef<HeaderCtx>;
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
@ContentChild('footer', { read: TemplateRef }) footerTpl?: TemplateRef<FooterCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private __rozieWatchInitial_4 = true;
private __rozieWatchInitial_5 = true;
private __rozieWatchInitial_6 = true;
private __rozieWatchInitial_7 = true;
constructor() {
effect(() => { const __watchVal = (() => (this.disabled() || this.__rozieCvaDisabled()))(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => this.instance?.option('disabled', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.group())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => this.instance?.option('group', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.handle())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } ((v: any) => this.instance?.option('handle', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.ghostClass())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => this.instance?.option('ghostClass', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.chosenClass())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((v: any) => this.instance?.option('chosenClass', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.dragClass())(); untracked(() => { if (this.__rozieWatchInitial_5) { this.__rozieWatchInitial_5 = false; return; } ((v: any) => this.instance?.option('dragClass', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.filter())(); untracked(() => { if (this.__rozieWatchInitial_6) { this.__rozieWatchInitial_6 = false; return; } ((v: any) => this.instance?.option('filter', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.easing())(); untracked(() => { if (this.__rozieWatchInitial_7) { this.__rozieWatchInitial_7 = false; return; } ((v: any) => this.instance?.option('easing', v))(__watchVal); }); });
}
ngAfterViewInit() {
const __group = this.group();
// Named `sortable` (not `handle`) to avoid shadowing `$props.handle`
// when the options object below references it.
const sortable = useSortableJS(this.listEl()!.nativeElement, {
items: () => this.items(),
onCommit: (next: any) => {
this.items.set(next), this.__rozieCvaOnChange(next);
},
options: {
animation: this.animation(),
disabled: (this.disabled() || this.__rozieCvaDisabled()),
// `cloneable` is a high-level Rozie prop that REPLACES a string
// `group` with SortableJS's `{ name, pull: 'clone', put: true }`
// object form. When `cloneable:false`, pass `$props.group` through
// verbatim. When `cloneable:true` AND `$props.group` is null,
// leave it null — a clone-mode list without a group name is not
// meaningful (no peer list can join the cross-list flow).
group: this.cloneable() && typeof __group === 'string' ? {
name: __group,
pull: 'clone',
put: true
} : __group,
handle: this.handle(),
ghostClass: this.ghostClass(),
chosenClass: this.chosenClass(),
dragClass: this.dragClass(),
filter: this.filter(),
forceFallback: this.forceFallback(),
swapThreshold: this.swapThreshold(),
easing: this.easing(),
...this.options()
},
// Lit lit-html `repeat` directive caches its part array by sentinel-
// comment node identity; SortableJS's physical DOM mutation desyncs
// that cache. The sigil lowers to `__rozieReconcileAfterDomMutation(this)`
// on Lit (real call) and `void 0` on the other 5 targets (no-op).
afterCommit: () => void 0,
onChange: ({
kind,
oldIndex,
newIndex,
item
}: any) => {
if (kind === 'reorder') this.change.emit({
oldIndex,
newIndex,
item
});else if (kind === 'add') this.add.emit({
newIndex,
item
});else if (kind === 'remove') this.remove.emit({
oldIndex,
item
});
},
onStart: (e: any) => this.start.emit(e),
onEnd: (e: any) => this.end.emit(e)
});
this.instance = sortable.instance;
// $onMount's cleanup-return: closing over a setup-local (`sortable`) does
// not survive the Solid emitter's setup/cleanup split — it scopes cleanup
// outside the setup IIFE. Closing over `instance` (a module-scope `let`)
// works on every target.
this.__rozieDestroyRef.onDestroy(() => this.instance?.destroy());
}
instance: any = null;
__rowKey = {
map: new WeakMap(),
seq: 0
};
keyFor = (item: any, index: any) => {
const __itemKey = this.itemKey();
// (a) function itemKey: consumer-supplied (item, index) => key.
if (typeof __itemKey === 'function') {
return __itemKey(item, index);
}
// (b) string itemKey: a property name on a non-null object item.
if (typeof __itemKey === 'string' && item !== null && typeof item === 'object' && item[__itemKey] != null) {
return item[__itemKey];
}
// (c) id-less object (or function) item: assign-on-first-sight WeakMap
// synthetic id. Survives reorder because it is keyed by object identity.
if (item !== null && typeof item === 'object' || typeof item === 'function') {
if (!this.__rowKey.map.has(item)) {
this.__rowKey.map.set(item, '__rk' + this.__rowKey.seq++);
}
return this.__rowKey.map.get(item);
}
// (d) primitive item: fall back to index. NOTE: duplicate primitives are
// unsafe to reorder this way — pass a function itemKey for those.
return index;
};
itemClassFor = (item: any, index: any) => {
const v = this.itemClass();
return typeof v === 'function' ? v(item, index) : v;
};
itemStyleFor = (item: any, index: any) => {
const __itemStyle = this.itemStyle();
const s = typeof __itemStyle === 'function' ? __itemStyle(item, index) : __itemStyle;
return s == null || s === '' ? null : s;
};
getLabel = (idx: any) => {
const __labelFor = this.labelFor();
const item = this.items()[idx];
if (__labelFor !== null) return __labelFor(item, idx);
if (item !== null && typeof item === 'object' && 'label' in item) return item.label;
return String(item);
};
keyboardEnabled = () => !(this.disabled() || this.__rozieCvaDisabled()) && !this.disableKeyboard();
onRowKeyDown = ($event: any, index: any) => {
// Defense-in-depth: when keyboard reordering is off the rows carry no
// tabindex and can't receive focus, but a consumer-focused row (or a
// programmatic .focus()) must still no-op here rather than reorder.
if (!this.keyboardEnabled()) return;
const key = $event.key;
// Space (' ' on browsers; KeyboardEvent.key === ' ') OR Enter — lift/drop.
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
$event.preventDefault();
if (this.liftedIndex() === null) {
// LIFT
this.liftedIndex.set(index);
this.ariaLiveText.set('Lifted ' + this.getLabel(index));
return;
}
// DROP
const dropped = this.getLabel(this.liftedIndex());
const at = this.liftedIndex();
this.liftedIndex.set(null);
this.ariaLiveText.set('Dropped ' + dropped + ' at position ' + (at + 1));
return;
}
if (key === 'Escape') {
if (this.liftedIndex() === null) return;
$event.preventDefault();
const cancelled = this.getLabel(this.liftedIndex());
this.liftedIndex.set(null);
this.ariaLiveText.set('Cancelled lift of ' + cancelled);
return;
}
if (key === 'ArrowDown' || key === 'ArrowUp') {
if (this.liftedIndex() === null) return;
$event.preventDefault();
const dir = key === 'ArrowDown' ? 1 : -1;
const from = this.liftedIndex();
const to = from + dir;
if (to < 0 || to >= this.items().length) return;
const next = [...this.items()];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
this.items.set(next), this.__rozieCvaOnChange(next);
this.liftedIndex.set(to);
this.ariaLiveText.set('Moved ' + this.getLabel(to) + ' to position ' + (to + 1));
// After the keyed reorder write, restore focus to the moved row. No-op
// on React/Vue/Angular (DOM identity preserved); queueMicrotask +
// querySelectorAll + .focus() on Svelte/Solid/Lit (DOM re-created).
void 0;
this.change.emit({
oldIndex: from,
newIndex: to,
item: moved
});
}
};
getInstance = () => {
return this.instance;
};
toArray = () => {
return this.instance ? this.instance.toArray() : [];
};
sort = (order: any, useAnimation: any = true) => {
this.instance?.sort(order, useAnimation);
};
option = (name: any, value: any) => {
if (!this.instance) return undefined;
if (value === undefined) return this.instance.option(name);
this.instance.option(name, value);
return value;
};
private __rozieCvaOnChange: (v: any[]) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: any[] | null): void {
this.items.set(v ?? (() => [])());
}
registerOnChange(fn: (v: any[]) => 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: SortableList,
_ctx: unknown,
): _ctx is HeaderCtx | DefaultCtx | FooterCtx {
return true;
}
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 SortableList;tsx
import type { JSX } from 'solid-js';
import { For, children, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, parseInlineStyle, rozieAttr, rozieClass } from '@rozie/runtime-solid';
import { useSortableJS } from './internal/useSortableJS';
__rozieInjectStyle('SortableList-0af24eae', `.rozie-sortable-wrap[data-rozie-s-0af24eae] { display: block; }
.rozie-sortable-list[data-rozie-s-0af24eae] { display: block; }
.rozie-sortable-item[data-rozie-s-0af24eae] { display: block; outline: none; }
.rozie-sortable-item[data-rozie-s-0af24eae]:focus { outline: 2px solid rgba(0, 102, 204, 0.6); outline-offset: -2px; }
.rozie-sortable-item-lifted[data-rozie-s-0af24eae] {
background: rgba(0, 102, 204, 0.08);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.4) inset;
}
.rozie-sortable-aria-live[data-rozie-s-0af24eae] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}`);
interface SortableListProps {
/**
* The bound items array. The sole `model: true` prop — two-way bind it (`r-model:items` / `v-model:items` / `bind:items` / `[(items)]`) and SortableList writes the re-ordered array back whenever a drag, cross-list move, or keyboard reorder commits, with no manual `onChange → setState` wiring.
* @example
* <SortableList r-model:items="$data.todos" itemKey="id" />
*/
items?: any[];
defaultItems?: any[];
onItemsChange?: (items: any[]) => void;
/**
* The per-row key the framework reconciler tracks each item by across a reorder — either a property name (e.g. `itemKey="id"` reads `item.id`) or an `(item, index) => key` function. With neither, id-less object items get a stable synthetic key via an internal `WeakMap` (survives reorder by object identity); primitive items fall back to index — pass a function for reorderable duplicate primitives.
*/
itemKey?: (string | ((...args: unknown[]) => unknown)) | null;
/**
* CSS selector identifying the per-row drag handle, so a drag starts only from that element rather than anywhere in the row. Authored class names render literally on every target (React included), so a plain `.grip` works; `$classSelector('grip')` is an optional, typo-checked way to author it.
*/
handle?: (string) | null;
/**
* SortableJS group name enabling cross-list drag — two lists sharing a `group` accept items between each other (the source fires `remove`, the destination fires `add`). Set `cloneable: true` to flip a string group into clone-mode.
*/
group?: (string) | null;
/**
* Reorder animation duration in milliseconds. `0` disables the animation. Runtime-updatable.
*/
animation?: number;
/**
* Temporarily disable dragging without unmounting — reapplied live via `instance.option('disabled', v)` (no remount). Also suppresses keyboard reordering: a disabled list is not sortable by any input, so rows lose their `tabindex` and the keydown handler no-ops.
*/
disabled?: boolean;
/**
* Opt out of keyboard reordering (Space lift / Arrow move / Esc cancel / Enter drop) while leaving pointer drag enabled. Rows drop out of the tab order (no `tabindex`) and the keydown handler no-ops. Keyboard access is gated on `!disabled && !disableKeyboard`.
*/
disableKeyboard?: boolean;
/**
* Verbatim SortableJS options pass-through for anything not covered by the named props. The named props win on key conflict but `options` lands AFTER them in the merge so consumers can override defaults; handler keys (`onStart`, `onEnd`, `onUpdate`, `onAdd`, `onRemove`, `onClone`) are stripped — the helper owns those paths.
*/
options?: Record<string, any>;
/**
* Optional `(item, idx) => string` returning the screen-reader label for the aria-live announcer during keyboard drag. Defaults to `item.label` (or `String(item)` when no `label` field exists).
*/
labelFor?: ((...args: unknown[]) => unknown) | null;
/**
* Class name applied to the drop-placeholder (ghost) element while dragging. Forwarded live via `instance.option`, so toggling it at runtime takes effect without a remount.
*/
ghostClass?: (string) | null;
/**
* Class name applied to the currently-chosen item while dragging. Forwarded live via `instance.option` (no remount needed to change it).
*/
chosenClass?: (string) | null;
/**
* Class name applied to the dragging element. Only takes effect in fallback mode (`forceFallback: true`). Forwarded live via `instance.option`.
*/
dragClass?: (string) | null;
/**
* CSS selector that prevents drag initiation on matching rows (locked items). SortableJS checks it at `mousedown`/`touchstart` and aborts the drag if it matches. A `data-*` attribute selector (e.g. `[data-locked]`) is the most robust choice across all targets.
*/
filter?: (string) | null;
/**
* CSS easing function for the reorder animation (e.g. `'ease-in'`, `'cubic-bezier(0.4, 0, 0.2, 1)'`). Runtime-updatable.
*/
easing?: (string) | null;
/**
* Force SortableJS's mouse-event drag path over HTML5 DnD — useful for touch devices, consistent cross-browser behavior, and synthetic test drivers (and `dragClass` only applies in this mode). **Construction-time only**: SortableJS reads it once at construction, so re-key the `<SortableList>` to toggle it at runtime.
*/
forceFallback?: boolean;
/**
* SortableJS swap threshold (0..1) — a lower value makes rows swap earlier as the dragged item overlaps a neighbor. **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
swapThreshold?: number;
/**
* High-level prop that REPLACES a string `group` with SortableJS's `{ name, pull: 'clone', put: true }` clone-mode object form — the source deposits a COPY onto the destination and keeps its own array unchanged (the palette → canvas pattern). With `group: null` it is a no-op (a clone-mode list with no group name has no peer to clone into). **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
cloneable?: boolean;
/**
* Extra class(es) merged onto the list container (the SortableJS root) alongside the base `rozie-sortable-list` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding), normalized identically across all six targets — the hook for bridging a CSS framework (`.list-group`) or a flex/grid parent onto the component.
*/
listClass?: string | any[] | Record<string, any>;
/**
* Extra class(es) merged onto every item row alongside the base `rozie-sortable-item` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding) applied uniformly, OR an `(item, index) => class` function for per-row classes evaluated at render time. Normalized identically across all six targets.
*/
itemClass?: string | any[] | Record<string, any> | ((...args: unknown[]) => unknown);
/**
* Per-row inline style applied to the `.rozie-sortable-item` wrapper. Accepts a CSS `String`, a flat style object (`Record<string, string | number>`), or an `(item, index) => string | object` function for per-row styling. Because it lands on the wrapper — the direct child of the list container — it can drive CSS-grid placement (`grid-column` / `grid-row` / `align-self`) when `listClass` sets `display: grid`. Normalized per target; `null` / empty drops the attribute.
*/
itemStyle?: (string | Record<string, any> | ((...args: unknown[]) => unknown)) | null;
onChange?: (...args: unknown[]) => void;
onAdd?: (...args: unknown[]) => void;
onRemove?: (...args: unknown[]) => void;
onStart?: (...args: unknown[]) => void;
onEnd?: (...args: unknown[]) => void;
headerSlot?: JSX.Element;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
footerSlot?: JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: SortableListHandle) => void;
}
export interface SortableListHandle {
getInstance: (...args: any[]) => any;
toArray: (...args: any[]) => any;
sort: (...args: any[]) => any;
option: (...args: any[]) => any;
}
export default function SortableList(_props: SortableListProps): JSX.Element {
const _merged = mergeProps({ itemKey: null, handle: null, group: null, animation: 150, disabled: false, disableKeyboard: false, options: (() => ({}))(), labelFor: null, ghostClass: null, chosenClass: null, dragClass: null, filter: null, easing: null, forceFallback: false, swapThreshold: 1, cloneable: false, listClass: '', itemClass: '', itemStyle: null }, _props);
const [local, attrs] = splitProps(_merged, ['items', 'itemKey', 'handle', 'group', 'animation', 'disabled', 'disableKeyboard', 'options', 'labelFor', 'ghostClass', 'chosenClass', 'dragClass', 'filter', 'easing', 'forceFallback', 'swapThreshold', 'cloneable', 'listClass', 'itemClass', 'itemStyle', 'children', 'ref']);
const resolved = children(() => local.children);
onMount(() => { local.ref?.({ getInstance, toArray, sort, option }); });
const [items, setItems] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'items', (() => [])());
const [liftedIndex, setLiftedIndex] = createSignal<any>(null);
const [ariaLiveText, setAriaLiveText] = createSignal('');
onMount(() => {
const _cleanup = (() => {
// Named `sortable` (not `handle`) to avoid shadowing `$props.handle`
// when the options object below references it.
const sortable = useSortableJS(listElRef, {
items: () => items(),
onCommit: (next: any) => {
setItems(next);
},
options: {
animation: local.animation,
disabled: local.disabled,
// `cloneable` is a high-level Rozie prop that REPLACES a string
// `group` with SortableJS's `{ name, pull: 'clone', put: true }`
// object form. When `cloneable:false`, pass `$props.group` through
// verbatim. When `cloneable:true` AND `$props.group` is null,
// leave it null — a clone-mode list without a group name is not
// meaningful (no peer list can join the cross-list flow).
group: local.cloneable && typeof local.group === 'string' ? {
name: local.group,
pull: 'clone',
put: true
} : local.group,
handle: local.handle,
ghostClass: local.ghostClass,
chosenClass: local.chosenClass,
dragClass: local.dragClass,
filter: local.filter,
forceFallback: local.forceFallback,
swapThreshold: local.swapThreshold,
easing: local.easing,
...local.options
},
// Lit lit-html `repeat` directive caches its part array by sentinel-
// comment node identity; SortableJS's physical DOM mutation desyncs
// that cache. The sigil lowers to `__rozieReconcileAfterDomMutation(this)`
// on Lit (real call) and `void 0` on the other 5 targets (no-op).
afterCommit: () => void 0,
onChange: ({
kind,
oldIndex,
newIndex,
item
}: any) => {
if (kind === 'reorder') _props.onChange?.({
oldIndex,
newIndex,
item
});else if (kind === 'add') _props.onAdd?.({
newIndex,
item
});else if (kind === 'remove') _props.onRemove?.({
oldIndex,
item
});
},
onStart: (e: any) => _props.onStart?.(e),
onEnd: (e: any) => _props.onEnd?.(e)
});
instance = sortable.instance;
// $onMount's cleanup-return: closing over a setup-local (`sortable`) does
// not survive the Solid emitter's setup/cleanup split — it scopes cleanup
// outside the setup IIFE. Closing over `instance` (a module-scope `let`)
// works on every target.
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => instance?.destroy());
});
createEffect(on(() => (() => local.disabled)(), (v) => untrack(() => ((v: any) => instance?.option('disabled', v))(v)), { defer: true }));
createEffect(on(() => (() => local.group)(), (v) => untrack(() => ((v: any) => instance?.option('group', v))(v)), { defer: true }));
createEffect(on(() => (() => local.handle)(), (v) => untrack(() => ((v: any) => instance?.option('handle', v))(v)), { defer: true }));
createEffect(on(() => (() => local.ghostClass)(), (v) => untrack(() => ((v: any) => instance?.option('ghostClass', v))(v)), { defer: true }));
createEffect(on(() => (() => local.chosenClass)(), (v) => untrack(() => ((v: any) => instance?.option('chosenClass', v))(v)), { defer: true }));
createEffect(on(() => (() => local.dragClass)(), (v) => untrack(() => ((v: any) => instance?.option('dragClass', v))(v)), { defer: true }));
createEffect(on(() => (() => local.filter)(), (v) => untrack(() => ((v: any) => instance?.option('filter', v))(v)), { defer: true }));
createEffect(on(() => (() => local.easing)(), (v) => untrack(() => ((v: any) => instance?.option('easing', v))(v)), { defer: true }));
let listElRef: HTMLElement | null = null;
let __rozieRootRef: HTMLElement | null = null;
let instance: any = null;
// Instance-scoped synthetic-id store for id-less object items. Keyed by object
// IDENTITY, so the same object keeps its synthetic id across a reorder (the
// framework reconciler then rebinds the row component instance to its ORIGINAL
// item, not its slot position — the data-corruption fix). BOTH the WeakMap and
// the monotonic counter live in ONE member-mutated fresh-instance object so the
// React emitter hoists the whole thing to a single useMemo(() => …, []) (the
// setup-once-persistence guarantee). Folding the counter in is deliberate: a
// bare `let __rowKeySeq = 0` mutated only inside the non-hook keyFor helper is
// NOT caught by React's hoistModuleLet (it resets every render → an item added
// in a later render collides on an already-issued synthetic id → corruption).
// new WeakMap()/seq inside one object dodges that emitter gap. Verified in codegen.
const __rowKey = {
map: new WeakMap(),
seq: 0
};
// 4-tier per-row key precedence. Its return feeds BOTH :key and :data-id.
function keyFor(item: any, index: any) {
// (a) function itemKey: consumer-supplied (item, index) => key.
if (typeof local.itemKey === 'function') {
return local.itemKey(item, index);
}
// (b) string itemKey: a property name on a non-null object item.
if (typeof local.itemKey === 'string' && item !== null && typeof item === 'object' && item[local.itemKey] != null) {
return item[local.itemKey];
}
// (c) id-less object (or function) item: assign-on-first-sight WeakMap
// synthetic id. Survives reorder because it is keyed by object identity.
if (item !== null && typeof item === 'object' || typeof item === 'function') {
if (!__rowKey.map.has(item)) {
__rowKey.map.set(item, '__rk' + __rowKey.seq++);
}
return __rowKey.map.get(item);
}
// (d) primitive item: fall back to index. NOTE: duplicate primitives are
// unsafe to reorder this way — pass a function itemKey for those.
return index;
}
// Resolve itemClass for a row: a static value (string | array | object) OR a
// per-row (item, index) => class function. The result is fed into the :class
// array and normalized by each target's class path (rozieClass / clsx / native).
function itemClassFor(item: any, index: any) {
const v = local.itemClass;
return typeof v === 'function' ? v(item, index) : v;
}
// Resolve itemStyle for a row: a static value (string | object) OR a per-row
// (item, index) => style function. Returns string | object | null; the dynamic
// :style binding normalizes it per target. null / empty → attribute dropped.
function itemStyleFor(item: any, index: any) {
const s = typeof local.itemStyle === 'function' ? local.itemStyle(item, index) : local.itemStyle;
return s == null || s === '' ? null : s;
}
// Read the display label for an item — used by the aria-live announcer.
// Phase 16 R7 / D-08: $props.labelFor reads as `null` on all 6 targets when
// the consumer omits it (Plan 16-01 prop-default coercion fix); the check is
// a plain null compare — NO runtime callable-type coercion.
function getLabel(idx: any) {
const item = items()[idx];
if (local.labelFor !== null) return local.labelFor(item, idx);
if (item !== null && typeof item === 'object' && 'label' in item) return item.label;
return String(item);
}
// Keyboard handler (Phase 16 R7): Space lifts/drops, ArrowDown/ArrowUp move
// the lifted row, Escape cancels, Enter is an alternate drop trigger. After
// any array-reorder write, $restoreFocus('[role="listitem"]', newIdx) keeps
// focus on the moved row across the React/Vue/Angular vs Svelte/Solid/Lit
// keyed-reconciler divide (Plan 16-03 sigil — no-op on the first three;
// queueMicrotask + querySelectorAll + .focus() on the latter three).
//
// Note: `index` is passed directly as a number. Plan 16-02 (Solid call-arg
// accessor unwrap) ensures Solid's <For> alias unwraps to `index()` at the
// call site — no runtime callable-type coercion needed in user source.
// Keyboard reordering is available only when the list is not disabled AND the
// `disableKeyboard` opt-out is off. Drives BOTH the row tabindex (rows are
// focusable only when reorderable) and the onRowKeyDown guard below. Reads
// straight off $props so the tabindex binding re-evaluates reactively when
// `disabled`/`disableKeyboard` toggle at runtime.
function keyboardEnabled() {
return !local.disabled && !local.disableKeyboard;
}
function onRowKeyDown($event: any, index: any) {
// Defense-in-depth: when keyboard reordering is off the rows carry no
// tabindex and can't receive focus, but a consumer-focused row (or a
// programmatic .focus()) must still no-op here rather than reorder.
if (!keyboardEnabled()) return;
const key = $event.key;
// Space (' ' on browsers; KeyboardEvent.key === ' ') OR Enter — lift/drop.
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
$event.preventDefault();
if (liftedIndex() === null) {
// LIFT
setLiftedIndex(index);
setAriaLiveText('Lifted ' + getLabel(index));
return;
}
// DROP
const dropped = getLabel(liftedIndex());
const at = liftedIndex();
setLiftedIndex(null);
setAriaLiveText('Dropped ' + dropped + ' at position ' + (at + 1));
return;
}
if (key === 'Escape') {
if (liftedIndex() === null) return;
$event.preventDefault();
const cancelled = getLabel(liftedIndex());
setLiftedIndex(null);
setAriaLiveText('Cancelled lift of ' + cancelled);
return;
}
if (key === 'ArrowDown' || key === 'ArrowUp') {
if (liftedIndex() === null) return;
$event.preventDefault();
const dir = key === 'ArrowDown' ? 1 : -1;
const from = liftedIndex();
const to = from + dir;
if (to < 0 || to >= items().length) return;
const next = [...items()];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
setItems(next);
setLiftedIndex(to);
setAriaLiveText('Moved ' + getLabel(to) + ' to position ' + (to + 1));
// After the keyed reorder write, restore focus to the moved row. No-op
// on React/Vue/Angular (DOM identity preserved); queueMicrotask +
// querySelectorAll + .focus() on Svelte/Solid/Lit (DOM re-created).
queueMicrotask(() => (__rozieRootRef!.querySelectorAll('[role="listitem"]')?.[to] as HTMLElement | undefined)?.focus?.());
_props.onChange?.({
oldIndex: from,
newIndex: to,
item: moved
});
}
}
// SortableJS wiring lives in `useSortableJS()` (./internal/useSortableJS).
// The helper owns the SortableJS-vs-reconciler dance — DOM-restore hardening
// against fragile-event paths, identity-based item lookup over fragile
// `e.oldIndex`, and the single-onEnd disambiguation that collapses
// onUpdate / onAdd / onRemove into one handler.
//
// What stays here is purely declarative: which array to read, what to write
// back, what to emit, and how to bridge `afterCommit` to the Lit-only
// `$reconcileAfterDomMutation()` sigil.
// Imperative handle (Phase 21 $expose). The SortableJS imperative surface a
// consumer can't drive through props alone — exposed uniformly to all 6 targets.
// Each guards the pre-mount/destroyed `instance = null`. Collision-clear: none of
// the 4 verb names collide with the 16 props or the 5 events — `option` is a
// distinct identifier from the `options` prop, so ROZ121 is clear.
function getInstance() {
return instance;
}
// toArray()/sort() operate on SortableJS's data-id ordering — every row carries
// :data-id="keyFor(item, index)", so toArray() returns the current key order and
// sort(order) reorders by those keys (set itemKey for stable object-list keys).
function toArray() {
return instance ? instance.toArray() : [];
}
function sort(order: any, useAnimation = true) {
instance?.sort(order, useAnimation);
}
// option(name) reads a live SortableJS option; option(name, value) sets one — the
// runtime escape hatch for any SortableJS option beyond the curated props.
function option(name: any, value: any) {
if (!instance) return undefined;
if (value === undefined) return instance.option(name);
instance.option(name, value);
return value;
}
return (
<>
<div ref={(el) => { __rozieRootRef = el as HTMLElement; }} {...attrs} class={"rozie-sortable-wrap" + (((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-0af24eae="">
<div class={rozieClass(['rozie-sortable-list', local.listClass])} ref={(el) => { listElRef = el as HTMLElement; }} part="list" data-rozie-s-0af24eae="">
{(_props.headerSlot ?? _props.slots?.['header']?.({}))}
<For each={items()}>{(item, index) => <div data-id={rozieAttr(keyFor(item, index()))} role="listitem" class={rozieClass(['rozie-sortable-item', itemClassFor(item, index()), { 'rozie-sortable-item-lifted': liftedIndex() === index() }])} style={parseInlineStyle(itemStyleFor(item, index()))} tabIndex={rozieAttr(keyboardEnabled() ? 0 : null)} onKeyDown={($event) => { onRowKeyDown($event, index()); }} data-rozie-s-0af24eae="">
{typeof local.children === 'function' ? (local.children as (s: any) => any)({ item, index: index() }) : resolved()}
</div>}</For>
{(_props.footerSlot ?? _props.slots?.['footer']?.({}))}
</div>
<div class={"rozie-sortable-aria-live"} data-rozie-sortable-aria-live="" aria-live="polite" aria-atomic="true" data-rozie-s-0af24eae="">{ariaLiveText()}</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 { __rozieReconcileAfterDomMutation, createLitControllableProperty, rozieAttr, rozieClass, rozieListeners, rozieSpread, rozieStyle } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
import { keyed } from 'lit/directives/keyed.js';
import { useSortableJS } from './internal/useSortableJS';
interface RozieDefaultSlotCtx {
item: unknown;
index: unknown;
}
@customElement('rozie-sortable-list')
export default class SortableList extends SignalWatcher(LitElement) {
static styles = css`
.rozie-sortable-wrap[data-rozie-s-0af24eae] { display: block; }
.rozie-sortable-list[data-rozie-s-0af24eae] { display: block; }
.rozie-sortable-item[data-rozie-s-0af24eae] { display: block; outline: none; }
.rozie-sortable-item[data-rozie-s-0af24eae]:focus { outline: 2px solid rgba(0, 102, 204, 0.6); outline-offset: -2px; }
.rozie-sortable-item-lifted[data-rozie-s-0af24eae] {
background: rgba(0, 102, 204, 0.08);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.4) inset;
}
.rozie-sortable-aria-live[data-rozie-s-0af24eae] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`;
/**
* The bound items array. The sole `model: true` prop — two-way bind it (`r-model:items` / `v-model:items` / `bind:items` / `[(items)]`) and SortableList writes the re-ordered array back whenever a drag, cross-list move, or keyboard reorder commits, with no manual `onChange → setState` wiring.
* @example
* <SortableList r-model:items="$data.todos" itemKey="id" />
*/
@property({ type: Array, attribute: 'items' }) _items_attr: any[] = [];
private _itemsControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'items-change', defaultValue: [], initialControlledValue: undefined });
/**
* The per-row key the framework reconciler tracks each item by across a reorder — either a property name (e.g. `itemKey="id"` reads `item.id`) or an `(item, index) => key` function. With neither, id-less object items get a stable synthetic key via an internal `WeakMap` (survives reorder by object identity); primitive items fall back to index — pass a function for reorderable duplicate primitives.
*/
@property({ type: String }) itemKey: string | (((...args: unknown[]) => unknown) | null) = null;
/**
* CSS selector identifying the per-row drag handle, so a drag starts only from that element rather than anywhere in the row. Authored class names render literally on every target (React included), so a plain `.grip` works; `$classSelector('grip')` is an optional, typo-checked way to author it.
*/
@property({ type: String, reflect: true }) handle: string | null = null;
/**
* SortableJS group name enabling cross-list drag — two lists sharing a `group` accept items between each other (the source fires `remove`, the destination fires `add`). Set `cloneable: true` to flip a string group into clone-mode.
*/
@property({ type: String, reflect: true }) group: string | null = null;
/**
* Reorder animation duration in milliseconds. `0` disables the animation. Runtime-updatable.
*/
@property({ type: Number, reflect: true }) animation: number = 150;
/**
* Temporarily disable dragging without unmounting — reapplied live via `instance.option('disabled', v)` (no remount). Also suppresses keyboard reordering: a disabled list is not sortable by any input, so rows lose their `tabindex` and the keydown handler no-ops.
*/
@property({ type: Boolean, reflect: true }) disabled: boolean = false;
/**
* Opt out of keyboard reordering (Space lift / Arrow move / Esc cancel / Enter drop) while leaving pointer drag enabled. Rows drop out of the tab order (no `tabindex`) and the keydown handler no-ops. Keyboard access is gated on `!disabled && !disableKeyboard`.
*/
@property({ type: Boolean, reflect: true }) disableKeyboard: boolean = false;
/**
* Verbatim SortableJS options pass-through for anything not covered by the named props. The named props win on key conflict but `options` lands AFTER them in the merge so consumers can override defaults; handler keys (`onStart`, `onEnd`, `onUpdate`, `onAdd`, `onRemove`, `onClone`) are stripped — the helper owns those paths.
*/
@property({ type: Object }) options: any = {};
/**
* Optional `(item, idx) => string` returning the screen-reader label for the aria-live announcer during keyboard drag. Defaults to `item.label` (or `String(item)` when no `label` field exists).
*/
@property({ type: Function }) labelFor: ((...args: unknown[]) => unknown) | null = null;
/**
* Class name applied to the drop-placeholder (ghost) element while dragging. Forwarded live via `instance.option`, so toggling it at runtime takes effect without a remount.
*/
@property({ type: String, reflect: true }) ghostClass: string | null = null;
/**
* Class name applied to the currently-chosen item while dragging. Forwarded live via `instance.option` (no remount needed to change it).
*/
@property({ type: String, reflect: true }) chosenClass: string | null = null;
/**
* Class name applied to the dragging element. Only takes effect in fallback mode (`forceFallback: true`). Forwarded live via `instance.option`.
*/
@property({ type: String, reflect: true }) dragClass: string | null = null;
/**
* CSS selector that prevents drag initiation on matching rows (locked items). SortableJS checks it at `mousedown`/`touchstart` and aborts the drag if it matches. A `data-*` attribute selector (e.g. `[data-locked]`) is the most robust choice across all targets.
*/
@property({ type: String, reflect: true }) filter: string | null = null;
/**
* CSS easing function for the reorder animation (e.g. `'ease-in'`, `'cubic-bezier(0.4, 0, 0.2, 1)'`). Runtime-updatable.
*/
@property({ type: String, reflect: true }) easing: string | null = null;
/**
* Force SortableJS's mouse-event drag path over HTML5 DnD — useful for touch devices, consistent cross-browser behavior, and synthetic test drivers (and `dragClass` only applies in this mode). **Construction-time only**: SortableJS reads it once at construction, so re-key the `<SortableList>` to toggle it at runtime.
*/
@property({ type: Boolean, reflect: true }) forceFallback: boolean = false;
/**
* SortableJS swap threshold (0..1) — a lower value makes rows swap earlier as the dragged item overlaps a neighbor. **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
@property({ type: Number, reflect: true }) swapThreshold: number = 1;
/**
* High-level prop that REPLACES a string `group` with SortableJS's `{ name, pull: 'clone', put: true }` clone-mode object form — the source deposits a COPY onto the destination and keeps its own array unchanged (the palette → canvas pattern). With `group: null` it is a no-op (a clone-mode list with no group name has no peer to clone into). **Construction-time only**: re-key the `<SortableList>` to change it at runtime.
*/
@property({ type: Boolean, reflect: true }) cloneable: boolean = false;
/**
* Extra class(es) merged onto the list container (the SortableJS root) alongside the base `rozie-sortable-list` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding), normalized identically across all six targets — the hook for bridging a CSS framework (`.list-group`) or a flex/grid parent onto the component.
*/
@property({ type: String }) listClass: string | any[] | any = '';
/**
* Extra class(es) merged onto every item row alongside the base `rozie-sortable-item` class. Accepts a `String`, `Array`, or `Object` (Vue-style class binding) applied uniformly, OR an `(item, index) => class` function for per-row classes evaluated at render time. Normalized identically across all six targets.
*/
@property({ type: String }) itemClass: string | any[] | any | (((...args: unknown[]) => unknown) | null) = '';
/**
* Per-row inline style applied to the `.rozie-sortable-item` wrapper. Accepts a CSS `String`, a flat style object (`Record<string, string | number>`), or an `(item, index) => string | object` function for per-row styling. Because it lands on the wrapper — the direct child of the list container — it can drive CSS-grid placement (`grid-column` / `grid-row` / `align-self`) when `listClass` sets `display: grid`. Normalized per target; `null` / empty drops the attribute.
*/
@property({ type: String }) itemStyle: string | any | (((...args: unknown[]) => unknown) | null) = null;
private _liftedIndex = signal<any>(null);
private _ariaLiveText = signal('');
@query('[data-rozie-ref="listEl"]') private _refListEl!: HTMLElement;
@query('[data-rozie-ref="__rozieRoot"]') private _ref__rozieRoot!: HTMLElement;
private __rozieFirstUpdateDone = false;
@state() private _hasSlotHeader = false;
@queryAssignedElements({ slot: 'header', flatten: true }) private _slotHeaderElements!: Element[];
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
@property({ attribute: false }) __rozieDefaultSlot__?: (scope: { item: unknown; index: unknown }) => unknown;
@state() private _hasSlotFooter = false;
@queryAssignedElements({ slot: 'footer', flatten: true }) private _slotFooterElements!: 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;
_rozieReconcileSeq = 0;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot[name="header"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotHeader = this._slotHeaderElements.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:not([name])');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDefault = this._slotDefaultElements.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="footer"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotFooter = this._slotFooterElements.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._hasSlotHeader = Array.from(this.children).some((el) => el.getAttribute('slot') === 'header');
this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
this._hasSlotFooter = Array.from(this.children).some((el) => el.getAttribute('slot') === 'footer');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
this._disconnectCleanups.push((() => this.instance?.destroy()));
// Named `sortable` (not `handle`) to avoid shadowing `$props.handle`
// when the options object below references it.
const sortable = useSortableJS(this._refListEl, {
items: () => this.items,
onCommit: (next: any) => {
this._itemsControllable.write(next);
},
options: {
animation: this.animation,
disabled: this.disabled,
// `cloneable` is a high-level Rozie prop that REPLACES a string
// `group` with SortableJS's `{ name, pull: 'clone', put: true }`
// object form. When `cloneable:false`, pass `$props.group` through
// verbatim. When `cloneable:true` AND `$props.group` is null,
// leave it null — a clone-mode list without a group name is not
// meaningful (no peer list can join the cross-list flow).
group: this.cloneable && typeof this.group === 'string' ? {
name: this.group,
pull: 'clone',
put: true
} : this.group,
handle: this.handle,
ghostClass: this.ghostClass,
chosenClass: this.chosenClass,
dragClass: this.dragClass,
filter: this.filter,
forceFallback: this.forceFallback,
swapThreshold: this.swapThreshold,
easing: this.easing,
...this.options
},
// Lit lit-html `repeat` directive caches its part array by sentinel-
// comment node identity; SortableJS's physical DOM mutation desyncs
// that cache. The sigil lowers to `__rozieReconcileAfterDomMutation(this)`
// on Lit (real call) and `void 0` on the other 5 targets (no-op).
afterCommit: () => __rozieReconcileAfterDomMutation(this),
onChange: ({
kind,
oldIndex,
newIndex,
item
}: any) => {
if (kind === 'reorder') this.dispatchEvent(new CustomEvent("change", {
detail: {
oldIndex,
newIndex,
item
},
bubbles: true,
composed: true
}));else if (kind === 'add') this.dispatchEvent(new CustomEvent("add", {
detail: {
newIndex,
item
},
bubbles: true,
composed: true
}));else if (kind === 'remove') this.dispatchEvent(new CustomEvent("remove", {
detail: {
oldIndex,
item
},
bubbles: true,
composed: true
}));
},
onStart: (e: any) => this.dispatchEvent(new CustomEvent("start", {
detail: e,
bubbles: true,
composed: true
})),
onEnd: (e: any) => this.dispatchEvent(new CustomEvent("end", {
detail: e,
bubbles: true,
composed: true
}))
});
this.instance = sortable.instance;
// $onMount's cleanup-return: closing over a setup-local (`sortable`) does
// not survive the Solid emitter's setup/cleanup split — it scopes cleanup
// outside the setup IIFE. Closing over `instance` (a module-scope `let`)
// works on every target.
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('disabled'))) { const __watchVal = (() => this.disabled)(); ((v: any) => this.instance?.option('disabled', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('group'))) { const __watchVal = (() => this.group)(); ((v: any) => this.instance?.option('group', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('handle'))) { const __watchVal = (() => this.handle)(); ((v: any) => this.instance?.option('handle', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('ghostClass'))) { const __watchVal = (() => this.ghostClass)(); ((v: any) => this.instance?.option('ghostClass', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('chosenClass'))) { const __watchVal = (() => this.chosenClass)(); ((v: any) => this.instance?.option('chosenClass', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('dragClass'))) { const __watchVal = (() => this.dragClass)(); ((v: any) => this.instance?.option('dragClass', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('filter'))) { const __watchVal = (() => this.filter)(); ((v: any) => this.instance?.option('filter', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('easing'))) { const __watchVal = (() => this.easing)(); ((v: any) => this.instance?.option('easing', v))(__watchVal); }
this.__rozieFirstUpdateDone = true;
}
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 === 'items') this._itemsControllable.notifyAttributeChange(value as unknown as any[]);
}
render() {
return html`
<div class="rozie-sortable-wrap" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="__rozieRoot" data-rozie-s-0af24eae>
<div class="${(rozieClass(['rozie-sortable-list', this.listClass]))}" part="list" data-rozie-ref="listEl" data-rozie-s-0af24eae>${keyed(this._rozieReconcileSeq ?? 0, html`
<slot name="header"></slot>
${repeat<any>(this.items, (item, index) => this.keyFor(item, index), (item, index) => html`<div class="${(rozieClass(['rozie-sortable-item', this.itemClassFor(item, index), { 'rozie-sortable-item-lifted': this._liftedIndex.value === index }]))}" key=${rozieAttr(this.keyFor(item, index))} style=${rozieStyle(this.itemStyleFor(item, index))} data-id=${rozieAttr(this.keyFor(item, index))} role="listitem" tabindex=${rozieAttr(this.keyboardEnabled() ? 0 : null)} @keydown=${($event: Event) => { this.onRowKeyDown($event, index); }} data-rozie-s-0af24eae>
${this.__rozieDefaultSlot__ !== undefined ? this.__rozieDefaultSlot__({item: item, index: index}) : html`<slot data-rozie-params=${(() => { try { return JSON.stringify({item: item, index: index}); } catch { return '{}'; } })()}></slot>`}
</div>`)}
<slot name="footer"></slot>
`)}</div>
<div class="rozie-sortable-aria-live" data-rozie-sortable-aria-live="" aria-live="polite" aria-atomic="true" data-rozie-s-0af24eae>${this._ariaLiveText.value}</div>
</div>
`;
}
instance: any = null;
__rowKey = {
map: new WeakMap(),
seq: 0
};
keyFor = (item: any, index: any) => {
// (a) function itemKey: consumer-supplied (item, index) => key.
if (typeof this.itemKey === 'function') {
return this.itemKey(item, index);
}
// (b) string itemKey: a property name on a non-null object item.
if (typeof this.itemKey === 'string' && item !== null && typeof item === 'object' && item[this.itemKey] != null) {
return item[this.itemKey];
}
// (c) id-less object (or function) item: assign-on-first-sight WeakMap
// synthetic id. Survives reorder because it is keyed by object identity.
if (item !== null && typeof item === 'object' || typeof item === 'function') {
if (!this.__rowKey.map.has(item)) {
this.__rowKey.map.set(item, '__rk' + this.__rowKey.seq++);
}
return this.__rowKey.map.get(item);
}
// (d) primitive item: fall back to index. NOTE: duplicate primitives are
// unsafe to reorder this way — pass a function itemKey for those.
return index;
};
itemClassFor = (item: any, index: any) => {
const v = this.itemClass;
return typeof v === 'function' ? v(item, index) : v;
};
itemStyleFor = (item: any, index: any) => {
const s = typeof this.itemStyle === 'function' ? this.itemStyle(item, index) : this.itemStyle;
return s == null || s === '' ? null : s;
};
getLabel = (idx: any) => {
const item = this.items[idx];
if (this.labelFor !== null) return this.labelFor(item, idx);
if (item !== null && typeof item === 'object' && 'label' in item) return item.label;
return String(item);
};
keyboardEnabled = () => !this.disabled && !this.disableKeyboard;
onRowKeyDown = ($event: any, index: any) => {
// Defense-in-depth: when keyboard reordering is off the rows carry no
// tabindex and can't receive focus, but a consumer-focused row (or a
// programmatic .focus()) must still no-op here rather than reorder.
if (!this.keyboardEnabled()) return;
const key = $event.key;
// Space (' ' on browsers; KeyboardEvent.key === ' ') OR Enter — lift/drop.
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
$event.preventDefault();
if (this._liftedIndex.value === null) {
// LIFT
this._liftedIndex.value = index;
this._ariaLiveText.value = 'Lifted ' + this.getLabel(index);
return;
}
// DROP
const dropped = this.getLabel(this._liftedIndex.value);
const at = this._liftedIndex.value;
this._liftedIndex.value = null;
this._ariaLiveText.value = 'Dropped ' + dropped + ' at position ' + (at + 1);
return;
}
if (key === 'Escape') {
if (this._liftedIndex.value === null) return;
$event.preventDefault();
const cancelled = this.getLabel(this._liftedIndex.value);
this._liftedIndex.value = null;
this._ariaLiveText.value = 'Cancelled lift of ' + cancelled;
return;
}
if (key === 'ArrowDown' || key === 'ArrowUp') {
if (this._liftedIndex.value === null) return;
$event.preventDefault();
const dir = key === 'ArrowDown' ? 1 : -1;
const from = this._liftedIndex.value;
const to = from + dir;
if (to < 0 || to >= this.items.length) return;
const next = [...this.items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
this._itemsControllable.write(next);
this._liftedIndex.value = to;
this._ariaLiveText.value = 'Moved ' + this.getLabel(to) + ' to position ' + (to + 1);
// After the keyed reorder write, restore focus to the moved row. No-op
// on React/Vue/Angular (DOM identity preserved); queueMicrotask +
// querySelectorAll + .focus() on Svelte/Solid/Lit (DOM re-created).
queueMicrotask(() => (this.renderRoot.querySelectorAll('[role="listitem"]')?.[to] as HTMLElement | undefined)?.focus?.());
this.dispatchEvent(new CustomEvent("change", {
detail: {
oldIndex: from,
newIndex: to,
item: moved
},
bubbles: true,
composed: true
}));
}
};
getInstance() {
return this.instance;
}
toArray() {
return this.instance ? this.instance.toArray() : [];
}
sort(order: any, useAnimation = true) {
this.instance?.sort(order, useAnimation);
}
option(name: any, value: any) {
if (!this.instance) return undefined;
if (value === undefined) return this.instance.option(name);
this.instance.option(name, value);
return value;
}
get items(): any[] { return this._itemsControllable.read(); }
set items(v: any[]) { this._itemsControllable.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>(['items', 'item-key', 'itemkey', 'handle', 'group', 'animation', 'disabled', 'disable-keyboard', 'disablekeyboard', 'options', 'label-for', 'labelfor', 'ghost-class', 'ghostclass', 'chosen-class', 'chosenclass', 'drag-class', 'dragclass', 'filter', 'easing', 'force-fallback', 'forcefallback', 'swap-threshold', 'swapthreshold', 'cloneable', 'list-class', 'listclass', 'item-class', 'itemclass', 'item-style', 'itemstyle']);
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;
}
}Demo source — SortableListDemo.rozie
rozie
<!--
SortableListDemo.rozie — companion consumer for SortableList.
The whole point of writing the demo IN Rozie: a single source compiles
to React + Vue + Svelte + Angular + Solid + Lit consumers automatically,
so the demo is itself a six-way "look ma, no per-framework integration"
proof. There is no /examples/consumers/react-sortable-page.tsx companion
here — there's just THIS file, fanned out by the compiler.
Exercises (in addition to dragging):
- `r-model:items` consumer-side two-way binding (Phase 07.3)
- Component composition via <components> block
- Default slot with scoped params + fallback
- r-for (in the state-display section) — covered by the bare-comma
regression coverage in extractRForAliases
- r-model on text input + checkbox
- @submit.prevent + @click event-modifier passthrough
- r-if / r-else for the empty-state branch
-->
<rozie name="SortableListDemo">
<components>
{
SortableList: '../../packages/ui/sortable-list/src/SortableList.rozie',
}
</components>
<data>
{
items: [],
newLabel: '',
disabled: false,
nextId: 6,
}
</data>
<script>
const INITIAL_DATA = [
{ id: 'a', label: 'Apple' },
{ id: 'b', label: 'Banana' },
{ id: 'c', label: 'Cherry' },
{ id: 'd', label: 'Date' },
{ id: 'e', label: 'Elderberry' },
];
const addItem = () => {
const label = $data.newLabel.trim()
if (!label) return
$data.items = [...$data.items, { id: String($data.nextId), label }]
$data.nextId = $data.nextId + 1
$data.newLabel = ''
}
const removeItem = (id) => {
$data.items = $data.items.filter(i => i.id !== id)
}
const reset = () => {
$data.items = INITIAL_DATA
}
$onMount(() => reset());
</script>
<template>
<div class="sortable-demo">
<header>
<h3>Drag to reorder</h3>
<p class="hint">Grab a row by the handle (⋮⋮) and drop it elsewhere. The bound array on the right updates in real time.</p>
</header>
<div class="grid">
<section class="list-pane">
<SortableList r-model:items="$data.items" itemKey="id" :handle="$classSelector('grip')" :disabled="$data.disabled">
<template #default="{ item }">
<div class="row">
<span class="grip" aria-label="Drag handle">⋮⋮</span>
<span class="label">{{ item.label }}</span>
<button class="remove" @click="removeItem(item.id)" aria-label="Remove">×</button>
</div>
</template>
</SortableList>
<form @submit.prevent="addItem" class="add-form">
<input r-model="$data.newLabel" placeholder="Add an item…" />
<button type="submit" :disabled="!$data.newLabel.trim()">Add</button>
</form>
<div class="controls">
<label><input type="checkbox" r-model="$data.disabled" /> Disabled</label>
<button type="button" @click="reset">Reset</button>
</div>
</section>
<aside class="state-pane">
<h4>Bound state ({{ $data.items.length }})</h4>
<ol r-if="$data.items.length > 0" class="state-list">
<li r-for="item, index in $data.items" :key="item.id">
<span class="state-index">{{ index + 1 }}.</span>
<code>{{ item.id }}</code>
<span>{{ item.label }}</span>
</li>
</ol>
<p r-else class="empty">Empty.</p>
</aside>
</div>
</div>
</template>
<style>
.sortable-demo {
font-family: system-ui, -apple-system, sans-serif;
color: #1a1a1a;
padding: 1rem;
max-width: 720px;
}
header h3 { margin: 0 0 0.25rem; font-size: 1.125rem; }
header .hint { margin: 0 0 1rem; color: rgba(0, 0, 0, 0.55); font-size: 0.875rem; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
align-items: start;
}
.list-pane, .state-pane { min-width: 0; }
.row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f5f5f7;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 6px;
margin: 0.25rem 0;
}
.grip {
color: rgba(0, 0, 0, 0.35);
cursor: grab;
user-select: none;
font-family: monospace;
}
.grip:active { cursor: grabbing; }
.label { flex: 1; }
.remove {
background: transparent;
border: none;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
font-size: 1.125rem;
line-height: 1;
padding: 0 0.25rem;
}
.remove:hover { color: #c00; }
.add-form {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.add-form input {
flex: 1;
padding: 0.375rem 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
font: inherit;
}
.add-form button {
padding: 0.375rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.15);
background: white;
border-radius: 4px;
cursor: pointer;
}
.controls {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.875rem;
}
.controls button {
padding: 0.25rem 0.625rem;
border: 1px solid rgba(0, 0, 0, 0.15);
background: white;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.state-pane h4 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.55);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.state-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
overflow: hidden;
}
.state-list li {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.state-list li:last-child { border-bottom: none; }
.state-index {
color: rgba(0, 0, 0, 0.35);
font-variant-numeric: tabular-nums;
min-width: 1.5rem;
}
.state-list code {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.04);
padding: 0.0625rem 0.25rem;
border-radius: 2px;
}
.empty {
margin: 0;
padding: 0.75rem;
color: rgba(0, 0, 0, 0.4);
font-style: italic;
text-align: center;
border: 1px dashed rgba(0, 0, 0, 0.1);
border-radius: 6px;
}
</style>
</rozie>