Skip to content

Instantly share code, notes, and snippets.

@MaybeThisIsRu
Last active September 13, 2020 08:17
Show Gist options
  • Save MaybeThisIsRu/98ae4883467081f582ba15a13c22524f to your computer and use it in GitHub Desktop.
Save MaybeThisIsRu/98ae4883467081f582ba15a13c22524f to your computer and use it in GitHub Desktop.
a11y while adding categories
<!--
Problem statement:
I'm writing an interactive category field.
I have three major things on play here:
1. A text input that is described by a paragraph above it
2. A datalist of categories received from the server
3. A list of checked checkboxes for each new category
Why #3? Well, I don't want to have to manage my form on the frontend before sending it to the backend. A checkbox with the same name attribute automatically takes care of sending multiple categories.
1. My main concern is indicating to users that the list of entered categories is *here* -- how do I connect the two?
2. A secondary concern is also making sense of what this "x" button *does* and for which particular value.
-->
<script>
import { onMount } from "svelte";
let categories = [];
$: userEnteredCategories = [];
let currentCategoryName = "";
onMount(async () => {
const response = await fetch(
"/api/v1/micropub/category/"
).catch((error) => console.error(error));
const data = await response
.json()
.catch((error) => console.error(error));
if (data && data.error) console.error(data.error_description);
else categories = data;
});
const handleKeyUp = (event) => {
if (event.code === "Comma") {
userEnteredCategories = [
...userEnteredCategories,
currentCategoryName.slice(0, -1),
];
currentCategoryName = "";
}
};
const deleteUserCategory = (event) => {
userEnteredCategories = userEnteredCategories.filter((cat) =>
event.target.dataset.value === cat ? false : true
);
};
</script>
<input
type="text"
id="field-group__category"
aria-describedby="field-group__description--category"
list="category--suggestions"
on:keyup|preventDefault={handleKeyUp}
bind:value="{currentCategoryName}"
/>
<datalist class="category" id="category--suggestions">
{#each categories as category}
<option value="{category}">{category}</option>
{/each}
</datalist>
<!-- To avoid handling the form manually just for categories, we build a list of checkboxes and have HTML do the heavy lifting for us -->
<!-- ! Concerns around accessibility -->
<div class="category" id="category--user">
{#each userEnteredCategories as userEnteredCategory, i}
<div class="user-category">
<button
class="user-category__remove"
on:click|preventDefault={deleteUserCategory}
data-value="{userEnteredCategory}"
>
x
</button>
<label class="user-category__name" for="{userEnteredCategory}_{i}"
>{userEnteredCategory}</label
>
<input
type="checkbox"
name="category"
id="{userEnteredCategory}_{i}"
value="{userEnteredCategory}"
class="user-category__value hidden"
checked
/>
</div>
{/each}
</div>
@meduzen
Copy link

meduzen commented Aug 24, 2020

(Okay, first I’m bad with ARIA, but rather good with regular HTML, but let’s go!)

Looks like a really complex challenge. So worth trying! 👍

UK gov has a fantastic read about their journey to fill a similar use case… and they have implemented the multi-selection on their search page, but they also explain to have backpedaled on some others.

They seem to use a ARIA live regions for the output (the list of selected tags), in which they use a list of <button> that delete the selection (“Remove filter xxxx”, probably partly answering your 2nd question: “Remove category xxx”). But you can also still use the checkboxes in the left panel in order to select/unselect a service.

Capture d’écran 2020-08-24 à 22 h 23   25

I don’t know if the use of aria-live answers your first question, but the main difference is in the left panel: instead of relying on what datalist enabled for text completion, which creates the difficulty you encounter (connecting the datalist autocompletion to a generated button+ checkbox), they use a regular text input (with no datalist) to filter the list of checkboxes that already exists in the HTML (= better progressive enhancement).

It’s the main difference: no datalist! Instead of having to connect the datalist native stuff (which has no HTML, so nothing you can connect) to a list of checkboxes, they have to connect a list of checkboxes to the delete button in the live region, which I think is more reasonable.

Putting aside those considerations for which I’m no expert at all, here are some issues I see in the HTML, and a proposal improvement after.

  1. Your input text needs a label. Right now, it’s a field without clear context: the sentence before explains where we are (which is great) but I fear it isn’t enough.
  2. Instead of preventDefault on the delete button, the type="button" attribute should do the trick. By default, a button type is submit, that’s probably why you call preventDefault on it. But with type="button", using the button won’t submit the form.
  3. The label of the delete button is x, which doesn’t mean anything for a human. :p

Proposal (without the GOV UK way of doing it):

<!-- Fieldset groups same-purpose fields (categories) -->
<fieldset>

    <!-- Title of the fieldset -->
    <legend>Categories</legend>

    <!-- Free text… -->
    <p>Explainer text (if your server supports…)</p>

    <!-- See issue 1 -->
    <label for="field-group__category">Filter categories</label>

    <!-- removed preventDefault on keyUp (does Svelte require it?) -->
    <input
        type="text"
        id="field-group__category"
        aria-describedby="field-group__description--category"
        list="category--suggestions"
        on:keyup={handleKeyUp}
        bind:value="{currentCategoryName}"
    />

    <datalist class="category" id="category--suggestions">
        {#each categories as category}
        <option value="{category}">{category}</option>
        {/each}
    </datalist>

    <!-- Using HTML list (ul > li) instead of divs -->
    <ul class="category" id="category--user">
        {#each userEnteredCategories as userEnteredCategory, i}
        <li class="user-category">

            <!-- See issue 2 -->
            <button
                type="button"
                class="user-category__remove"
                on:click={deleteUserCategory}
                data-value="{userEnteredCategory}"
            >
                remove category {userEnteredCategory}
                <!-- See issue 3 -->
            </button>

            <label class="user-category__name" for="{userEnteredCategory}_{i}">
                {userEnteredCategory}
            </label>
            <input
                type="checkbox"
                name="category"
                id="{userEnteredCategory}_{i}"
                value="{userEnteredCategory}"
                class="user-category__value hidden"
                checked
            />
        </li>
        {/each}
    </ul>

</fieldset>

I hope this can help you put thing in perspective. I learned a bit of ARIA while searching and writing this. 😅

@MaybeThisIsRu
Copy link
Author

Very well done, @meduzen, thank you!

Just FYI, there really are two sources of data here -- a completely new category that does not exist in the datalist, and a category that does exist in the datalist because the user has previously entered it while creating a new post and the server returned it to us the next time the user opens a post form.

I skimmed through ARIA live regions and it may be useful here in some capacity, but I'll need to figure out how exactly. I would love to be able to clarify to a screen-reader that a new category has been "added" once they hit Enter. So it does seem worth a go, thank you!

Presenting a list of checkboxes is something I'd rather not do at this stage, but maybe in the future? Some people on the IndieWeb have admitted to having 3000-4000 categories (and even more...). There's also plenty of ongoing development around the query API so I'd rather just rely on a datalist until this matures further -- along with Celestial so I have more time to devote to niche decisions like these. :)

A fieldset and a label do exist -- they're just not a part of the Svelte component because they do not change based on user input. The Svelte component only has the dynamic HTML portion. However, I think I did use fieldset incorrectly here, so I'll correct that. 😉 Also good tip on the type="button" attribute, didn't realize that. 😅 Agreed on using lists instead.

For the "x" button, this is what gov.uk has done:

<div class="facet-tag">
  <p class="facet-tag__text">Academy for Social Justice</p>
  <button type="button" class="facet-tag__remove" aria-label="Remove filter Academy for Social Justice" data-module="remove-filter-link" data-track-label="Academy for Social Justice" data-facet="organisations" data-value="academy-for-social-justice" data-name=""></button> </div>

It appears they concluded an aria-label is sufficient for the button. I do want to use this because the space is so limited (at least for now) in this area of the page. What do you think? An aria-label won't be read out to the average user though...

P.s. I'm also no good with ARIA, unfortunately.

@meduzen
Copy link

meduzen commented Aug 25, 2020

Oh, I skipped the part where it has to create a new category, indeed. That makes it more complex. 😅

I’m not fan of a x as content for the button, but thinking twice, their solution is okay-ish because :

  • they properly ARIA-ed it, I think;
  • it’s not a the x letter, it’s a (multiplication sign) but I guess most humans won’t read it “multiplication sign”. 😄

So, to sum up: adding aria-label will more likely be the right way to do it. 👍

What I usually do in that situation is this (but I’m gonna reconsider):

<button>
    <span class="visually-hidden>real button label</span <!-- this is read by screen readers -->
    <svg></svg> <!-- fancy “x” icon -->
</button>

I have this CSS utility class in all projects, which is the ultimate way to hide content visually, but not for screen readers.

.visually-hidden:not(:focus):not(:active) {
  position: absolute;
  size: 1px 1px;
  margin: -1px;
  padding: 0;

  clip: rect(0 0 0 0);
  clip-path: inset(100%);
  clip-path: polygon(0 0, 0 0, 0 0, 0 0);
  overflow: hidden;

  border: 0;

  white-space: nowrap;
}

This way there’s no doubt about the semantic vs a x or whatever else label we could have, for both readers and maintainers. I often use it to hide headings that are not shown but should structurally exist in the DOM for understanding, like:

<section>
    <h2 class="visually-hidden">title that is not visible but still read by screen readers</h2>
    <p>some content</p>
</section>

However, Scott O’Hara explains when we should use ARIA and when we should use something else like this class, and I’m probably not using it correctly in some (most?) cases. 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment