@uniflowed/web 0.0.0-alpha.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cookie.js +144 -0
- package/head.js +132 -0
- package/index.js +96 -0
- package/media.js +637 -0
- package/package.json +31 -0
- package/regions.js +155 -0
- package/time.js +187 -0
- package/vitals.js +721 -0
package/regions.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/web/regions`: the elements that exist for a screen reader.
|
|
4
|
+
//
|
|
5
|
+
// A single-page application breaks two things a browser does for free. It
|
|
6
|
+
// navigates without a page load, so nothing announces that the page changed;
|
|
7
|
+
// and it updates regions in place, so a reader who is not looking at that part
|
|
8
|
+
// of the screen is never told.
|
|
9
|
+
//
|
|
10
|
+
// Both are one element with the right ARIA attributes, and both are easy to
|
|
11
|
+
// get subtly wrong — which is why they are components rather than advice.
|
|
12
|
+
//
|
|
13
|
+
// # What belongs in this module
|
|
14
|
+
//
|
|
15
|
+
// The two kinds of region a document has for assistive technology: landmark
|
|
16
|
+
// regions, which say what a part of the page *is* (`Page`, `Layout`) or how to
|
|
17
|
+
// reach one (`SkipLink`), and live regions, which say that something in it
|
|
18
|
+
// changed (`Announcer`). The test is whether a sighted reader would notice its
|
|
19
|
+
// absence — if only a screen reader would, it is here.
|
|
20
|
+
//
|
|
21
|
+
// Not here: a widget that carries ARIA alongside a visual job. A dialog, a tab
|
|
22
|
+
// list or a menu is `@uniflowed/ui`'s, and comes with focus management and
|
|
23
|
+
// keyboard behaviour this module deliberately has no opinion about. The
|
|
24
|
+
// elements here render markup and nothing else; that is why they can be used
|
|
25
|
+
// on a page that has no design system at all.
|
|
26
|
+
|
|
27
|
+
import * as React from "@uniflowed/react";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The id `Page` renders and `SkipLink` points at.
|
|
31
|
+
*
|
|
32
|
+
* One constant rather than two string literals, because the two have to agree
|
|
33
|
+
* and a skip link that points at nothing fails silently — the focus simply
|
|
34
|
+
* stays where it was, and only someone navigating by keyboard ever finds out.
|
|
35
|
+
*/
|
|
36
|
+
const MAIN_ID: string = "uf-main";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How a thing is hidden from eyes but not from a screen reader.
|
|
40
|
+
*
|
|
41
|
+
* `display: none` and `visibility: hidden` both take an element out of the
|
|
42
|
+
* accessibility tree as well, which is the usual way this is got wrong. Inline
|
|
43
|
+
* rather than a class, because a component in a library cannot assume the
|
|
44
|
+
* application has loaded any particular stylesheet.
|
|
45
|
+
*/
|
|
46
|
+
const VISUALLY_HIDDEN = {
|
|
47
|
+
position: "absolute",
|
|
48
|
+
width: "1px",
|
|
49
|
+
height: "1px",
|
|
50
|
+
margin: "-1px",
|
|
51
|
+
padding: 0,
|
|
52
|
+
overflow: "hidden",
|
|
53
|
+
// Both spellings: `clip` is deprecated and still the one older screen
|
|
54
|
+
// readers honour.
|
|
55
|
+
clip: "rect(0 0 0 0)",
|
|
56
|
+
clipPath: "inset(50%)",
|
|
57
|
+
whiteSpace: "nowrap",
|
|
58
|
+
border: 0,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A live region: text put here is read out when it changes.
|
|
63
|
+
*
|
|
64
|
+
* `polite` waits for the reader to finish what they are saying, which is right
|
|
65
|
+
* for everything except an error the reader must hear now. `assertive`
|
|
66
|
+
* interrupts, and interrupting someone mid-sentence to say "saved" is why it is
|
|
67
|
+
* not the default.
|
|
68
|
+
*
|
|
69
|
+
* The element is always rendered, empty or not. A live region that appears at
|
|
70
|
+
* the same moment as its text is not announced — the screen reader has to be
|
|
71
|
+
* watching it *before* it changes — which is the mistake that makes an
|
|
72
|
+
* announcer look like it does nothing.
|
|
73
|
+
*/
|
|
74
|
+
export component Announcer(
|
|
75
|
+
children?: React.Node,
|
|
76
|
+
urgency?: "polite" | "assertive" = "polite",
|
|
77
|
+
atomic?: boolean = true,
|
|
78
|
+
) renders React.Node {
|
|
79
|
+
return (
|
|
80
|
+
<div aria-live={urgency} aria-atomic={atomic} style={VISUALLY_HIDDEN}>
|
|
81
|
+
{children}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The page's main content, as a landmark.
|
|
88
|
+
*
|
|
89
|
+
* `<main>` is what "skip to content" skips to and what a screen reader's
|
|
90
|
+
* landmark list offers, and there must be exactly one per document — which is
|
|
91
|
+
* why this is not something a layout should also render.
|
|
92
|
+
*/
|
|
93
|
+
export component Page(children?: React.Node, className?: string) renders React.Node {
|
|
94
|
+
return (
|
|
95
|
+
<main id={MAIN_ID} className={className}>
|
|
96
|
+
{children}
|
|
97
|
+
</main>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* "Skip to content": the first thing a keyboard reaches on the page.
|
|
103
|
+
*
|
|
104
|
+
* Without it, tabbing into a page means tabbing through the whole navigation
|
|
105
|
+
* before reaching a word of the content — on every route, every time. It is
|
|
106
|
+
* the cheapest accessibility fix there is and the one most often missing,
|
|
107
|
+
* because it is invisible when it works and so nobody notices it is absent.
|
|
108
|
+
*
|
|
109
|
+
* Render it first inside the layout, before the navigation. It is hidden until
|
|
110
|
+
* it takes focus and then shows itself, which is why it does not cost a
|
|
111
|
+
* design anything: the only person who ever sees it is the person using it.
|
|
112
|
+
*/
|
|
113
|
+
export component SkipLink(children?: React.Node = "Skip to content") renders React.Node {
|
|
114
|
+
const [focused, setFocused] = React.useState(false);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<a
|
|
118
|
+
href={`#${MAIN_ID}`}
|
|
119
|
+
onFocus={() => setFocused(true)}
|
|
120
|
+
onBlur={() => setFocused(false)}
|
|
121
|
+
style={
|
|
122
|
+
focused
|
|
123
|
+
? {
|
|
124
|
+
position: "absolute",
|
|
125
|
+
top: "0.5rem",
|
|
126
|
+
left: "0.5rem",
|
|
127
|
+
// Above anything a page is likely to have stacked, since the
|
|
128
|
+
// link has to be readable wherever it lands.
|
|
129
|
+
zIndex: 1000,
|
|
130
|
+
padding: "0.5rem 0.75rem",
|
|
131
|
+
background: "Canvas",
|
|
132
|
+
color: "CanvasText",
|
|
133
|
+
// `CanvasText` follows the reader's own light or dark setting,
|
|
134
|
+
// so the link stays legible without knowing the page's theme.
|
|
135
|
+
border: "1px solid CanvasText",
|
|
136
|
+
borderRadius: "0.25rem",
|
|
137
|
+
}
|
|
138
|
+
: VISUALLY_HIDDEN
|
|
139
|
+
}
|
|
140
|
+
>
|
|
141
|
+
{children}
|
|
142
|
+
</a>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The frame around a page: whatever is the same between routes.
|
|
148
|
+
*
|
|
149
|
+
* A plain grouping element rather than a landmark, because the landmarks
|
|
150
|
+
* inside it — `<nav>`, `<main>`, `<footer>` — are the ones a reader navigates
|
|
151
|
+
* by, and wrapping them in another one adds a level to walk through.
|
|
152
|
+
*/
|
|
153
|
+
export component Layout(children?: React.Node, className?: string) renders React.Node {
|
|
154
|
+
return <div className={className}>{children}</div>;
|
|
155
|
+
}
|
package/time.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/web/time`: rendering an instant.
|
|
4
|
+
//
|
|
5
|
+
// The whole difficulty is that a server and a browser are in different places.
|
|
6
|
+
// `new Date().toLocaleString()` on a server in UTC and in a browser in Tokyo
|
|
7
|
+
// produce different text, React notices during hydration, and the fix people
|
|
8
|
+
// reach for — rendering nothing until an effect runs — makes the date invisible
|
|
9
|
+
// to a crawler and shifts the layout when it appears.
|
|
10
|
+
//
|
|
11
|
+
// So `Time` formats deterministically by default: the same string on both
|
|
12
|
+
// sides, chosen by the caller rather than by the environment. A caller who
|
|
13
|
+
// genuinely wants the reader's locale asks for it, and gets a component that
|
|
14
|
+
// renders the deterministic form first and upgrades after hydration — which is
|
|
15
|
+
// the honest version of what the naive code was trying to do.
|
|
16
|
+
//
|
|
17
|
+
// # Temporal, not `Date`
|
|
18
|
+
//
|
|
19
|
+
// This component is built on `@uniflowed/core/temporal` and the choice is not
|
|
20
|
+
// cosmetic. `Date` has one time zone — whichever the machine is set to — and
|
|
21
|
+
// the two machines here are set to different ones, which is the entire problem
|
|
22
|
+
// restated. There is no way to write "six in the morning UTC, shown as three in
|
|
23
|
+
// the afternoon in Tokyo" with a `Date` without going through the host's zone
|
|
24
|
+
// on the way, and going through the host's zone is what makes the two renders
|
|
25
|
+
// disagree. A `Temporal.Instant` carries no zone and a `Temporal.ZonedDateTime`
|
|
26
|
+
// carries the zone it was asked for, so the server's text is a function of the
|
|
27
|
+
// instant and a zone name rather than of the machine.
|
|
28
|
+
//
|
|
29
|
+
// The zone the deterministic text is rendered in comes from the render itself —
|
|
30
|
+
// `RenderProvider` fixes it and the markup carries it — so the browser
|
|
31
|
+
// reproduces the server's string exactly, and *then* moves to the reader's own
|
|
32
|
+
// zone. Without a provider the deterministic zone is UTC, which is unambiguous
|
|
33
|
+
// and the same everywhere, rather than the host's, which is neither.
|
|
34
|
+
//
|
|
35
|
+
// # What belongs in this module
|
|
36
|
+
//
|
|
37
|
+
// Anything whose difficulty is that the two renders are in different places or
|
|
38
|
+
// at different moments: a formatted date, a duration, a countdown, a
|
|
39
|
+
// timezone-aware label. The test is whether hydration would notice a
|
|
40
|
+
// difference.
|
|
41
|
+
//
|
|
42
|
+
// Not here: a hook that reads a clock to schedule work — `useInterval` and
|
|
43
|
+
// `useTimeout` are `@uniflowed/hooks`'s, and this module renders a value
|
|
44
|
+
// rather than driving one. Locale-formatted numbers and currency have the same
|
|
45
|
+
// hydration problem and would be a module of their own rather than a second
|
|
46
|
+
// subject inside this one, because nothing about a price is about an instant.
|
|
47
|
+
|
|
48
|
+
import * as React from "@uniflowed/react";
|
|
49
|
+
import type { Instant } from "@uniflowed/core/temporal";
|
|
50
|
+
import { Temporal } from "@uniflowed/core/temporal";
|
|
51
|
+
import { useRenderEnvelope } from "@uniflowed/hooks/render";
|
|
52
|
+
|
|
53
|
+
/** Whatever a caller has an instant written as. */
|
|
54
|
+
export type TimeValue = Instant | Date | string | number;
|
|
55
|
+
|
|
56
|
+
/** What to show. Everything but `local` and `relative` is the same everywhere. */
|
|
57
|
+
export type TimeFormat =
|
|
58
|
+
/** `2026-09-04T06:00:00Z`. Unambiguous, and the same on every machine. */
|
|
59
|
+
| "iso"
|
|
60
|
+
/** `2026-09-04`. The calendar date in the render's zone. */
|
|
61
|
+
| "date"
|
|
62
|
+
/** `2026-09-04 15:00 +09:00`. The wall clock in the render's zone. */
|
|
63
|
+
| "zoned"
|
|
64
|
+
/** The reader's own locale and zone, applied after hydration. */
|
|
65
|
+
| "local"
|
|
66
|
+
/** "3 minutes ago", relative to now, after hydration. */
|
|
67
|
+
| "relative";
|
|
68
|
+
|
|
69
|
+
/** Milliseconds in each unit, largest first, for the relative form. */
|
|
70
|
+
const UNITS: $ReadOnlyArray<[Intl$RelativeTimeFormatUnit, number]> = [
|
|
71
|
+
["year", 31_536_000_000],
|
|
72
|
+
["month", 2_592_000_000],
|
|
73
|
+
["day", 86_400_000],
|
|
74
|
+
["hour", 3_600_000],
|
|
75
|
+
["minute", 60_000],
|
|
76
|
+
["second", 1_000],
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whatever the caller passed, as a `Temporal.Instant`.
|
|
81
|
+
*
|
|
82
|
+
* A string must carry an offset, because Temporal refuses one that does not and
|
|
83
|
+
* this component must not be more permissive than the standard it is built on:
|
|
84
|
+
* `"2026-09-04"` is a date and not an instant, and every implementation that
|
|
85
|
+
* has guessed which midnight it meant has guessed differently.
|
|
86
|
+
*/
|
|
87
|
+
export function asInstant(value: TimeValue): Instant {
|
|
88
|
+
if (value instanceof Date) {
|
|
89
|
+
return Temporal.Instant.fromEpochMilliseconds(value.getTime());
|
|
90
|
+
}
|
|
91
|
+
if (typeof value === "number") {
|
|
92
|
+
return Temporal.Instant.fromEpochMilliseconds(value);
|
|
93
|
+
}
|
|
94
|
+
if (typeof value === "string") {
|
|
95
|
+
return Temporal.Instant.from(value);
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The text that is the same on a server and in a browser. */
|
|
101
|
+
function stable(at: Instant, format: TimeFormat, zone: string): string {
|
|
102
|
+
if (format === "iso") {
|
|
103
|
+
return at.toString();
|
|
104
|
+
}
|
|
105
|
+
const zoned = at.toZonedDateTimeISO(zone);
|
|
106
|
+
if (format === "date") {
|
|
107
|
+
return zoned.toPlainDate().toString();
|
|
108
|
+
}
|
|
109
|
+
// `local` and `relative` start here too: it is the most readable form that
|
|
110
|
+
// still says exactly which instant it is, which is what a reader with no
|
|
111
|
+
// JavaScript and a crawler both end up with.
|
|
112
|
+
const time = `${String(zoned.hour).padStart(2, "0")}:${String(zoned.minute).padStart(2, "0")}`;
|
|
113
|
+
return `${zoned.toPlainDate().toString()} ${time} ${zoned.offset}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* "3 minutes ago", or "in 3 minutes".
|
|
118
|
+
*
|
|
119
|
+
* `from` defaults to the injected clock rather than to `new Date()`, so a test
|
|
120
|
+
* can decide what "now" is and a server render is reproducible. Both arguments
|
|
121
|
+
* take anything `Time` takes.
|
|
122
|
+
*/
|
|
123
|
+
export function relative(at: TimeValue, from?: TimeValue): string {
|
|
124
|
+
const target = asInstant(at);
|
|
125
|
+
const origin = from === undefined ? Temporal.Now.instant() : asInstant(from);
|
|
126
|
+
const difference = target.epochMilliseconds - origin.epochMilliseconds;
|
|
127
|
+
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
|
128
|
+
for (const [unit, span] of UNITS) {
|
|
129
|
+
if (Math.abs(difference) >= span) {
|
|
130
|
+
return formatter.format(Math.round(difference / span), unit);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Under a second in either direction is "now", not "in 0 seconds".
|
|
134
|
+
return formatter.format(0, "second");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* An instant, as a `<time>` element.
|
|
139
|
+
*
|
|
140
|
+
* The machine-readable value is always in `dateTime`, whatever the text says,
|
|
141
|
+
* so a crawler and a screen reader get the exact instant even when a reader
|
|
142
|
+
* sees "3 minutes ago".
|
|
143
|
+
*
|
|
144
|
+
* `iso`, `date` and `zoned` render the same string on both sides and never
|
|
145
|
+
* change. `local` and `relative` render the deterministic form first and
|
|
146
|
+
* replace it after hydration — the text is there for the first paint and for
|
|
147
|
+
* anything that does not run JavaScript, and it becomes the reader's own format
|
|
148
|
+
* once it can.
|
|
149
|
+
*
|
|
150
|
+
* `zone` is the zone the deterministic forms are written in. It defaults to the
|
|
151
|
+
* one the render fixed, so a page under a `RenderProvider` shows its server's
|
|
152
|
+
* zone until it can show the reader's; with no provider it is UTC, because a
|
|
153
|
+
* default that reads the host would be a different string on each side and
|
|
154
|
+
* would defeat the entire component.
|
|
155
|
+
*/
|
|
156
|
+
export component Time(
|
|
157
|
+
value: TimeValue,
|
|
158
|
+
format?: TimeFormat = "iso",
|
|
159
|
+
zone?: string,
|
|
160
|
+
locale?: string,
|
|
161
|
+
className?: string,
|
|
162
|
+
) renders React.Node {
|
|
163
|
+
const at = asInstant(value);
|
|
164
|
+
const machine = at.toString();
|
|
165
|
+
const rendered = useRenderEnvelope();
|
|
166
|
+
const server = stable(at, format, zone ?? rendered?.timeZone ?? "UTC");
|
|
167
|
+
|
|
168
|
+
// Starts at the deterministic text on both sides, so hydration matches; the
|
|
169
|
+
// effect below is what makes it the reader's.
|
|
170
|
+
const [text, setText] = React.useState(server);
|
|
171
|
+
|
|
172
|
+
React.useEffect(() => {
|
|
173
|
+
if (format === "local") {
|
|
174
|
+
setText(at.toZonedDateTimeISO(Temporal.Now.timeZoneId()).toLocaleString(locale));
|
|
175
|
+
} else if (format === "relative") {
|
|
176
|
+
setText(relative(at));
|
|
177
|
+
}
|
|
178
|
+
// `machine` rather than `at`: a caller passing a string builds a new instant
|
|
179
|
+
// every render, and depending on the object would re-run this forever.
|
|
180
|
+
}, [machine, format, locale]);
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<time dateTime={machine} className={className}>
|
|
184
|
+
{text}
|
|
185
|
+
</time>
|
|
186
|
+
);
|
|
187
|
+
}
|