The Road to Accessible Drag and Drop (Part 3)

Concluding our three-part series on accessible drag and drop, this article is detailed documentation for the DragAct codebase. Everything you need to configure and use the script is here.

The underlying concepts are explored in previous articles:

  1. Part 1 (Questions and requirements)
  2. Part 2 (Techniques and solutions)

Table of contents (skip)

  1. Demos and codebase
  2. Quick setup
  3. HTML

  4. CSS
  5. JavaScript


Quick setup

The quickest way to setup and use the script, is to start with one of the demos.

Here’s the recommended markup and resources for a single instance (with sorting):

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <style>

        @import url("./drag-act.css");
        @import url("./drag-act-sorted.css");

    </style>
</head>
<body>
    <section id="wrapper">

        <div role="listbox" data-drag-act="droptarget">

            <h3 data-drag-act="label">Container label</h3>

            <span data-drag-act="sorted"></span>

            <ol role="none" data-drag-act="parent">
                <li role="option" data-drag-act="dragitem">
                    First item
                </li>
                <li role="option" data-drag-act="dragitem">
                    Second item
                </li>
                <li role="option" data-drag-act="dragitem">
                    Third item
                </li>
                ...
            </ol>
        </div>

        ...

    </section>
    <script type="module">

        import { default as DragAct } from './drag-act.js';

        let instance = new DragAct('#wrapper');

    </script>
</body>
</html>

Table of contents


HTML

Structural and interactive elements are defined by custom data attributes, which declare the containers, container labels, and draggable items.

Containers can also optionally include an insertion parent for the items, and a sort button to implement selection sorting.

Here’s the basic outline of a single container, with all required and optional declarations:

<div data-drag-act="droptarget">
    <h3 data-drag-act="label">Container label</h3>
    <span data-drag-act="sorted"></span>
    <ol data-drag-act="parent">
        <li data-drag-act="dragitem">First item</li>
        <li data-drag-act="dragitem">Second item</li>
        <li data-drag-act="dragitem">Third item</li>
    </ol>
</div>

If the insertion parent and sort functionality are not required, this is the minimum structure:

<div data-drag-act="droptarget">
    <h3 data-drag-act="label">Container label</h3>
    <p data-drag-act="dragitem">First item</p>
    <p data-drag-act="dragitem">Second item</p>
    <p data-drag-act="dragitem">Third item</p>
</div>

The elements used in these examples are recommended, but not required; any valid HTML can be used except for elements that are natively focusable, such as buttons and links.

Natively interactive controls must not be used for any of the elements, nor can any of the elements contain other interactive content. These would undermine the script’s focus management, resulting in unpredictable navigation or selection behaviors.

Containers should not include any other content apart from [data-drag-act] elements. Such content won’t be accessible to JAWS and NVDA users, since read-cursor navigation keys are not available inside the composite widget.

Empty elements can be used, like <span> or <div> wrappers to provide extra styling hooks.

Table of contents

Role combinations

The containers and draggable items all require explicit roles.

Theoretically, any valid combination of ARIA roles can be used, however in practice, only two combinations are guaranteed to work in all supported browsers and assistive tech:

Listbox with options (multiple selection)
<div role="listbox" data-drag-act="droptarget">
    ...
    <ol role="none" data-drag-act="parent">
        <li role="option" data-drag-act="dragitem">...</li>
        <li role="option" data-drag-act="dragitem">...</li>
        <li role="option" data-drag-act="dragitem">... </li>
    </ol>
</div>
Radiogroup with radios (single selection)
<div role="radiogroup" data-drag-act="droptarget">
    ...
    <ol role="none" data-drag-act="parent">
        <li role="radio" data-drag-act="dragitem">...</li>
        <li role="radio" data-drag-act="dragitem">...</li>
        <li role="radio" data-drag-act="dragitem">... </li>
    </ol>
</div>

If an insertion parent is used then it must be semantically neutral, either using role="none", or a generic element like <div>.

Table of contents

Instance scope

Declare the wrapper element that surrounds all containers within this instance, which is used to constrain events, and can specify the instance language.

This element is required.

Scope configuration attributes

[lang]

Specify a language code for this instance.

<section lang="en">

This attribute is optional and can have any valid code that matches the available languages. Language codes are case-insensitive.

If undefined, this falls back on the value of <html lang>, or if that’s also undefined, it defaults to the user’s system language.

If there are no available languages for any of those codes (in that order of precedence), then the final fallback is to use the built-in default, which is "en".

Custom language extensions are allowed, and can be used to define language variations for specific instances, e.g., "en-widget1" or "fr-ca-radios".

Table of contents

Containers

These are the primary container elements for draggable items, which also provide available drop targets when items are selected, and which define the widget role.

This element is required.

There have to be two or more containers if items are to be moved between them, or if only sort functionality is required, then this can be provided with a single container.

Containers can have no items by default. They can also be populated after the instance is created, and will auto-refresh when you add or remove items.

However containers themselves can’t be added later. Creating an instance with no containers will not throw any errors, but neither will it ever work.

Container configuration attributes

[data-drag-act]

Declare that this element is a drop target container.

<div data-drag-act="droptarget">

This attribute is required and must have the exact value "droptarget".

[role]

Specify an ARIA role for this container.

<div role="listbox">

This attribute is required and can have any valid role.

Ensure that the role is compatible with the item role and selected state, preferably using a recommended role combination.

It is technically supported for different containers within the same instance to have different roles, however this is strongly discouraged, since it might be quite confusing for users.

[id]

Specify an ID for this container.

<div id="container1">

This attribute is optional and can specify any valid ID.

Most of the elements have programmatic IDs, which are generated dynamically so they don’t have to be hard-coded. However you may prefer to define a specific ID, to make it easier to identify elements in the instance collection data.

[data-drag-state]

Specify an attribute to declare the selected state of items in this container.

<div data-drag-state="aria-selected">

This attribute is optional and can specify any valid state.

If undefined, this defaults to "aria-checked".

It is technically supported for different containers within the same instance to use different selection states, however this is strongly discouraged, since it might be quite confusing for users.

[aria-orientation]

Specify the orientation of items in this container.

<div aria-orientation="horizontal">

This attribute is optional and is only needed if the layout is horizontal.

If undefined, this defaults to "vertical".

Additional container notes

  • It’s not necessary to add tabindex, or any other ARIA attributes apart from those listed above. All the additional container semantics are defined programmatically.
  • Do not define aria-label on the containers; the script will remove it. Accessible labels can only be defined using labelling elements.
  • Containers cannot be aria-disabled.

Table of contents

Container labels

One label element per container defines its accessible name, and may be used as a navigation or interaction hook with some assistive technologies.

This element is required.

Label configuration attributes

[data-drag-act]

Declare that this element is a container label.

<h3 data-drag-act="label">

This attribute is required and must have the exact value "label".

Additional label notes

  • Labels don’t have to be heading elements, however these are recommended because they provide navigation shortcuts for screen reader users.
  • It’s not necessary to add any ARIA attributes; all the additional semantics are defined programmatically.

Table of contents

Sort buttons

One button element per container provides a trigger for sort functionality, either by the user pressing it, or dragging items onto it.

This element is optional, and its presence controls whether sort functionality is available.

Sort button configuration attributes

[data-drag-act]

Declare that this element is a sort button.

<span data-drag-act="sorted">

This attribute is required and must have the exact value "sorted".

Additional sort button notes

  • It’s not necessary to add tabindex, or any ARIA attributes; all the additional semantics are defined programmatically.
  • Sort buttons are not programmatically populated, but they are given an accessible name (from defined language data). The recommended way to create a visible icon is with CSS pseudo-elements.

Table of contents

Insertion parents

Declare an element that wraps the draggable items, which can be used as a DOM node insertion reference in cases where the items are not direct children of the container.

This element is optional, and defaults to the container if undefined.

Parent configuration attributes

[data-drag-act]

Declare that this element is an insertion parent.

<ol data-drag-act="parent">

This attribute is required and must have the exact value "parent".

[role]

Parent elements have to be semantically neutral.

<ol role="none">

This attribute is required and must have the exact value "none" or "presentation" — but only if the element isn’t already semantically neutral.

Additional parent notes

  • You can have any number of wrapper elements between a container and its items, and all of them have to be semantically neutral, but only the one that directly contains the items is the "parent".
  • All the items within a container should have the same parent. If items have different parents then this won’t be remembered as they’re moved around, so they’ll all eventually end up inside the first one.

Table of contents

Draggable items

Draggable items are elements that can be selected, sorted, and moved between containers.

These elements are technically optional, since an instance can be created with no draggable items, and then dynamically populated later.

Item configuration attributes

[data-drag-act]

Declare that this element is a draggable item.

<li data-drag-act="dragitem">

This attribute is required and must have the exact value "dragitem".

[role]

Specify an ARIA role for this item.

<li role="option">

This attribute is required and can have any valid role.

Ensure that the role is compatible with the container role and selected state, preferably using a recommended role combination.

Multiple items within the same container must not have different roles.

Although this is technically supported, it wouldn’t be valid ARIA, and the container’s multiple selection model wouldn’t be able to adapt.

[id]

Specify an ID for this item.

<li id="item1">

This attribute is optional and can specify any valid ID.

Most of the elements have programmatic IDs, which are generated dynamically so they don’t have to be hard-coded. However you may prefer to define a specific ID, to make it easier to identify elements in the instance collection data.

[aria-disabled]

Specify that an item is unavailable.

<li aria-disabled="true">

This attribute is optional and can only have the value "true" to declare an unavailable item.

The "false" value shouldn’t be used; available items are identified by not having this attribute at all.

Additional item notes

  • It’s not necessary to add any other ARIA attributes apart from those listed above. All the additional semantics are defined programmatically.

Table of contents


CSS

The recommended way to create style rules is with attribute selectors, which can address all of the elements and persistent states, and combine to form compound state selectors.

Native dynamic states, such as focus and hover, can be addressed using their corresponding pseudo-classes, and there’s also several custom classes that identify non-native dynamic states.

Element types

The eponymous [data-drag-act] selector identifies each of the element types:

Element type selectors
Selector Meaning
[data-drag-act="droptarget"] Element is a container.
[data-drag-act="label"] Element is a container label.
[data-drag-act="sorted"] Element is a sort button.
[data-drag-act="parent"] Element is an insertion parent.
[data-drag-act="dragitem"] Element is a draggable item.
[data-drag-act="number"] Element is an order number used with selection-based sorting.

An additional element type of "description" also exists, which refers to the hidden elements that are used to define accessible descriptions for non-standard state information. Since these elements are usually hidden, they don’t need to be styled.

However accessible descriptions are not supported by VoiceOver, and these elements are repurposed into ARIA live regions. For that to work, the elements have to be displayed and visually-hidden.

The following rule is needed for that:

[data-drag-act="description"]:not([hidden]) {
    clip-path: inset(50%);
    display: block;
    height: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}

An example of this can also be found in the Demo CSS stylesheet.

Table of contents

Container states

Persistent states reflect whether selections have been made, and the available interactions within a given container:

Persistent container states
Selector Meaning
:not([data-drag-valid]) No items are selected in any container.
[data-drag-valid] Items are selected in any container (not necessarily this one).
[data-drag-valid="false"] Items are selected in this container, items can’t be moved here.
[data-drag-valid="true"] Items can be moved to this container, no items can be selected here.

Dynamic states reflect user interaction, and are only usable with specific elements at specific times:

Dynamic container states
Selector Meaning
[data-drag-act="droptarget"]:focus This container is directly focused.
[data-drag-act="droptarget"].focus-within This container has focus within it, but is not directly focused.

This state only occurs if a sort button is present and focused. The custom class is a polyfill for :focus-within, which currently lacks sufficient browser support.

[data-drag-valid="false"].dragout Items have been selected in this container, and either dragged out of it but not yet dropped elsewhere, or another container has focus.
[data-drag-valid="true"].dragover This container is an available drop target, and items are being dragged over it but haven’t been dropped.
[data-drag-valid="true"]:hover This container is an available drop target, and the mouse is hovering over it but selected items aren’t being dragged.

An additional container attribute declares whether Safari hacks are being used:

Safari hack states
Selector Meaning
[data-drag-safari] Safari hacks are being used in Safari (or the flag is set for all browsers).
[data-drag-safari="macos"] Safari hacks are being used in MacOS/Safari (or the flag is set for all browsers and touch events are not available).
[data-drag-safari="ios"] Safari hacks are being used in iOS/Safari (or the flag is set for all browsers and touch events are available).

Table of contents

Sorting states

The availability of sort functionality can be addressed with a combination of container, item, and sort button selectors:

Sortable container states
Selector Meaning
[data-drag-sorted="true"] This container has sort functionality, and includes a sort button.
Sortable item states
Selector Meaning
[data-drag-act="dragitem"] > [data-drag-act="number"]

This number element counts the order of selection inside a sortable container, with text content defined by language data.

The Sorting CSS stylesheet has an example of order number styling.

Sort button states
Selector Meaning
[data-drag-sorted="true"] > [data-drag-act="sorted"][aria-disabled="true"]

This (and every) sort button is disabled, because no items are selected in any container.

The recommended way to style disabled buttons is with opacity rather than color changes, because that’s still effective in forced-color modes.

[data-drag-sorted="true"] > [data-drag-act="sorted"]:not([aria-disabled])

This (and every) sort button is enabled, and can be used to either sort selected items within the same container, or to sort while dropping the items into a different container.

This selector doesn’t identify which container has selections, however that distinction can be made using [data-drag-valid].

[data-drag-sorted="true"] > [data-drag-act="sorted"]:focus This sort button is directly focused.

The sort buttons are not programmatically populated, but they are given an accessible name (from defined language data).

The visible appearance of sort buttons can be created in a number of ways, including adding <svg> or <img> icons directly inside the button, however the recommended approach is to use a pseudo-element:

[data-drag-sorted="true"] > [data-drag-act="sorted"]::before {
    ...
}

There’s an example of this in the Sorting CSS stylesheet, using SVG icons encoded as data URLs and rendered with CSS masking.

Table of contents

Draggable item states

Persistent states reflect whether items are selected or available, and all of them are defined by ARIA attributes. Note that state attributes such as [aria-checked] are only applicable if they match the container’s selection state.

Persistent item states
Selector Meaning
[data-drag-act="dragitem"][aria-checked="true"] This item is checked.
[data-drag-act="dragitem"][aria-checked="false"] This item is not (but can be) checked.
[data-drag-act="dragitem"][aria-selected="true"] This item is selected.
[data-drag-act="dragitem"][aria-selected="false"] This item is not (but can be) selected.
[data-drag-act="dragitem"]:not([aria-disabled="true"]) This item is available.
[data-drag-act="dragitem"][aria-disabled="true"]

This item is unavailable.

This selector doesn’t differentiate between items you’ve declared as disabled, and items that are temporarily disabled because they’re inside an available target container. However that distinction can be made using [data-drag-valid].

For items that use the checked selection state, it’s a good idea to provide extra visual affordance, using appropriate icons like a ticked or un-ticked box. These can be created with CSS pseudo-elements, for example:

[data-drag-act="dragitem"][aria-checked="false"]::before {
    ...
}
[data-drag-act="dragitem"][aria-checked="true"]::before {
    ...
}

The Demo CSS stylesheet has some examples, using SVG icons encoded as data URLs and rendered with CSS masking.

Dynamic states reflect user interaction, and are only usable with specific elements at specific times:

Dynamic item states
Selector Meaning
[data-drag-act="dragitem"].activedescendant

This item is the active element for keyboard navigation (referenced by its container’s aria-activedescendant attribute).

It’s recommended to style this state with focus indication.

[data-drag-act="droptarget"]:not([data-drag-valid="true"]):focus [data-drag-act="dragitem"].activedescendant

The .activedescendant class applies to the active element in every container (i.e., one per container). However it’s recommended to only visibly indicate it for the container that’s currently focused, when selections can be made. This selector addresses that compound state.

The Demo CSS stylesheet also has an example.

[data-drag-act="dragitem"].insertion

This item has just been moved or sorted, and is currently being animated.

When a set of items is appended to a container, they all have the .insertion class added at the moment of insertion (unless only one item is being moved). The class is then removed from each item on a 50ms interval, in the order they were appended. This can be used to create an insertion animation effect, like the example in the Demo CSS stylesheet.

Animated items must not be hidden or undisplayed or otherwise removed from the accessibility tree, however purely visual effects like opacity or transform can be used.

Table of contents

System colors

CSS system colors can be used to create visual states, when the content is viewed in a forced-colors mode, such as Windows high contrast.

Examples of this can be found in the Demo CSS and Sorting CSS stylesheets.

The available palette is extremely limited, and should only be used when necessary and appropriate.

Forced-color themes are primarily two-tone, like white text on a black background. Additional colors are only used for interactive controls, such as yellow for buttons, or cyan for links. Since the containers and items aren’t natively interactive, these variations won’t apply by default, but they can be defined in CSS.

For example, the draggable items can be styled using system button colors:

@media (forced-colors: active) {
    [data-drag-act="dragitem"] {
        background-color: ButtonFace;
        border-color: ButtonBorder;
        color: ButtonText;
        forced-color-adjust: none;
    }
}

While their checked or selected state can be styled with the system highlight colors:

@media (forced-colors: active) {
    [data-drag-act="dragitem"][aria-checked="true"] {
        background-color: Highlight;
        border-color: Highlight;
        color: HighlightText;
    }
}

The forced-color-adjust property is needed to remove the text backplate, which would otherwise cause the canvas color to be applied to the item text’s line-box. This is only needed for elements where a different system background color has been specified.

Table of contents


JavaScript

The DragAct class was designed as a default export module, which can loaded with an import declaration or dynamic import() expression. You could also convert it to a regular class declaration if that’s preferred.

Internationalization (i18n)

The class provides a static i18n() method for defining new language sets. The method accepts a single object, containing one or more objects of strings indexed by case-insensitive BCP47 language code (RFC5646), for example:

DragAct.i18n({
    'en' : {
        'role-description'  : '{{role}} drag and drop',
        'selection-notes'   : 'To choose items press Space.',
        'empty-notes'       : 'No items.',
        'drop-notes'        : 'To drop items press Enter.',
        'sort-notes'        : 'Sort by chosen order.',
        'sort-number'       : '#{{number}}',
        'selected-items'    : '{{count}} {{items}} checked.',
        'dropped-items'     : '{{count}} {{items}} dropped.',
        'item-single'       : 'item',
        'item-plural'       : 'items'
    }
});

Each language object must define every value, and all of them must be non-empty strings containing the required parsing tokens.

Language data can be defined at any time, but it’s generally best to do so before creating any instances, since they can’t be constructed with language data that doesn’t exist yet.

Language strings and parsing tokens
Key Meaning
"role-description"

Custom role description for containers.

The {{role}} token is parsed with the container’s role attribute, so that both are announced.

"selection-notes"

Interaction hint for containers when items can be selected.

"empty-notes"

Interaction hint for empty containers.

"drop-notes"

Interaction hint for containers when items can be dropped.

"sort-notes"

Label text for sort buttons.

"sort-number"

Text format for selection order numbers, appended to selected items when sorting is enabled.

The {{number}} token is parsed with an integer value starting from 1.

"selected-items"

Additional description for items when they’re selected, or when their selection state changes.

The {{count}} token is parsed with an integer value, while the {{items}} token is parsed with the language for "item-single" or "item-plural".

"dropped-items"

Additional description for containers immediately after items have been dropped.

The {{count}} token is parsed with an integer value, while the {{items}} token is parsed with the language for "item-single" or "item-plural".

"item-single"

Singular term for one item.

"item-plural"

Plural term for multiple items.

Table of contents

Static properties

Two static properties are available, for information or test configuration:

DragAct.language

The language property is a deep object indexed by case-insensitive BCP47 language code (RFC5646), providing a read-only copy of all the available language data:

console.log(DragAct.language);
console.log(DragAct.language['en']);
DragAct.safarihacks

Several Safari-specific hacks are used to handle variations in the selection/navigation model and non-standard state descriptions for VoiceOver.

The safarihacks flag can be used to configure that behavior. This flag is provided for testing purposes and should not be used in production; it can only be set before creating instances:

DragAct.safarihacks = -1;
safarihacks values
Value Meaning
-1

Safari hacks are not applied.

0

Safari hacks are applied for Safari (default).

1

Safari hacks are applied for all browsers.

Table of contents

Constructor

The constructor function is used to create an instance within a specified scope:

let instance = new DragAct('#wrapper');

The scope can be defined as a selector query, or an element reference.

It’s not required to assign the constructed instance, however doing so provides access to additional instance properties and callbacks.

Any number of instances can be created, and multiple instances are mutually exclusive. Items can’t be dragged between instances, and selecting items in one instance will clear selections in another.

Table of contents

Instance properties

Constructed instances provide a number of read-only properties, with on-demand information about that instance:

instance.collection

An object of all the relevant elements and behavioral flags that apply to each container within this instance. This is a deep object indexed by container ID, where each member object has the following data:

collection[id] members
Key Data
"droptarget"

The container element.

"dragitems"

An array of the draggable items inside this container.

This is a live reference, which therefore reflects all changes at the moment it’s queried.

Static snapshots can be created when an instance callback is dispatched, by making a local copy of the array (e.g., using the spread operator).

"label"

The container label element.

"parent"

The insertion parent element.

If no explicit parent has been declared, this will be the same element as "droptarget".

"sorted"

The sort button element.

If the sort button isn’t present, this will be null.

"multimode"

The multiple selection model for this container, one of three possible values:

  • -1   (multiple selection is controlled by input defaults or modifier keys.)
  • 0   (selection is locked to single items.)
  • 1   (non-contiguous multiple selection by default; contiguous selection is controlled by modifier keys.)

It’s not possible to set the mode directly; this is determined by selected state and item roles.

instance.language

A read-only object of the language data used for this instance.

This is specifically the language data that’s actually used for this instance, which may not match the language that was initially declared by the scope lang attribute or document (if that code couldn’t be matched with available languages).

instance.scope

A reference to the scope element.

Querying this element’s lang attribute will tell you what language code is being used for this instance, over-writing any code that was originally declared.

Table of contents

Instance auto-refresh

The script includes a mutation observer that watches for the addition or removal of draggable items. When items are added or removed from a container, the observer will automatically re-initialize that collection (and dispatch any callbacks).

Items can be added using DOM methods like appendChild(), or by writing to the container’s innerHTML.

Ensure that created items have the correct attributes.

If multiple items are added using innerHTML, the mutation observer will fire once for each node. This will trigger multiple callbacks, all of which return the same collection, reflecting the final result of all additions. This happens because the node insertions are synchronous, while the callbacks are asynchronous, therefore all insertions have already happened before the first callback dispatches

Only the addition or removal of draggable items is supported, the observer won’t respond to whole containers.

Table of contents


And that’s it!

I hope you’ve enjoyed this mini-series, and I welcome any thoughts, ideas, or feedback you might have on the concepts and solutions, or anything else.


Image credit: ucumari photography.

Categories: KnowledgeBase Content, Technical, User Experience (UX)

About James Edwards

I’m a web accessibility consultant with around 20 years experience. I develop, research and write about all aspects of accessible front-end development, with a particular specialism in accessible JavaScript. I can also turn my hand to PHP and MySQL when it’s needed. I started my career as an HTML coder, then as a JavaScript developer, but the more I learned, the more I realised just how important it is to consider accessibility. It’s the basic foundation of web development and a fundamental design principle of the web itself. If information is not accessible, then what’s the point of any of it? Coding is mechanics, but accessibility is people, and it’s people that actually matter.