Compare commits

...

2 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
74e9a9ace1 fix(CollectionControl): use core Button for drag handle and add a11y test
Replace the hand-rolled <button> drag activator with the core Button
component (type="text") so it inherits theme focus styles instead of
relying on a custom focus-visible outline. Strengthen the existing
drag-activator test by asserting the aria-label attribute and the
underlying BUTTON tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:42:08 +03:00
Mehmet Salih Yavuz
ed1588a6f7 fix(CollectionControl): a11y drag handle and vertical axis lock
dnd-kit attributes (which include role="button", tabIndex, and
aria-roledescription) were spread onto a <span> wrapping an <Icon
role="img">, producing a button-role span containing an img-role child
— a confusing a11y tree that's not natively keyboard-activatable.

- Render the drag handle as <button type="button"> via setActivatorNodeRef
  so the activator (button) is distinct from the drop node, and gets
  proper button semantics + keyboard activation. Move the aria-label
  to the button and mark the icon aria-hidden.
- Add restrictToVerticalAxis modifier so dragged items can't drift
  horizontally (the original react-sortable-hoc implementation used
  lockAxis="y"). Adds @dnd-kit/modifiers (peer-compatible with
  @dnd-kit/core 6.3+).
- Add KeyboardSensor with sortableKeyboardCoordinates so the list can
  be reordered via keyboard.
2026-05-04 19:44:05 +03:00
4 changed files with 54 additions and 9 deletions

View File

@@ -23,6 +23,7 @@
"@deck.gl/mesh-layers": "~9.2.5",
"@deck.gl/react": "~9.2.5",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/cache": "^11.4.0",
@@ -3041,6 +3042,20 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",

View File

@@ -104,6 +104,7 @@
"@deck.gl/mesh-layers": "~9.2.5",
"@deck.gl/react": "~9.2.5",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/cache": "^11.4.0",
@@ -133,16 +134,16 @@
"@superset-ui/legacy-plugin-chart-partition": "file:./plugins/legacy-plugin-chart-partition",
"@superset-ui/legacy-plugin-chart-rose": "file:./plugins/legacy-plugin-chart-rose",
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
"@types/d3-format": "^3.0.1",
"@types/d3-selection": "^3.0.11",

View File

@@ -134,6 +134,14 @@ test('Should have SortableDragger icon', async () => {
expect(await screen.findByLabelText('Drag to reorder')).toBeVisible();
});
test('Drag activator exposes aria-label="Drag to reorder"', async () => {
const props = createProps();
render(<CollectionControl {...props} />);
const dragActivator = await screen.findByLabelText('Drag to reorder');
expect(dragActivator).toHaveAttribute('aria-label', 'Drag to reorder');
expect(dragActivator.tagName).toBe('BUTTON');
});
test('Should call Control component', async () => {
const props = createProps();
render(<CollectionControl {...props} />);

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import React, { useCallback, useMemo } from 'react';
import { IconTooltip, List } from '@superset-ui/core/components';
import { Button, IconTooltip, List } from '@superset-ui/core/components';
import { nanoid } from 'nanoid';
import { t } from '@apache-superset/core/translation';
import { useTheme, type SupersetTheme } from '@apache-superset/core/theme';
@@ -27,14 +27,17 @@ import {
useSensor,
useSensors,
PointerSensor,
KeyboardSensor,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
sortableKeyboardCoordinates,
arrayMove,
} from '@dnd-kit/sortable';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { CSS } from '@dnd-kit/utilities';
import { Icons } from '@superset-ui/core/components/Icons';
import {
@@ -68,8 +71,7 @@ interface CollectionControlProps {
function DragHandle() {
return (
<Icons.MenuOutlined
role="img"
aria-label={t('Drag to reorder')}
aria-hidden
className="text-primary"
style={{ cursor: 'ns-resize' }}
/>
@@ -93,8 +95,14 @@ function SortableItem({
onChangeItem,
onRemoveItem,
}: SortableItemProps) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition: transition ?? undefined,
@@ -116,8 +124,17 @@ function SortableItem({
paddingInline: theme.sizeUnit * 6,
})}
>
<span {...attributes} {...listeners}>
<DragHandle />
<span ref={setActivatorNodeRef} css={{ display: 'inline-flex' }}>
<Button
type="text"
aria-label={t('Drag to reorder')}
icon={<DragHandle />}
css={{
cursor: 'ns-resize',
}}
{...attributes}
{...listeners}
/>
</span>
<div
css={(theme: SupersetTheme) => ({
@@ -183,6 +200,9 @@ function CollectionControl({
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const itemIds = useMemo(
@@ -260,6 +280,7 @@ function CollectionControl({
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>