**PR Summary**: Label the New Shortcut dialog for accessibility (Bug 1997388)
**Problem**:
The "Add Shortcut" dialog in Firefox’s New Tab page was not labeled for screen readers. This meant assistive technology users only heard "dialog" with no context, making the dialog inaccessible.
**Changes Made:**
Moved aria-labelledby to the <dialog> element in ModalOverlayWrapper:
**Why**: The accessible name for a dialog must be on the dialog element itself, not on a child. This ensures screen readers announce the dialog with a meaningful label.
What was changed: The aria-labelledby attribute was moved from an inner div to the <dialog> element, so the dialog is properly labeled.
Added an id to the TopSiteForm heading:
**Why**: The dialog’s aria-labelledby needs to reference a visible heading that describes the dialog’s purpose. Adding an id to the heading allows it to be referenced directly.
What was changed: The <h3> heading in TopSiteForm was given a unique id.
Passed the heading id as headerId to ModalOverlayWrapper:
**Why**: To connect the dialog’s aria-labelledby to the correct heading, the id was passed as a prop from TopSites to ModalOverlayWrapper.
What was changed: The headerId prop was threaded through the relevant components.
No code was deleted except for moving the aria-labelledby attribute:
**Why**: The only removal was to prevent duplicate or misplaced aria-labelledby attributes, ensuring there is a single, correct accessible label.
Testing Performed:
**Tested**:
Verified in the accessibility panel that the dialog now has an accessible name ("New Shortcut") when opened.
Confirmed that screen readers announce the dialog with the correct label.
Built and ran Firefox to ensure the dialog still functions as expected for all users.
Ensured only the three relevant source files were changed, with no changes to generated files or lockfiles.
**Result**:
The dialog is now properly labeled for assistive technology, improving accessibility and user experience for all users.
Differential Revision: https://phabricator.services.mozilla.com/D282220
296 lines
8.7 KiB
React
296 lines
8.7 KiB
React
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useDispatch, useSelector } from "react-redux";
|
|
import { ModalOverlayWrapper } from "content-src/components/ModalOverlay/ModalOverlay";
|
|
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
|
|
|
|
const EMOJI_LABELS = {
|
|
business: "💼",
|
|
arts: "🎭",
|
|
food: "🍕",
|
|
health: "🩺",
|
|
finance: "💰",
|
|
government: "🏛️",
|
|
sports: "⚽️",
|
|
tech: "💻",
|
|
travel: "✈️",
|
|
"education-science": "🧪",
|
|
society: "💡",
|
|
};
|
|
|
|
function TopicSelection({ supportUrl }) {
|
|
const dispatch = useDispatch();
|
|
const inputRef = useRef(null);
|
|
const modalRef = useRef(null);
|
|
const checkboxWrapperRef = useRef(null);
|
|
const prefs = useSelector(state => state.Prefs.values);
|
|
const topics = prefs["discoverystream.topicSelection.topics"].split(", ");
|
|
const selectedTopics = prefs["discoverystream.topicSelection.selectedTopics"];
|
|
const suggestedTopics =
|
|
prefs["discoverystream.topicSelection.suggestedTopics"]?.split(", ");
|
|
const displayCount =
|
|
prefs["discoverystream.topicSelection.onboarding.displayCount"];
|
|
const topicsHaveBeenPreviouslySet =
|
|
prefs["discoverystream.topicSelection.hasBeenUpdatedPreviously"];
|
|
const [isFirstRun] = useState(displayCount === 0);
|
|
const displayCountRef = useRef(displayCount);
|
|
const preselectedTopics = () => {
|
|
if (selectedTopics) {
|
|
return selectedTopics.split(", ");
|
|
}
|
|
return isFirstRun ? suggestedTopics : [];
|
|
};
|
|
const [topicsToSelect, setTopicsToSelect] = useState(preselectedTopics);
|
|
|
|
function isFirstSave() {
|
|
// Only return true if the user has not previous set prefs
|
|
// and the selected topics pref is empty
|
|
if (selectedTopics === "" && !topicsHaveBeenPreviouslySet) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function handleModalClose() {
|
|
dispatch(ac.OnlyToMain({ type: at.TOPIC_SELECTION_USER_DISMISS }));
|
|
dispatch(
|
|
ac.BroadcastToContent({ type: at.TOPIC_SELECTION_SPOTLIGHT_CLOSE })
|
|
);
|
|
}
|
|
|
|
function handleUserClose(e) {
|
|
const id = e?.target?.id;
|
|
|
|
if (id === "first-run") {
|
|
dispatch(ac.AlsoToMain({ type: at.TOPIC_SELECTION_MAYBE_LATER }));
|
|
dispatch(
|
|
ac.SetPref(
|
|
"discoverystream.topicSelection.onboarding.maybeDisplay",
|
|
true
|
|
)
|
|
);
|
|
} else {
|
|
dispatch(
|
|
ac.SetPref(
|
|
"discoverystream.topicSelection.onboarding.maybeDisplay",
|
|
false
|
|
)
|
|
);
|
|
}
|
|
handleModalClose();
|
|
}
|
|
|
|
// By doing this, the useEffect that sets up the IntersectionObserver
|
|
// will not re-run every time displayCount changes,
|
|
// but the observer callback will always have access
|
|
// to the latest displayCount value through the ref.
|
|
useEffect(() => {
|
|
displayCountRef.current = displayCount;
|
|
}, [displayCount]);
|
|
|
|
useEffect(() => {
|
|
const { current } = modalRef;
|
|
let observer;
|
|
if (current) {
|
|
observer = new IntersectionObserver(([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
// if the user has seen the modal more than 3 times,
|
|
// automatically remove them from onboarding
|
|
if (displayCountRef.current > 3) {
|
|
dispatch(
|
|
ac.SetPref(
|
|
"discoverystream.topicSelection.onboarding.maybeDisplay",
|
|
false
|
|
)
|
|
);
|
|
}
|
|
observer.unobserve(modalRef.current);
|
|
dispatch(
|
|
ac.AlsoToMain({
|
|
type: at.TOPIC_SELECTION_IMPRESSION,
|
|
})
|
|
);
|
|
}
|
|
});
|
|
observer.observe(current);
|
|
}
|
|
|
|
return () => {
|
|
if (current) {
|
|
observer.unobserve(current);
|
|
}
|
|
};
|
|
}, [modalRef, dispatch]);
|
|
|
|
// when component mounts, set focus to input
|
|
useEffect(() => {
|
|
inputRef?.current?.focus();
|
|
}, [inputRef]);
|
|
|
|
const handleFocus = useCallback(e => {
|
|
const isArrowPressed = e.key === "ArrowUp" || e.key === "ArrowDown";
|
|
|
|
if (
|
|
isArrowPressed &&
|
|
checkboxWrapperRef.current.contains(document.activeElement)
|
|
) {
|
|
e.preventDefault();
|
|
const checkboxElements =
|
|
checkboxWrapperRef.current.querySelectorAll("input");
|
|
const [firstInput] = checkboxElements;
|
|
const lastInput = checkboxElements[checkboxElements.length - 1];
|
|
const inputArr = Array.from(checkboxElements);
|
|
const currentIndex = inputArr.indexOf(document.activeElement);
|
|
let nextEl;
|
|
if (e.key === "ArrowUp") {
|
|
nextEl =
|
|
document.activeElement === firstInput
|
|
? lastInput
|
|
: checkboxElements[currentIndex - 1];
|
|
} else if (e.key === "ArrowDown") {
|
|
nextEl =
|
|
document.activeElement === lastInput
|
|
? firstInput
|
|
: checkboxElements[currentIndex + 1];
|
|
}
|
|
nextEl.tabIndex = 0;
|
|
document.activeElement.tabIndex = -1;
|
|
nextEl.focus();
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const ref = modalRef.current;
|
|
ref.addEventListener("keydown", handleFocus);
|
|
|
|
inputRef.current.tabIndex = 0;
|
|
|
|
return () => {
|
|
ref.removeEventListener("keydown", handleFocus);
|
|
};
|
|
}, [handleFocus]);
|
|
|
|
function handleChange(e) {
|
|
const topic = e.target.name;
|
|
const isChecked = e.target.checked;
|
|
if (isChecked) {
|
|
setTopicsToSelect([...topicsToSelect, topic]);
|
|
} else {
|
|
const updatedTopics = topicsToSelect.filter(t => t !== topic);
|
|
setTopicsToSelect(updatedTopics);
|
|
}
|
|
}
|
|
|
|
function handleSubmit() {
|
|
const topicsString = topicsToSelect.join(", ");
|
|
dispatch(
|
|
ac.SetPref("discoverystream.topicSelection.selectedTopics", topicsString)
|
|
);
|
|
dispatch(
|
|
ac.SetPref(
|
|
"discoverystream.topicSelection.onboarding.maybeDisplay",
|
|
false
|
|
)
|
|
);
|
|
if (!topicsHaveBeenPreviouslySet) {
|
|
dispatch(
|
|
ac.SetPref(
|
|
"discoverystream.topicSelection.hasBeenUpdatedPreviously",
|
|
true
|
|
)
|
|
);
|
|
}
|
|
dispatch(
|
|
ac.OnlyToMain({
|
|
type: at.TOPIC_SELECTION_USER_SAVE,
|
|
data: {
|
|
topics: topicsString,
|
|
previous_topics: selectedTopics,
|
|
first_save: isFirstSave(),
|
|
},
|
|
})
|
|
);
|
|
handleModalClose();
|
|
}
|
|
|
|
return (
|
|
<ModalOverlayWrapper
|
|
onClose={handleUserClose}
|
|
innerClassName="topic-selection-container"
|
|
headerId="topic-selection-title"
|
|
>
|
|
<div className="topic-selection-form" ref={modalRef}>
|
|
<button
|
|
className="dismiss-button"
|
|
title="dismiss"
|
|
onClick={handleUserClose}
|
|
/>
|
|
<h1
|
|
className="title"
|
|
id="topic-selection-title"
|
|
data-l10n-id="newtab-topic-selection-title"
|
|
/>
|
|
<p
|
|
className="subtitle"
|
|
data-l10n-id="newtab-topic-selection-subtitle"
|
|
/>
|
|
<div className="topic-list" ref={checkboxWrapperRef}>
|
|
{topics.map((topic, i) => {
|
|
const checked = topicsToSelect.includes(topic);
|
|
return (
|
|
<label className={`topic-item`} key={topic}>
|
|
<input
|
|
type="checkbox"
|
|
id={topic}
|
|
name={topic}
|
|
ref={i === 0 ? inputRef : null}
|
|
onChange={handleChange}
|
|
checked={checked}
|
|
aria-checked={checked}
|
|
tabIndex={-1}
|
|
/>
|
|
<div className={`topic-custom-checkbox`}>
|
|
<span className="topic-icon">{EMOJI_LABELS[`${topic}`]}</span>
|
|
<span className="topic-checked" />
|
|
</div>
|
|
<span
|
|
className="topic-item-label"
|
|
data-l10n-id={`newtab-topic-label-${topic}`}
|
|
/>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="modal-footer">
|
|
<a
|
|
href={supportUrl}
|
|
data-l10n-id="newtab-topic-selection-privacy-link"
|
|
/>
|
|
<moz-button-group className="button-group">
|
|
<moz-button
|
|
id={isFirstRun ? "first-run" : ""}
|
|
data-l10n-id={
|
|
isFirstRun
|
|
? "newtab-topic-selection-button-maybe-later"
|
|
: "newtab-topic-selection-cancel-button"
|
|
}
|
|
onClick={handleUserClose}
|
|
/>
|
|
<moz-button
|
|
data-l10n-id="newtab-topic-selection-save-button"
|
|
type="primary"
|
|
onClick={handleSubmit}
|
|
/>
|
|
</moz-button-group>
|
|
</div>
|
|
</div>
|
|
</ModalOverlayWrapper>
|
|
);
|
|
}
|
|
|
|
export { TopicSelection };
|