@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/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
+ }