import * as React from 'react'; import classNames from 'classnames'; import type { ReorderableListingItemData as CoreReorderableListingItemData, ReorderableListingKey as CoreReorderableListingKey, ReorderableListingReorderEvent as CoreReorderableListingReorderEvent, } from '@shoptet/ui-core-web'; import { ReorderableListing as CoreReorderableListing, useTranslations } from '@shoptet/ui-core-web'; import { useStableCallback } from '@shoptet/utils'; import { Icon } from '../Icon/Icon'; import type { IconName } from '../Icon/IconName'; import { dictionary } from './dictionary'; import type { ReorderableListingItemState } from './ReorderableListingItem'; import { ReorderableListingItem } from './ReorderableListingItem'; export type ReorderableListingKey = CoreReorderableListingKey; export interface ReorderableListingSubitem { /** Stable unique key. */ id: ReorderableListingKey; /** Row label. */ label: React.ReactNode; /** Leading icon. */ icon?: IconName; /** Renders the row in the hidden state (muted, eye-off action). */ hidden?: boolean; /** Renders the row in the disabled state (muted, no action). */ disabled?: boolean; /** Text used for accessibility / typeahead. Falls back to `label` when it is a string. */ textValue?: string; } export interface ReorderableListingItemData extends ReorderableListingSubitem { /** Subitems. They move with their parent and are not independently draggable. */ children?: ReorderableListingSubitem[]; /** * Whether the row can be dragged to reorder. Unlike `disabled`, a non-draggable row remains * selectable and keeps its own actions — only the drag affordance is disabled. * @default true */ draggable?: boolean; } export interface ReorderableListingProps extends Omit, 'className'> { /** Items to render (controlled). */ items?: ReorderableListingItemData[]; /** Initial items (uncontrolled). */ defaultItems?: ReorderableListingItemData[]; /** Called with the new top-level order after a drag-and-drop reorder. */ onReorder?: ( orderedIds: ReorderableListingKey[], items: ReorderableListingItemData[], movedId: ReorderableListingKey ) => void; /** Called when a top-level row is pressed (click or Enter) — e.g. to select it. */ onAction?: (id: ReorderableListingKey) => void; /** Called when a row's hide action is toggled. `parentId` is set for subitems. */ onHiddenChange?: (id: ReorderableListingKey, hidden: boolean, parentId?: ReorderableListingKey) => void; /** * Renders custom action-area content for a row (e.g. multiple buttons). When it returns a node, * it replaces the built-in hide button for that row. `parentId` is set for subitems. */ renderActions?: ( item: ReorderableListingSubitem, context: { mode: 'parent' | 'subitem'; parentId?: ReorderableListingKey } ) => React.ReactNode; /** Renders custom leading content (e.g. a thumbnail) for a row. Replaces the row's icon. */ renderMedia?: (item: ReorderableListingSubitem, context: { mode: 'parent' | 'subitem' }) => React.ReactNode; /** Footer content (e.g. an action button). */ footer?: React.ReactNode; /** Accessible label for the list. */ 'aria-label': string; /** Additional class name. */ className?: string; } type CoreItem = CoreReorderableListingItemData & { item: ReorderableListingItemData }; const getTextValue = (item: ReorderableListingSubitem): string => item.textValue ?? (typeof item.label === 'string' ? item.label : String(item.id)); const getState = (item: ReorderableListingSubitem) => item.disabled ? ('disabled' as const) : item.hidden ? ('hidden' as const) : ('default' as const); export const reorder = ( list: ReorderableListingItemData[], keys: Set, targetKey: ReorderableListingKey, position: 'before' | 'after' ): ReorderableListingItemData[] => { const moving = list.filter(item => keys.has(item.id)); const rest = list.filter(item => !keys.has(item.id)); const targetIndex = rest.findIndex(item => item.id === targetKey); if (targetIndex === -1) { return list; } rest.splice(position === 'before' ? targetIndex : targetIndex + 1, 0, ...moving); return rest; }; const toggleHidden = ( list: ReorderableListingItemData[], id: ReorderableListingKey, parentId?: ReorderableListingKey ): ReorderableListingItemData[] => list.map(item => { if (parentId !== undefined) { return item.id === parentId ? { ...item, children: item.children?.map(c => (c.id === id ? { ...c, hidden: !c.hidden } : c)) } : item; } return item.id === id ? { ...item, hidden: !item.hidden } : item; }); export const ReorderableListing = ({ items, defaultItems, onReorder, onAction, onHiddenChange, renderActions, renderMedia, footer, 'aria-label': ariaLabel, className, ...rest }: ReorderableListingProps) => { const translations = useTranslations(dictionary); const isControlled = items !== undefined; const [internalItems, setInternalItems] = React.useState(defaultItems ?? []); const data = isControlled ? items : internalItems; const coreItems = React.useMemo( () => data.map(item => ({ key: item.id, textValue: getTextValue(item), disabled: item.disabled, draggable: item.draggable, item, })), [data] ); const handleReorder = useStableCallback((event: CoreReorderableListingReorderEvent) => { const next = reorder(data, event.keys, event.targetKey, event.position); const [movedId] = event.keys; if (!isControlled) { setInternalItems(prev => reorder(prev, event.keys, event.targetKey, event.position)); } onReorder?.( next.map(item => item.id), next, movedId! ); }); const handleHideToggle = (item: ReorderableListingSubitem, parentId?: ReorderableListingKey) => { // Uncontrolled mode toggles visibility internally (mirrors uncontrolled reorder); // controlled mode only notifies and lets the consumer update `items`. if (!isControlled) { setInternalItems(prev => toggleHidden(prev, item.id, parentId)); } onHiddenChange?.(item.id, !item.hidden, parentId); }; const renderRow = ( rowItem: ReorderableListingSubitem, context: { mode: 'parent' | 'subitem'; state: ReorderableListingItemState; showHide: boolean; parentId?: ReorderableListingKey; dragHandle?: React.ReactNode; } ) => ( handleHideToggle(rowItem, context.parentId)} hideLabel={translations.hide} showLabel={translations.show} dragHandle={context.dragHandle} /> ); return (
( )} > {({ item }) => ( {({ state: rowState }) => ( <> {renderRow(item, { mode: 'parent', state: rowState.dragging ? 'dragging' : getState(item), showHide: !item.disabled, dragHandle: ( ), })} {!rowState.dragging && item.children?.map(subitem => renderRow(subitem, { mode: 'subitem', state: item.hidden ? 'hidden' : getState(subitem), showHide: !subitem.disabled && !item.hidden, parentId: item.id, }) )} )} )} {footer &&
{footer}
}
); };