skip to Main Content

I want to make a custom control that has different interaction methods for mouse and keyboard. More specifically, it will have buttons for mouse use and separate keyboard controls, similar to the native number input.

In essence, there are action two buttons ("decrement" and "increment") and a value. The control itself is the focusable part; the buttons are not keyboard focusable, they can only be clicked. When the control is focused, the up/down arrow keys trigger the same actions as the buttons.

Here’s the basic structure I have at the moment (no labels, ARIA, etc.):

<span id="control">
  <button data-action="decrement"> - </button>
  <span data-value>0</span>
  <button data-action="increment"> + </button>
</span>

Below is a very minimal implementation that simply uses the two buttons:

const control = document.querySelector("#control");
const buttonDecrement = control.querySelector('[data-action="decrement"]');
const buttonIncrement = control.querySelector('[data-action="increment"]');
const value = control.querySelector("[data-value]");

function decrement() {
  value.textContent = Number(value.textContent) - 1;
}

function increment() {
  value.textContent = Number(value.textContent) + 1;
}

buttonDecrement.addEventListener("click", () => {
  decrement();
});

buttonIncrement.addEventListener("click", () => {
  increment();
});
* {
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

body {
  display: grid;
  place-content: center;
}

[data-value] {
  display: inline-block;
  text-align: center;
  min-width: calc(3ch + 1em);
  padding-left: 0.5em;
  padding-right: 0.5em;
}
<span id="control">
  <button data-action="decrement" aria-label="Decrement">
    -
  </button>
  <span data-value>0</span>
  <button data-action="increment" aria-label="Increment">
    +
  </button>
</span>

Now, what I need help with:

  • The focus should always go to the container, not the contents (except maybe for things like arrow key navigation with a screen reader?).
    tabindex="0" on the container and -1 on the buttons seems to work, but there is likely more to it that I’m missing.
    Then there’s the issue of nested interactive elements, etc. I’m not sure what the "correct" behavior is.

  • I experimented with adding keyboard event listeners (in conjunction with tabindex="0" on the element), but when using a screen reader (NVDA, specifically) the events were consumed by it instead of triggering the event listener. For example, the arrow keys are used for navigation; standard controls that use these (the number input uses the up/down arrows, for example) are fine, but I don’t know how to specify that behavior for my own event listeners.

  • It should be reasonably accessible, or able to be made so.
    For example:

    • It should have labeling. Based on the docs for aria-label, I understand a role is required for this. However, I am not sure which to use.
    • A screen reader should read the value when focusing it, but not list the buttons. Just using aria-hidden on the buttons seems to work.
    • The new value should be read out it is updated. I believe aria-live is enough?
    • The keyboard controls should be made known to accessibility technology somehow.

Custom elements can be used. However, the Element Internals API is not widely supported enough, so please don’t include that as a requirement, unless stating that there is no other way.


I apologize for the large question. I felt that it was most logical to keep everything together, rather than trying to split it into multiple questions.
If folks want it split up / narrowed down, I can try. Just state so clearly.

2

Answers


  1. Introduction: Is a custom control really needed ? Isn’t there an existing control that already do what you need ?

    When talking about accessibility of user interfaces in general, this is the first question you should ask yourself. Using a standard control provide a lot of advantages, ammong the following:

    • No need to reinvent the wheel, you have much less work to do, no need to maintain your custom control to keep it working as the time pass
    • Accessibility of standard controls is in principle guaranteed everywhere, and if not, is going to improver over time, without the need for you to do anything

    When you make your own control, even with all the good will you have, you are very likely to make something that isn’t as accessible as standard controls.
    Even if you use custom controls provided by whatever library, even well known ones, accessibility often don’t get as much attention as it should.

    Believe me, you can’t beat standard controls. As a screen reader user myself, my own experience is, unfortunately, quite pessimistic, even for libraries that are used a lot and promoted by major companies.

    So, ideally, you should only make custom controls when standard controls functionally don’t fullfill your needs. I especially stress on the functionality, the feature: what does the control allow the user to do? what kind of data the user has to input?
    Because if the problem is only a question of visual design, you are normally able to do whatever you want with CSS. Ideally, you shouldn’t create a custom control just because of the visual appearence of the standard equivalent.

    The W3C/WAI and the ARIA group have compiled a whole list of the most common usages, and the controls or pattern they correspond to. 95% of the time, you don’t need to go custom.
    See for example that list of ARIA roles on the MDN. For most of the roles, there exists an element that implements it natively.

    Back to your specific case

    IF we ask the above question in your specific case, the answer is clearly no, you don’t need a custom control, because what you are trying to do already exists.
    What you are doing is a spin control.
    A spin control is a control that allows the user to enter a number, whether directly by typing it, or by incrementing/decrementing it.

    In HTML, you create a spin control by using the number input type:

    <input type="number" value="50" min="0" max="100" step="1"/>
    

    That’s it. No hastle, and itt works everywhere.

    IF you still want to make your custom control

    IF you still want to go for your custom control, let’s answer your initial questions.

    In your case, the adequate ARIA role is spinbutton. That page also expain the differences with several other roles, and again recommend yoou to use <input type="number"/>.

    This previous link also tells you what to do about focus: only the text field, or in your case only the span containing the value, should be focusable. The buttons shouldn’t be focusable, as the functionality is already provided through up/down arrow keys.

    As you have noticed, making the container focusable is bad, since it makes the screen reader uselessly read the increment/decrement buttons.

    For labelling the control, refer to aria-label / aria-labelledby attributes. They will work if you define a role for your element.

    For NVDA which don’t let you take arrow keys for your own controls, it should no longer occur if you correctly set the spinbutton role, and well implement the corresponding functionality.

    Login or Signup to reply.
  2. @QuentinC has some good info in their answer.

    To address a few specific questions you had

    tabindex="0" on the container and -1 on the buttons seems to work, but there is likely more to it that I’m missing.

    It’s almost that simple, except for your nested interactive element question which I address in the next paragraph. Take a look at the spinbutton design pattern and the example. They are setting tabindex="0" on the "value" of the spinbutton and tabindex="-1" on the increment/decrement buttons.

    Then there’s the issue of nested interactive elements, etc. I’m not sure what the "correct" behavior is.

    Nested interactive elements are typically not allowed from an HTML spec perspective. The previously mentioned spinbutton design pattern has the increment/decrement buttons as siblings of the spinbutton value, so there aren’t any nested elements. You’ll want your <span data-value>0</span> to have tabindex="0".

    I experimented with adding keyboard event listeners (in conjunction with tabindex="0" on the element), but when using a screen reader (NVDA, specifically) the events were consumed by it instead of triggering the event listener

    A screen reader has two modes of operation: (1) browse mode and (2) forms mode. (Different terms might be used by different screen readers for the two modes.) Browse mode is when you press a key and the screen reader has first shot at that key, such as the arrow keys, as you noticed. Forms mode is when the browser has first shot at the key, which is what you want when the focus is on your element.

    When you TAB through a page and land on interative elements, the screen reader will automatically (by default) switch between the two modes if that interactive element requires keyboard input more than just the ENTER key. For example, tabbing to a link will not switch modes because you don’t have to type so it’ll remain in browser mode, although pressing ENTER on the link will activate it. If you tab to an <input>, the mode will switch so that you can type into the input.

    A spinbutton will automatically switch modes, whether you use a native spinbutton or if you have a custom one as long as your custom element has role="spinbutton". That will allow the up/down events to go to your element.

    It should have labeling. Based on the docs for aria-label,

    Yes, all interactive elements should have a label. The design pattern example uses <div role="group" aria-labelledby="id"> around the entire spinbutton group.

    I understand a role is required for this. However, I am not sure which to use.

    Yes, role="spinbutton" as mentioned above so that the screen reader will switch modes automatically for you and allow the arrow key events to be sent to your element.

    A screen reader should read the value when focusing it, but not list the buttons. Just using aria-hidden on the buttons seems to work.

    Yes, aria-hidden will prevent it from being read by the screen reader but you also need tabindex="-1" so that the screen reader user can’t TAB to the element, otherwise nothing will be announced when they TAB to it.

    The new value should be read out it is updated. I believe aria-live is enough?

    You could use aria-live but if your element has focus and you update the aria-valuenow attribute, the new value will be announced for "free". You don’t have to do anything to force the announcement.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search