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/mesh-layers": "~9.2.5",
"@deck.gl/react": "~9.2.5", "@deck.gl/react": "~9.2.5",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/cache": "^11.4.0", "@emotion/cache": "^11.4.0",
@@ -3041,6 +3042,20 @@
"react-dom": ">=16.8.0" "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": { "node_modules/@dnd-kit/sortable": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", "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/mesh-layers": "~9.2.5",
"@deck.gl/react": "~9.2.5", "@deck.gl/react": "~9.2.5",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/cache": "^11.4.0", "@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-partition": "file:./plugins/legacy-plugin-chart-partition",
"@superset-ui/legacy-plugin-chart-rose": "file:./plugins/legacy-plugin-chart-rose", "@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/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/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-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram", "@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts", "@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-handlebars": "file:./plugins/plugin-chart-handlebars",
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table", "@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-table": "file:./plugins/plugin-chart-table",
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud", "@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", "@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
"@types/d3-format": "^3.0.1", "@types/d3-format": "^3.0.1",
"@types/d3-selection": "^3.0.11", "@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(); 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 () => { test('Should call Control component', async () => {
const props = createProps(); const props = createProps();
render(<CollectionControl {...props} />); render(<CollectionControl {...props} />);

View File

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