Skip to content

Commit a471a2f

Browse files
serpentbladeclaude
andcommitted
feat(sortable): add $expose imperative handle (getInstance/toArray/sort/option)
Closes the G1 gap — SortableList was the only @rozie-ui port without an $expose handle (it predated Phase 21). Adds a uniform 4-verb handle on all six targets: - getInstance() — raw SortableJS instance (escape hatch) - toArray() — current order as data-id key array - sort(order, useAnimation?) — reorder by data-id keys - option(name, value?) — get/set a live SortableJS option Rows now carry :data-id="keyFor(item, index)" so toArray()/sort() operate on the rendered key order (Angular emits the fixed [attr.data-id] path). 'option' is collision-clear vs the 'options' prop (ROZ121 passes). Codegen: ports codemirror's handle machinery — new handle-manifest.mjs (lockstep-asserted against ir.expose), a '## Imperative handle' README section per leaf, and the react/solid barrel now forwards the emitted SortableListHandle type. Regenerated all 6 leaves. Gates: sortable family typecheck + build 19/19 (incl. strict react/solid/lit + vue-tsc/svelte-check). Compiler/emitters untouched (codegen is pure glue over @rozie/core), so dist-parity/target-snapshot gates are unaffected; data-id + handle are pixel-neutral so VR baselines stay valid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8cbd863 commit a471a2f

19 files changed

Lines changed: 520 additions & 23 deletions

File tree

packages/ui/sortable-list/packages/angular/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,26 @@ The accessor contract: only real user interaction dirties the control — progra
102102
| `start` | Fired when dragging starts. |
103103
| `end` | Fired when dragging ends (source side). |
104104

105+
## Imperative handle
106+
107+
Beyond props, the component exposes imperative methods (declared once in the Rozie source via `$expose`). Grab a handle with the native ref mechanism and call them directly:
108+
109+
| Method | Description |
110+
| --- | --- |
111+
| `getInstance` | Return the underlying SortableJS instance for direct API access (the raw-engine escape hatch — `save`, `closest`, etc. are one hop away). `null` before mount and after destroy. |
112+
| `toArray` | Return the current order as an array of `data-id` strings (each row carries `data-id="<key>"`). `[]` before mount. |
113+
| `sort` | Reorder the list by an array of `data-id` strings — `sort(order, useAnimation = true)`. |
114+
| `option` | Read or set a live SortableJS option — `option(name)` gets, `option(name, value)` sets. The runtime escape hatch for options beyond the curated props. |
115+
116+
```ts
117+
@Component({ /* ... */ })
118+
export class DemoComponent {
119+
@ViewChild(SortableList) sl!: SortableList; // or the viewChild() signal
120+
logOrder() { console.log(this.sl.toArray()); }
121+
disable() { this.sl.option('disabled', true); }
122+
}
123+
```
124+
105125
## Slots
106126

107127
| Slot | Params |

packages/ui/sortable-list/packages/angular/src/SortableList.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@ interface DefaultCtx {
1010
index: any;
1111
}
1212

13+
function __rozieDisplay(v: unknown): string {
14+
if (v == null) return '';
15+
if (typeof v === 'string') return v;
16+
if (typeof v === 'object') {
17+
try {
18+
return JSON.stringify(v, null, 2);
19+
} catch {
20+
// Circular structure or a non-serialisable value (BigInt nested in an
21+
// object). Degrade to a non-throwing form so the wrap never crashes the
22+
// render — that is the entire point of "safe" interpolation (SPEC-1).
23+
return String(v);
24+
}
25+
}
26+
return String(v);
27+
}
28+
1329
@Component({
1430
selector: 'rozie-sortable-list',
1531
standalone: true,
@@ -19,7 +35,7 @@ interface DefaultCtx {
1935
<div class="rozie-sortable-wrap" #__rozieRoot #rozieSpread_0 #rozieListenersTarget_1>
2036
<div class="rozie-sortable-list" #listEl part="list">
2137
@for (item of items(); track keyFor(item, index); let index = $index) {
22-
<div class="rozie-sortable-item" [ngClass]="{ 'rozie-sortable-item-lifted': liftedIndex() === index }" role="listitem" tabindex="0" (keydown)="onRowKeyDown($event, index)">
38+
<div class="rozie-sortable-item" [ngClass]="{ 'rozie-sortable-item-lifted': liftedIndex() === index }" [attr.data-id]="rozieDisplay(keyFor(item, index))" role="listitem" tabindex="0" (keydown)="onRowKeyDown($event, index)">
2339
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot']); context: { $implicit: { item: item, index: index }, item: item, index: index }" />
2440
</div>
2541
}
@@ -239,6 +255,21 @@ export class SortableList {
239255
});
240256
}
241257
};
258+
getInstance = () => {
259+
return this.instance;
260+
};
261+
toArray = () => {
262+
return this.instance ? this.instance.toArray() : [];
263+
};
264+
sort = (order: any, useAnimation: any = true) => {
265+
this.instance?.sort(order, useAnimation);
266+
};
267+
option = (name: any, value: any) => {
268+
if (!this.instance) return undefined;
269+
if (value === undefined) return this.instance.option(name);
270+
this.instance.option(name, value);
271+
return value;
272+
};
242273

243274
private __rozieCvaOnChange: (v: any[]) => void = () => {};
244275
private __rozieCvaOnTouchedFn: () => void = () => {};
@@ -388,6 +419,8 @@ export class SortableList {
388419
});
389420
}
390421
});
422+
423+
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
391424
}
392425

393426
export default SortableList;

packages/ui/sortable-list/packages/lit/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,25 @@ el.addEventListener('items-change', (e) => {
5959
| `start` | Fired when dragging starts. |
6060
| `end` | Fired when dragging ends (source side). |
6161

62+
## Imperative handle
63+
64+
Beyond props, the component exposes imperative methods (declared once in the Rozie source via `$expose`). Grab a handle with the native ref mechanism and call them directly:
65+
66+
| Method | Description |
67+
| --- | --- |
68+
| `getInstance` | Return the underlying SortableJS instance for direct API access (the raw-engine escape hatch — `save`, `closest`, etc. are one hop away). `null` before mount and after destroy. |
69+
| `toArray` | Return the current order as an array of `data-id` strings (each row carries `data-id="<key>"`). `[]` before mount. |
70+
| `sort` | Reorder the list by an array of `data-id` strings — `sort(order, useAnimation = true)`. |
71+
| `option` | Read or set a live SortableJS option — `option(name)` gets, `option(name, value)` sets. The runtime escape hatch for options beyond the curated props. |
72+
73+
```ts
74+
// The custom element IS the handle — its exposed methods are public
75+
// element methods.
76+
const el = document.querySelector('rozie-sortable-list');
77+
const order = el.toArray();
78+
el.option('disabled', true);
79+
```
80+
6281
## Slots
6382

6483
| Slot | Params |

packages/ui/sortable-list/packages/lit/src/SortableList.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ private __rozieFirstUpdateDone = false;
209209
return html`
210210
<div class="rozie-sortable-wrap" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="__rozieRoot" data-rozie-s-0af24eae>
211211
<div class="rozie-sortable-list" part="list" data-rozie-ref="listEl" data-rozie-s-0af24eae>${keyed(this._rozieReconcileSeq ?? 0, html`
212-
${repeat<any>(this.items, (item, index) => this.keyFor(item, index), (item, index) => html`<div class="${Object.entries({ "rozie-sortable-item": true, 'rozie-sortable-item-lifted': this._liftedIndex.value === index }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieDisplay(this.keyFor(item, index))} role="listitem" tabindex="0" @keydown=${($event: Event) => { this.onRowKeyDown($event, index); }} data-rozie-s-0af24eae>
212+
${repeat<any>(this.items, (item, index) => this.keyFor(item, index), (item, index) => html`<div class="${Object.entries({ "rozie-sortable-item": true, 'rozie-sortable-item-lifted': this._liftedIndex.value === index }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieDisplay(this.keyFor(item, index))} data-id=${rozieDisplay(this.keyFor(item, index))} role="listitem" tabindex="0" @keydown=${($event: Event) => { this.onRowKeyDown($event, index); }} data-rozie-s-0af24eae>
213213
${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>`}
214214
</div>`)}
215215
`)}</div>
@@ -289,6 +289,25 @@ private __rozieFirstUpdateDone = false;
289289
}
290290
};
291291

292+
getInstance() {
293+
return this.instance;
294+
}
295+
296+
toArray() {
297+
return this.instance ? this.instance.toArray() : [];
298+
}
299+
300+
sort(order: any, useAnimation = true) {
301+
this.instance?.sort(order, useAnimation);
302+
}
303+
304+
option(name: any, value: any) {
305+
if (!this.instance) return undefined;
306+
if (value === undefined) return this.instance.option(name);
307+
this.instance.option(name, value);
308+
return value;
309+
}
310+
292311
get items(): any[] { return this._itemsControllable.read(); }
293312
set items(v: any[]) { this._itemsControllable.notifyPropertyWrite(v); }
294313

packages/ui/sortable-list/packages/react/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,27 @@ export function Demo() {
6060
| `start` | Fired when dragging starts. |
6161
| `end` | Fired when dragging ends (source side). |
6262

63+
## Imperative handle
64+
65+
Beyond props, the component exposes imperative methods (declared once in the Rozie source via `$expose`). Grab a handle with the native ref mechanism and call them directly:
66+
67+
| Method | Description |
68+
| --- | --- |
69+
| `getInstance` | Return the underlying SortableJS instance for direct API access (the raw-engine escape hatch — `save`, `closest`, etc. are one hop away). `null` before mount and after destroy. |
70+
| `toArray` | Return the current order as an array of `data-id` strings (each row carries `data-id="<key>"`). `[]` before mount. |
71+
| `sort` | Reorder the list by an array of `data-id` strings — `sort(order, useAnimation = true)`. |
72+
| `option` | Read or set a live SortableJS option — `option(name)` gets, `option(name, value)` sets. The runtime escape hatch for options beyond the curated props. |
73+
74+
```tsx
75+
import { useRef } from 'react';
76+
import { SortableList, type SortableListHandle } from '@rozie-ui/sortable-list-react';
77+
78+
const sl = useRef<SortableListHandle>(null);
79+
// <SortableList ref={sl} ... />
80+
const order = sl.current?.toArray();
81+
sl.current?.option('disabled', true);
82+
```
83+
6384
## Slots
6485

6586
| Slot | Params |

packages/ui/sortable-list/packages/react/src/SortableList.d.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { ReactNode } from 'react';
2+
import type { ForwardRefExoticComponent, RefAttributes } from 'react';
3+
import type * as React from 'react';
24

35
export interface SortableListProps {
46
items?: unknown[];
@@ -28,5 +30,12 @@ export interface SortableListProps {
2830
slots?: Record<string, () => ReactNode>;
2931
}
3032

31-
declare function SortableList(props: SortableListProps): JSX.Element;
33+
export interface SortableListHandle {
34+
getInstance: (...args: any[]) => any;
35+
toArray: (...args: any[]) => any;
36+
sort: (...args: any[]) => any;
37+
option: (...args: any[]) => any;
38+
}
39+
40+
declare const SortableList: React.ForwardRefExoticComponent<SortableListProps & React.RefAttributes<SortableListHandle>>;
3241
export default SortableList;

packages/ui/sortable-list/packages/react/src/SortableList.tsx

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useCallback, useEffect, useRef, useState } from 'react';
1+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
22
import type { ReactNode } from 'react';
3-
import { clsx, useControllableState } from '@rozie/runtime-react';
3+
import { clsx, rozieDisplay, useControllableState } from '@rozie/runtime-react';
44
import './SortableList.css';
55
import { useSortableJS } from './internal/useSortableJS';
66

@@ -34,7 +34,14 @@ interface SortableListProps {
3434
slots?: Record<string, () => import('react').ReactNode>;
3535
}
3636

37-
export default function SortableList(_props: SortableListProps): JSX.Element {
37+
export interface SortableListHandle {
38+
getInstance: (...args: any[]) => any;
39+
toArray: (...args: any[]) => any;
40+
sort: (...args: any[]) => any;
41+
option: (...args: any[]) => any;
42+
}
43+
44+
const SortableList = forwardRef<SortableListHandle, SortableListProps>(function SortableList(_props: SortableListProps, ref): JSX.Element {
3845
const __defaultOptions = useState(() => (() => ({}))())[0];
3946
const props: Omit<SortableListProps, 'itemKey' | 'handle' | 'group' | 'animation' | 'disabled' | 'options' | 'labelFor' | 'ghostClass' | 'chosenClass' | 'dragClass' | 'filter' | 'easing' | 'forceFallback' | 'swapThreshold' | 'cloneable'> & { itemKey: (string) | null; handle: (string) | null; group: (string) | null; animation: number; disabled: 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 } = {
4047
..._props,
@@ -159,6 +166,36 @@ export default function SortableList(_props: SortableListProps): JSX.Element {
159166
});
160167
}
161168
}, [_rozieProp_onChange, getLabel, items, liftedIndex, setItems]);
169+
// Imperative handle (Phase 21 $expose). The SortableJS imperative surface a
170+
// consumer can't drive through props alone — exposed uniformly to all 6 targets.
171+
// Each guards the pre-mount/destroyed `instance = null`. Collision-clear: none of
172+
// the 4 verb names collide with the 16 props or the 5 events — `option` is a
173+
// distinct identifier from the `options` prop, so ROZ121 is clear.
174+
function getInstance() {
175+
return instance.current;
176+
}
177+
// toArray()/sort() operate on SortableJS's data-id ordering — every row carries
178+
// :data-id="keyFor(item, index)", so toArray() returns the current key order and
179+
// sort(order) reorders by those keys (set itemKey for stable object-list keys).
180+
// toArray()/sort() operate on SortableJS's data-id ordering — every row carries
181+
// :data-id="keyFor(item, index)", so toArray() returns the current key order and
182+
// sort(order) reorders by those keys (set itemKey for stable object-list keys).
183+
function toArray() {
184+
return instance.current ? instance.current.toArray() : [];
185+
}
186+
function sort(order: any, useAnimation = true) {
187+
instance.current?.sort(order, useAnimation);
188+
}
189+
// option(name) reads a live SortableJS option; option(name, value) sets one — the
190+
// runtime escape hatch for any SortableJS option beyond the curated props.
191+
// option(name) reads a live SortableJS option; option(name, value) sets one — the
192+
// runtime escape hatch for any SortableJS option beyond the curated props.
193+
function option(name: any, value: any) {
194+
if (!instance.current) return undefined;
195+
if (value === undefined) return instance.current.option(name);
196+
instance.current.option(name, value);
197+
return value;
198+
}
162199

163200
useEffect(() => {
164201
// Named `sortable` (not `handle`) to avoid shadowing `$props.handle`
@@ -266,16 +303,19 @@ export default function SortableList(_props: SortableListProps): JSX.Element {
266303
instance.current?.option('easing', v);
267304
}, [props.easing]);
268305

306+
useImperativeHandle(ref, () => ({ getInstance, toArray, sort, option }), []); // eslint-disable-line react-hooks/exhaustive-deps
307+
269308
return (
270309
<>
271310
<div ref={__rozieRoot} {...attrs} className={clsx("rozie-sortable-wrap", (attrs.className as string | undefined))} data-rozie-s-0af24eae="">
272311
<div className={"rozie-sortable-list"} ref={listEl} part="list" data-rozie-s-0af24eae="">
273-
{items.map((item, index) => <div key={keyFor(item, index)} className={clsx("rozie-sortable-item", { "rozie-sortable-item-lifted": liftedIndex === index })} role="listitem" tabIndex={0} onKeyDown={($event) => { onRowKeyDown($event, index); }} data-rozie-s-0af24eae="">
312+
{items.map((item, index) => <div key={keyFor(item, index)} className={clsx("rozie-sortable-item", { "rozie-sortable-item-lifted": liftedIndex === index })} data-id={rozieDisplay(keyFor(item, index))} role="listitem" tabIndex={0} onKeyDown={($event) => { onRowKeyDown($event, index); }} data-rozie-s-0af24eae="">
274313
{typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)({ item, index }) : (props.children ?? props.slots?.[''])}
275314
</div>)}
276315
</div>
277316
<div className={"rozie-sortable-aria-live"} data-rozie-sortable-aria-live="" aria-live="polite" aria-atomic="true" data-rozie-s-0af24eae="">{ariaLiveText}</div>
278317
</div>
279318
</>
280319
);
281-
}
320+
});
321+
export default SortableList;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
export { default as SortableList } from './SortableList';
22
export { default } from './SortableList';
3+
4+
/** The `$expose` imperative handle received via `ref` — { getInstance, toArray, sort, option }. */
5+
export type { SortableListHandle } from './SortableList';

packages/ui/sortable-list/packages/solid/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,26 @@ export function Demo() {
6060
| `start` | Fired when dragging starts. |
6161
| `end` | Fired when dragging ends (source side). |
6262

63+
## Imperative handle
64+
65+
Beyond props, the component exposes imperative methods (declared once in the Rozie source via `$expose`). Grab a handle with the native ref mechanism and call them directly:
66+
67+
| Method | Description |
68+
| --- | --- |
69+
| `getInstance` | Return the underlying SortableJS instance for direct API access (the raw-engine escape hatch — `save`, `closest`, etc. are one hop away). `null` before mount and after destroy. |
70+
| `toArray` | Return the current order as an array of `data-id` strings (each row carries `data-id="<key>"`). `[]` before mount. |
71+
| `sort` | Reorder the list by an array of `data-id` strings — `sort(order, useAnimation = true)`. |
72+
| `option` | Read or set a live SortableJS option — `option(name)` gets, `option(name, value)` sets. The runtime escape hatch for options beyond the curated props. |
73+
74+
```tsx
75+
import { SortableList, type SortableListHandle } from '@rozie-ui/sortable-list-solid';
76+
77+
let handle: SortableListHandle | undefined;
78+
// The ref callback receives the HANDLE object (not the DOM node).
79+
<SortableList ref={(h) => (handle = h)} />;
80+
const order = handle?.toArray();
81+
```
82+
6383
## Slots
6484

6585
| Slot | Params |

0 commit comments

Comments
 (0)