Appearance
A settings page
A settings screen is the smallest realistic thing an application has that is made entirely of controls. Nothing on it is hard on its own, and three decisions decide whether it stays maintainable: where the state lives, how the groups are expressed, and what happens when one setting rules another.
This recipe assumes using components, so that a control taken from the library rather than built by hand is already the obvious move, and cells and bindings for what an internalState costs per frame.
Turn Email digest off and watch Digest frequency go with it. Move the slider, type in the name, then press Reset: one write puts every control back.
The shape first
Before any element, the record the screen is about:
tsx
/** Everything this screen can change, in one shape. */
interface Settings {
readonly displayName: string;
readonly emailDigest: boolean;
readonly digestFrequency: string;
readonly fontSize: number;
readonly wrapLines: boolean;
}
/** What the screen starts as, and what Reset writes back. */
const DEFAULTS: Settings = {
displayName: 'Sam',
emailDigest: true,
digestFrequency: 'weekly',
fontSize: 14,
wrapLines: true
};
const FREQUENCIES: readonly SelectOption[] = [
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' }
];Five fields and one default record. Writing this down first is what makes the rest of the page short: the summary line at the bottom, the Reset button and the digest rule all read the same object, so none of them has to know which control produced a change.
One cell, one reader, one writer
tsx
/**
* The settings, as one cell with one reader and one writer.
*
* A cell per control would work and would be worse: five cells are
* five things to reset, five things to send somewhere when the screen
* saves, and five places a rule that spans two settings has to look.
* One record answers all three, and `field` is what keeps a control
* bound to its own value rather than to the whole object.
*
* `distinctUntilChanged` is load-bearing. Every write replaces the
* record, so without it moving the slider would re-emit the display
* name too, and every control on the screen would rebind on every
* keystroke.
*/
function settingsStore(initial: Settings = DEFAULTS) {
const settings = internalState(initial);
return {
/** The whole record, for anything that needs more than one field. */
settings,
/** One field, and only that field. */
field: <K extends keyof Settings>(key: K): Observable<Settings[K]> =>
settings.pipe(
map(current => current[key]),
distinctUntilChanged()
),
/** The only writer. Every control on the screen goes through it. */
update: <K extends keyof Settings>(key: K, value: Settings[K]): void => {
settings.value = { ...settings.value, [key]: value };
},
reset: (): void => {
settings.value = DEFAULTS;
}
};
}A cell per control would work, and would be worse. Five cells are five things for Reset to write, five things to hand to whatever saves the screen, and five places a rule spanning two settings has to look. One record answers all three.
The cost of a single record is that every write replaces it, so every subscriber hears about every change. field is what contains that: each control binds to its own value through a distinctUntilChanged, so moving the slider does not re-emit the display name and rebind the text field. That is the one line to copy if you take nothing else from this page.
settingsStore is an ordinary function, not a service and not a component. Promote it to a service when a second screen needs the same values, which is what state and services is about; until then a function that returns four things is the whole of it.
Groups that exist for a screen reader
tsx
/**
* A group of settings: a heading, and a panel that says what it is.
*
* The `role` and `label` on the panel are what make the grouping
* exist for anything that is not looking at it. Without them the
* border is a decoration, and a screen reader hears nine controls in
* a row with nothing to say which three belong together.
*/
function Section(title: string, ...controls: UiChild[]) {
return (
<column gap={8}>
<text text={title} fontSize={11} fontWeight={600} color="textMuted" role="heading" />
<column
gap={14}
padding={16}
borderRadius={10}
borderWidth={1}
borderColor="border"
backgroundColor="surface"
role="group"
label={title}>
{controls}
</column>
</column>
);
}
/**
* One setting: the control, and a line under it saying what it does.
*
* The control carries its own `label`, so the name in the semantics
* tree is the name on screen and neither can drift from the other.
* The note is drawn text with no width of its own, so it wraps to the
* panel rather than pushing it wider.
*/
function Setting(control: UiChild, note: string) {
return (
<column gap={4}>
{control}
<text text={note} fontSize={12} color="textMuted" />
</column>
);
}The border and the heading say "these three belong together" to somebody looking at the screen. role="group" with a label says it to everything else, and it is two props rather than a component. The spec checks the grouping through the semantics tree, so a panel that loses its role fails before a reader meets it: see semantics for what lands in that tree and what does not.
Setting puts a note under each control. The note sets no width, so it wraps to the panel instead of widening it; text has the rule that makes that true. The control keeps its own label, so the visible name and the accessible name are the same string and cannot drift apart.
The screen
tsx
/**
* A settings screen: three groups, one cell, and one rule between two
* controls.
*
* Every control here is `gesso-components`, so the label, the focus
* ring, the hover and press states, the keyboard map and the role,
* name and states an assistive technology reads all arrive with it.
* What the screen supplies is the state and the rules.
*
* The rule worth reading is the digest. Turning `Email digest` off
* disables `Digest frequency` rather than leaving it live and
* ignored, so the control says what it is going to do before it is
* touched. Because both fields are in one record, that is one bound
* expression and no coordination between two cells.
*
* Reset is a hand-written `<button>`, and it binds Enter and Space
* itself: nothing in the runtime turns a key on a focused button into
* a click. Every control above it is the library's and needs no such
* line.
*/
export function SettingsScreen(_inputs: Inputs<{}>, _ctx: ComponentContext) {
const { settings, field, update, reset } = settingsStore();
const summary = settings.pipe(
map(
current =>
`${current.displayName}, ${current.fontSize} px, ` +
`${current.emailDigest ? `${current.digestFrequency} digest` : 'no digest'}, ` +
`${current.wrapLines ? 'wrapping' : 'not wrapping'}`
)
);
return (
<column gap={16} padding={20} width={percent(100)} height={percent(100)}>
{Section(
'Account',
Setting(
<TextInput
label="Display name"
value={field('displayName')}
onChange={next => update('displayName', next)}
/>,
'Shown beside anything you publish.'
)
)}
{Section(
'Notifications',
Setting(
<Switch label="Email digest" checked={field('emailDigest')} onChange={next => update('emailDigest', next)} />,
'One message a period instead of one per event.'
),
Setting(
<Select
label="Digest frequency"
options={FREQUENCIES}
value={field('digestFrequency')}
disabled={field('emailDigest').pipe(map(on => !on))}
onChange={next => update('digestFrequency', next)}
/>,
'Disabled while the digest is off, because a control that cannot do anything should say so rather than accept a choice nothing will act on.'
)
)}
{Section(
'Editor',
Setting(
<Slider
label="Font size"
min={11}
max={20}
step={1}
format={asPixels}
value={field('fontSize')}
onChange={next => update('fontSize', next)}
/>,
'Applies to the editor only.'
),
<Switch label="Wrap long lines" checked={field('wrapLines')} onChange={next => update('wrapLines', next)} />
)}
<row gap={12} y="center">
<button
label="Reset to defaults"
onClick={reset}
onKeyDown={keymap({ Enter: reset, ' ': reset })}
padding={9}
borderRadius={6}
borderWidth={1}
borderColor="border"
backgroundColor="background"
cursor="pointer"
modifiers={[HOVER_CONTROL]}>
<text text="Reset to defaults" fontSize={12} color="text" />
</button>
<text text={summary} fontSize={12} color="textMuted" />
</row>
</column>
);
}Three things here are worth stopping on.
Each control is bound, not assigned. value={field('fontSize')} is a subscription, so a write to the record moves the control, whoever made the write. That is what lets Reset be a single assignment rather than a sweep over five controls.
One control disables another in one expression. The Select's disabled prop is the digest switch's own value, negated. No handler coordinates the two, because both fields are in the same record and one of them can simply be read.
Disabled is not decoration. The Select declines the pointer and the keyboard while it is off, and carries disabled on its semantics record, so nothing announces a choice that would go nowhere. The alternative, leaving the control live and ignoring what it reports, is the version that produces a bug report.
Reset binds its own keys. It is a hand-written <button>, and nothing in the runtime turns Enter on a focused button into a click, so it carries keymap({ Enter: reset, ' ': reset }). Every control above it comes from the library and needs no such line, which is most of the argument for using the library. Keyboard operability is where that line is drawn.
Which control for which choice
| The choice | The control |
|---|---|
| On or off, applied immediately | Switch |
| One of three or four, all visible | RadioGroup |
| One of many, or a long list | Select |
| A number on a range | Slider, or NumberInput for an exact one |
| Free text | TextInput |
A Switch and a Checkbox are the same control with different announcements: a screen reader says on and off for one, checked and unchecked for the other. Use the switch for a preference that takes effect as it is flipped, and the checkbox for something that will be submitted later.
What has been checked
The spec beside the example drives every control through the queries an assistive technology uses: the three groups and their membership, the digest rule in both directions, the disabled Select refusing Enter, and Reset restoring all five fields. What it does not cover is drawing. The canvas above is Canvas2D, and Chrome and other Chromium browsers are the extent of what this page has been opened in.
Next
An appearance setting is the same screen with one setting that has to reach outside the application, and a dialog flow is what to do when a setting is destructive.