|
1 | | -import { useCallback, useEffect, useRef, useState } from 'react'; |
| 1 | +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; |
2 | 2 | import type { ReactNode } from 'react'; |
3 | | -import { clsx, useControllableState } from '@rozie/runtime-react'; |
| 3 | +import { clsx, rozieDisplay, useControllableState } from '@rozie/runtime-react'; |
4 | 4 | import './SortableList.css'; |
5 | 5 | import { useSortableJS } from './internal/useSortableJS'; |
6 | 6 |
|
@@ -34,7 +34,14 @@ interface SortableListProps { |
34 | 34 | slots?: Record<string, () => import('react').ReactNode>; |
35 | 35 | } |
36 | 36 |
|
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 { |
38 | 45 | const __defaultOptions = useState(() => (() => ({}))())[0]; |
39 | 46 | 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 } = { |
40 | 47 | ..._props, |
@@ -159,6 +166,36 @@ export default function SortableList(_props: SortableListProps): JSX.Element { |
159 | 166 | }); |
160 | 167 | } |
161 | 168 | }, [_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 | + } |
162 | 199 |
|
163 | 200 | useEffect(() => { |
164 | 201 | // Named `sortable` (not `handle`) to avoid shadowing `$props.handle` |
@@ -266,16 +303,19 @@ export default function SortableList(_props: SortableListProps): JSX.Element { |
266 | 303 | instance.current?.option('easing', v); |
267 | 304 | }, [props.easing]); |
268 | 305 |
|
| 306 | + useImperativeHandle(ref, () => ({ getInstance, toArray, sort, option }), []); // eslint-disable-line react-hooks/exhaustive-deps |
| 307 | + |
269 | 308 | return ( |
270 | 309 | <> |
271 | 310 | <div ref={__rozieRoot} {...attrs} className={clsx("rozie-sortable-wrap", (attrs.className as string | undefined))} data-rozie-s-0af24eae=""> |
272 | 311 | <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=""> |
274 | 313 | {typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)({ item, index }) : (props.children ?? props.slots?.[''])} |
275 | 314 | </div>)} |
276 | 315 | </div> |
277 | 316 | <div className={"rozie-sortable-aria-live"} data-rozie-sortable-aria-live="" aria-live="polite" aria-atomic="true" data-rozie-s-0af24eae="">{ariaLiveText}</div> |
278 | 317 | </div> |
279 | 318 | </> |
280 | 319 | ); |
281 | | -} |
| 320 | +}); |
| 321 | +export default SortableList; |
0 commit comments