diff --git a/src/main/resources/assets/admin/common/js/form2/components/sortable-list/SortableList.tsx b/src/main/resources/assets/admin/common/js/form2/components/sortable-list/SortableList.tsx index f9bfe5b4f..674896e98 100644 --- a/src/main/resources/assets/admin/common/js/form2/components/sortable-list/SortableList.tsx +++ b/src/main/resources/assets/admin/common/js/form2/components/sortable-list/SortableList.tsx @@ -76,6 +76,25 @@ export type SortableDropHint = { allowed: boolean; }; +/** Semantic attributes applied to the list container. */ +export type SortableListContainerProps = { + role?: JSX.AriaRole; + 'aria-label'?: string; +}; + +/** Semantic attributes applied to a sortable row. */ +export type SortableListItemProps = { + role?: JSX.AriaRole; + tabIndex?: number; + 'aria-disabled'?: boolean; + 'aria-expanded'?: boolean; + 'aria-level'?: number; + 'aria-posinset'?: number; + 'aria-roledescription'?: string; + 'aria-selected'?: boolean; + 'aria-setsize'?: number; +}; + /** Vertical drag-to-reorder list with built-in drag handles. */ export type SortableListProps = { 'data-component'?: string; @@ -116,6 +135,17 @@ export type SortableListProps = { resolveDrop?: (info: SortableDragInfo, items: T[]) => SortableDropHint | null; /** Extra classes on each row wrapper; function form receives item context. */ itemClassName?: string | ((context: SortableListItemContext) => string); + /** Semantic attributes applied to the list container. */ + containerProps?: SortableListContainerProps; + /** + * Returns semantic attributes for each row. Overriding `role` drops dnd-kit's button-specific + * ARIA, so supply `aria-disabled` and `aria-roledescription` here when the role needs them. + * Rows keep dnd-kit's tab stop unless `tabIndex` is returned — roving tab stop consumers + * (trees, listboxes) must return it, otherwise every row lands in the tab order. + */ + getItemProps?: (context: SortableListItemContext) => SortableListItemProps; + /** Whether dnd-kit restores focus to the original activator after a keyboard drag. Defaults to `true`. */ + restoreFocus?: boolean; className?: string; }; @@ -163,7 +193,7 @@ type SortableListItemInternalProps = { animateLayoutChanges?: (args: {isSorting: boolean; wasDragging: boolean}) => boolean; renderItem: (context: SortableListItemContext, grip?: ReactNode) => ReactNode; itemClassName?: string | ((context: SortableListItemContext) => string); - itemTabIndex?: number; + getItemProps?: (context: SortableListItemContext) => SortableListItemProps; }; const SortableListItem = ({ @@ -181,12 +211,12 @@ const SortableListItem = ({ animateLayoutChanges, renderItem, itemClassName, - itemTabIndex, + getItemProps, }: SortableListItemInternalProps): ReactElement => { const [isFocused, setIsFocused] = useState(false); const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({ id, - disabled: !isMovable, + disabled: !enabled || !isMovable, animateLayoutChanges, }); @@ -225,6 +255,11 @@ const SortableListItem = ({ }; const resolvedClassName = typeof itemClassName === 'function' ? itemClassName(context) : itemClassName; + const resolvedItemProps = getItemProps?.(context); + const resolvedRole = resolvedItemProps?.role ?? (attributes.role as JSX.AriaRole); + const hasCustomRole = resolvedItemProps?.role != null; + // ? dnd-kit's aria-disabled reports "not draggable", which only reads correctly on its own button role + const defaultAriaDisabled = hasCustomRole ? !enabled || undefined : attributes['aria-disabled']; const grip = isMovable && (