Skip to content

Commit

Permalink
BC-7520 - a11y messages (#3295)
Browse files Browse the repository at this point in the history
Implement some logic to ensure that aria-live messages, that are send in polite mode, do not interrupt the user, while typing some text into a text-element.

The problem occurs because the CK5-Editor is not recognized as an input field by the browsers and by that the user interactions in it are not handled the same way as if he/she would type in a normal textfield.

The solution that was implemented...
- switches the notifier into a queueing mode that collects all messages that are generated
- considers every key being pressed in the CK5-Editor as an interaction that should delay the notifications
- switches back to write mode after 1500ms have passed without the user pressing any key in the CK5-Editor
- writes all messages when being in write-mode

---------

Co-authored-by: Murat Merdoglu <murat.merdoglu@dataport.de>
  • Loading branch information
hoeppner-dataport and muratmerdoglu-dp committed Jun 25, 2024
1 parent 9d6e81c commit fecb37c
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 24 deletions.
96 changes: 81 additions & 15 deletions src/composables/ariaLiveNotifier.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,94 @@
import { computed, ref } from "vue";
import { useDebounceFn } from "@vueuse/core";

type Importance = "polite" | "assertive";
let mode: "write" | "queue" = "write";
const notifications = ref({
polite: <string[]>[],
assertive: <string[]>[],
});
let handle: NodeJS.Timeout | null = null;

export const useAriaLiveNotifier = () => {
const numberOfNotifications = computed(
() =>
notifications.value["polite"].length +
notifications.value["assertive"].length
);

const notifyOnScreenReader = (
message: string,
importance: "off" | "polite" | "assertive" = "polite"
importance: Importance = "polite"
) => {
// should be a div with aria-live="polite | assertive" attribute
// and should be appended to the upper level of the DOM tree
// the aria-live attribute should be set polite or assertive based on the importance of the message
const element = document.getElementById(
importance === "polite"
? "notify-screen-reader-polite"
: "notify-screen-reader-assertive"
);
notifications.value[importance].push(message);
handleNotificationWriting();
};

if (!element) {
console.error(
`Element with id 'notify-screen-reader-${importance}' not found`
);
return;
const ensurePoliteNotifications = () => {
mode = "queue";
processNotificationsDebounced();
};

const processNotificationsDebounced = useDebounceFn(
() => {
mode = "write";
handleNotificationWriting(); // explicit call needed for test
},
1500,
{ maxWait: 30000 }
);

const handleNotificationWriting = () => {
if (numberOfNotifications.value > 0) {
if (mode === "write") {
stopPeriodicRetry();
writeAllNotifications();
return;
} else {
startPeriodicRetry();
}
}
};

const startPeriodicRetry = () => {
if (handle === null) {
handle = setInterval(handleNotificationWriting, 1000);
}
};

const stopPeriodicRetry = () => {
if (handle) {
clearInterval(handle);
handle = null;
}
};

const writeAllNotifications = () => {
writeNotifications("polite");
writeNotifications("assertive");
};

const writeNotifications = (importance: Importance) => {
const element = getElement(importance);

if (element && notifications.value[importance].length > 0) {
element.innerHTML = notifications.value[importance]
.map((m) => `<span>${m}</span>`)
.join("");
notifications.value[importance] = [];
}
};

const getElement = (importance: Importance): HTMLElement | null => {
const element = document.getElementById(
`notify-screen-reader-${importance}`
);

element.innerHTML += `<span>${message}</span>`;
return element;
};

return {
notifyOnScreenReader,
ensurePoliteNotifications,
};
};
29 changes: 27 additions & 2 deletions src/composables/ariaLiveNotifier.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ describe("useAriaLiveNotifier", () => {
<div id="notify-screen-reader-polite"></div>
<div id="notify-screen-reader-assertive"></div>
</div>`;

jest.useFakeTimers();
});

it("should notify on screen reader on 'aria-live=assertive' mode", () => {
jest.useFakeTimers();
const { notifyOnScreenReader } = useAriaLiveNotifier();
const element = document.getElementById("notify-screen-reader-assertive");
const message = "Assertive screen reader message";
Expand All @@ -20,7 +22,6 @@ describe("useAriaLiveNotifier", () => {
});

it("should notify on screen reader on 'aria-live=polite' mode", () => {
jest.useFakeTimers();
const { notifyOnScreenReader } = useAriaLiveNotifier();
const element = document.getElementById("notify-screen-reader-polite");
const message = "Polite screen reader message";
Expand All @@ -29,4 +30,28 @@ describe("useAriaLiveNotifier", () => {
jest.advanceTimersByTime(3000);
expect(element?.innerHTML).toBe(`<span>${message}</span>`);
});

describe("ensurePoliteNotifications", () => {
describe("when politeNotifications are ensured", () => {
it("should notify all collected messages after some time without user interaction", () => {
const { notifyOnScreenReader, ensurePoliteNotifications } =
useAriaLiveNotifier();
const element = document.getElementById("notify-screen-reader-polite");
const message1 = "Polite screen reader message 1";
const message2 = "Polite screen reader message 2";

ensurePoliteNotifications();
notifyOnScreenReader(message1, "polite");
notifyOnScreenReader(message2, "polite");

expect(element?.innerHTML).toBe("");

jest.advanceTimersByTime(3000);

expect(element?.innerHTML).toBe(
`<span>${message1}</span><span>${message2}</span>`
);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const useBoardSocketApi = () => {
),
];

const ariaLiveNotification = [
const ariaLiveNotifications = [
on(BoardActions.createCardSuccess, notifyCreateCardSuccess),
on(CardActions.deleteCardSuccess, notifyDeleteCardSuccess),
on(BoardActions.createColumnSuccess, notifyCreateColumnSuccess),
Expand All @@ -93,7 +93,7 @@ export const useBoardSocketApi = () => {
action,
...successActions,
...failureActions,
...ariaLiveNotification,
...ariaLiveNotifications,
on(BoardActions.disconnectSocket, disconnectSocketRequest)
);
};
Expand Down
23 changes: 20 additions & 3 deletions src/modules/feature/board-text-element/RichTextContentElement.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
class="rich_text"
:autofocus="autofocus"
:value="modelValue.text"
@update:value="($event) => (modelValue.text = $event)"
@update:value="onUpdateElement"
@delete:element="onDeleteElement"
@blur="() => (autofocus = false)"
@blur="onBlur"
@keyup.capture="onKeyUp"
/>
</div>
</template>
Expand All @@ -23,6 +24,7 @@ import { useBoardFocusHandler, useContentElementState } from "@data-board";
import { defineComponent, PropType, ref, toRef } from "vue";
import RichTextContentElementDisplay from "./RichTextContentElementDisplay.vue";
import RichTextContentElementEdit from "./RichTextContentElementEdit.vue";
import { useAriaLiveNotifier } from "@/composables/ariaLiveNotifier";
export default defineComponent({
name: "RichTextContentElement",
Expand All @@ -40,6 +42,8 @@ export default defineComponent({
emits: ["delete:element"],
setup(props, { emit }) {
const { modelValue } = useContentElementState(props);
const { ensurePoliteNotifications } = useAriaLiveNotifier();
const autofocus = ref(false);
const element = toRef(props, "element");
useBoardFocusHandler(element.value.id, ref(null), () => {
Expand All @@ -50,10 +54,23 @@ export default defineComponent({
emit("delete:element", element.value.id);
};
const onUpdateElement = (text: string) => {
modelValue.value.text = text;
};
const onBlur = () => {
autofocus.value = false;
};
const onKeyUp = () => ensurePoliteNotifications();
return {
autofocus,
modelValue,
onBlur,
onDeleteElement,
autofocus,
onKeyUp,
onUpdateElement,
};
},
});
Expand Down
2 changes: 1 addition & 1 deletion src/modules/feature/board/card/CardTitle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default defineComponent({
props: {
value: {
type: String,
required: true,
default: "",
},
isEditMode: {
type: Boolean,
Expand Down
9 changes: 8 additions & 1 deletion src/modules/feature/editor/CKEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ export default defineComponent({
components: {
ckeditor: CKEditor.component,
},
emits: ["ready", "focus", "update:value", "blur", "keyboard:delete"],
emits: [
"ready",
"focus",
"update:value",
"blur",
"keyboard",
"keyboard:delete",
],
props: {
value: {
type: String,
Expand Down

0 comments on commit fecb37c

Please sign in to comment.