Self-service settings
A model-driven React settings area, deep-linkable and accessible, that became the foundation for later preference features.
- tech stack
- my role
- Engineered the front end
- timeline
- About three months, 2024
I built this for my employer, so there’s no public demo or repo. I’m happy to walk through it in more detail in an interview.
The problem
A learning platform kept its user settings in a cramped modal, and some preferences people reasonably expected to control weren’t there at all. Each new preference meant bespoke interface work. The platform needed a proper settings area that people could find, link to and use with a keyboard or screen reader, and that colleagues could keep adding to.
My approach
My team was involved from the design stage through to implementation, and I engineered the front end: one shared implementation, used identically wherever the settings appear.
The front end is model-driven. Each setting is described as data, with a kind, and the interface picks the right control from that kind. A new preference of a kind we already support doesn’t need a new control:
type Setting =
| { name: string; label: string; kind: "toggle"; value: boolean }
| { name: string; label: string; kind: "choice"; value: string; options: string[] };
interface Props {
setting: Setting;
onChange: (name: string, value: boolean | string) => void;
}
export const SettingField = ({ setting, onChange }: Props) => {
switch (setting.kind) {
case "toggle":
return (
<Toggle
label={setting.label}
checked={setting.value}
onChange={(value) => onChange(setting.name, value)}
/>
);
case "choice":
return (
<RadioGroup
label={setting.label}
options={setting.options}
value={setting.value}
onChange={(value) => onChange(setting.name, value)}
/>
);
}
}; The controls come from the shared design system. I built new radio and radio group components for it for this work, and they’ve been reused widely since.
The settings area is a sidebar of sections beside the active one. On narrow screens the sidebar becomes the whole page and choosing a section replaces it, and that layout switch is CSS on a single component tree. The sidebar marks the open section with aria-current="page".
Saving and other feedback appear in one notice region. The notices come from separate sources, so I merged them into a single RxJS stream: they can’t stack up or race each other, and each one is shown and announced to screen readers at the same time. Dismissing a notice puts focus back in the settings section, so it isn’t left stranded on the page.
Challenges
The trickiest part was navigation. Support staff and help pages needed to link someone straight to a section, and the browser’s back and forward buttons had to behave. So the open section lives in the URL, not in component state:
export const openView = (view: string, { replace = false } = {}): void => {
const url = new URL(window.location.href);
url.searchParams.set("view", view);
if (replace) {
window.history.replaceState(null, "", url);
} else {
window.history.pushState(null, "", url);
}
}; Choosing a section pushes a history entry, because it’s a choice someone might want to go back from. Correcting a missing or invalid section replaces the entry, so nobody can press back into a broken state. A popstate listener re-reads the URL whenever history changes.
Deep links made the Back button ambiguous on narrow screens. Back from a section usually means “show me the menu”, but if someone arrived on a section from a link there’s no menu behind them, and Back should leave the page. The settings area remembers how someone arrived and does what they meant.
The skip link needed care too. The page’s heading only exists once the settings have loaded, so the skip link had nothing to point at when the page first rendered. The settings area now fires an event once its heading is in place, and the skip link listens for it. The heading takes focus without showing a ring to mouse users, because its outline is styled with :focus-visible.
lesson learnt
Let the URL hold the view
Putting the open section in the URL gave us shareable links and working back and forward buttons for free. The only real decision was which changes deserve a history entry.
Outcome
The settings area, covered by about 176 tests, became the foundation for the preference features that followed, with colleagues adding notification and other preferences on top. On the server side, adding a setting is a small change. A new tab in the settings area takes more front-end work, so the model makes the common case cheap but not every case.