@cplieger/ui 0.0.0-stage → 1.0.0-dev.1
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/LICENSE +202 -0
- package/NOTICE +3 -0
- package/README.md +157 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/css/ui-primitives.css +344 -0
- package/jsr.json +44 -0
- package/package.json +105 -4
- package/src/actions/api.ts +268 -0
- package/src/actions/async-feedback.ts +217 -0
- package/src/actions/cleanup.ts +66 -0
- package/src/actions/debounce.ts +150 -0
- package/src/actions/define-helpers.ts +87 -0
- package/src/actions/define.ts +851 -0
- package/src/actions/error.ts +161 -0
- package/src/actions/index.ts +60 -0
- package/src/actions/loading.ts +131 -0
- package/src/actions/notifier.ts +61 -0
- package/src/actions/poll-until.ts +133 -0
- package/src/actions/poll.ts +156 -0
- package/src/actions/registry.ts +259 -0
- package/src/actions/retry.ts +79 -0
- package/src/actions/testing.ts +29 -0
- package/src/actions/transport.ts +104 -0
- package/src/actions/types.ts +249 -0
- package/src/dom/bind-list.ts +103 -0
- package/src/dom/el.ts +67 -0
- package/src/dom/index.ts +9 -0
- package/src/dom/reconcile-tree.ts +268 -0
- package/src/dom/reconcile.ts +76 -0
- package/src/fetch/index.ts +18 -0
- package/src/fetch/instance.ts +37 -0
- package/src/fetch/request.ts +437 -0
- package/src/fetch/timeout.ts +67 -0
- package/src/fetch/types.ts +206 -0
- package/src/fetch/verbs.ts +103 -0
- package/src/index.ts +4 -0
- package/src/primitives/announce.ts +134 -0
- package/src/primitives/ask.ts +406 -0
- package/src/primitives/dialog-engine.ts +500 -0
- package/src/primitives/dialog.ts +263 -0
- package/src/primitives/disclosure.ts +287 -0
- package/src/primitives/focus-trap.ts +157 -0
- package/src/primitives/fx.ts +204 -0
- package/src/primitives/live-regions.ts +83 -0
- package/src/primitives/modal-host.ts +214 -0
- package/src/primitives/modal.ts +247 -0
- package/src/primitives/owner.ts +322 -0
- package/src/primitives/popover.ts +500 -0
- package/src/primitives/popup-core.ts +603 -0
- package/src/primitives/popup.ts +23 -0
- package/src/primitives/roving-focus.ts +198 -0
- package/src/primitives/scroll-lock.ts +119 -0
- package/src/primitives/skeleton.ts +142 -0
- package/src/primitives/theme.ts +202 -0
- package/src/primitives/toast/engine.ts +327 -0
- package/src/primitives/toast/index.ts +139 -0
- package/src/primitives/toast/view.ts +244 -0
- package/src/primitives/tooltip.ts +377 -0
- package/src/primitives/transition.ts +119 -0
- package/src/primitives/view-transition.ts +57 -0
- package/src/signal/bus.ts +126 -0
- package/src/signal/collection.ts +182 -0
- package/src/signal/index.ts +34 -0
- package/src/signal/resource.ts +102 -0
- package/src/signal/signal-map.ts +46 -0
- package/src/signal/signal.ts +1002 -0
- package/src/signal/store.ts +84 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Public types for @cplieger/ui/fetch. No imports, no runtime behavior. */
|
|
2
|
+
|
|
3
|
+
/** HTTP verbs the wrapper speaks. */
|
|
4
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A runtime validator for a 2xx response body: returns the typed value or
|
|
8
|
+
* THROWS on a mismatch. Ships no combinators — each consumer supplies its own
|
|
9
|
+
* validators (hand-written, zod, valibot, …).
|
|
10
|
+
*/
|
|
11
|
+
export type Decoder<T> = (value: unknown) => T;
|
|
12
|
+
|
|
13
|
+
/** Configuration for a fetch instance. Captured immutably by
|
|
14
|
+
* {@link createFetch} (shallow-copied and frozen at construction); a changed
|
|
15
|
+
* backend produces a new instance. */
|
|
16
|
+
export interface FetchConfig {
|
|
17
|
+
/** Prepended to relative paths, minus one trailing `/`. With it set,
|
|
18
|
+
* an absolute (`https://…`) or protocol-relative (`//host`) path stays a
|
|
19
|
+
* path segment and cannot override the origin. With it UNSET, `path` goes
|
|
20
|
+
* to `fetch()` verbatim, so the caller owns the full URL and must never
|
|
21
|
+
* pass untrusted input as the whole path. */
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
/** `RequestInit.credentials` mode applied to every request (e.g. `"include"`
|
|
24
|
+
* for cookies). */
|
|
25
|
+
credentials?: RequestCredentials;
|
|
26
|
+
/** Custom fetch implementation. Useful for SSR (isomorphic fetch) or tests. */
|
|
27
|
+
fetchFn?: typeof fetch;
|
|
28
|
+
/** Inject headers on every request. Mutate the provided instance and/or
|
|
29
|
+
* return a replacement (a returned `Headers` wins wholesale, otherwise the
|
|
30
|
+
* mutated instance is used). May be async — read late-bound state (a token
|
|
31
|
+
* set after boot) from inside the hook rather than reconfiguring the
|
|
32
|
+
* instance. */
|
|
33
|
+
prepareHeaders?: (headers: Headers) => Headers | undefined | Promise<Headers | undefined>;
|
|
34
|
+
/** Optional cap on the response body size in bytes. Unset or `Infinity`
|
|
35
|
+
* means unlimited. When set, a larger body is rejected instead of buffered.
|
|
36
|
+
* `NaN` or a negative number is rejected by {@link createFetch} with a
|
|
37
|
+
* `TypeError`. */
|
|
38
|
+
maxResponseBytes?: number;
|
|
39
|
+
/** Observes every failure this instance returns, `decode` included, after
|
|
40
|
+
* its `code` is lifted and before the caller receives the same `ApiErr`
|
|
41
|
+
* object. Its return value is ignored and the promise is not awaited; a
|
|
42
|
+
* throw or a rejection is logged with `console.error` and swallowed, so it
|
|
43
|
+
* can never replace or delay the caller's failure. */
|
|
44
|
+
onError?: (err: ApiErr, req: FailedRequest) => void | Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The request an {@link FetchConfig.onError} failure belongs to. */
|
|
48
|
+
export interface FailedRequest {
|
|
49
|
+
readonly method: HttpMethod;
|
|
50
|
+
/** The path as the caller passed it, before the `baseUrl` join. */
|
|
51
|
+
readonly path: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Successful result envelope. */
|
|
55
|
+
export interface ApiOk<T> {
|
|
56
|
+
readonly ok: true;
|
|
57
|
+
readonly status: number;
|
|
58
|
+
/** The decoded / parsed response body, or a `Blob` under `as: "blob"`.
|
|
59
|
+
* `undefined` for a 204 or an empty body read as JSON; the null-collapsing helpers
|
|
60
|
+
* turn that into `null`. */
|
|
61
|
+
readonly data: T;
|
|
62
|
+
/** Response headers. Always present: a success envelope exists only once a
|
|
63
|
+
* real HTTP response was received. */
|
|
64
|
+
readonly headers: Headers;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Failure result envelope. Never thrown — always returned by `requestRaw`. */
|
|
68
|
+
export interface ApiErr {
|
|
69
|
+
readonly ok: false;
|
|
70
|
+
/** HTTP status, or 0 for a network / timeout / cancelled / invalid failure. */
|
|
71
|
+
readonly status: number;
|
|
72
|
+
/** Human-readable message. */
|
|
73
|
+
readonly error: string;
|
|
74
|
+
/** Machine code: `"network"` | `"timeout"` | `"cancelled"` | `"decode"` |
|
|
75
|
+
* `"invalid"`, or a code lifted from the server's error body, which can
|
|
76
|
+
* spoof a reserved one. Disambiguate by `status`, never by `code` alone:
|
|
77
|
+
* reserved codes carry `status === 0` (`"decode"` its real 2xx status), a
|
|
78
|
+
* lifted code always the real non-2xx status. */
|
|
79
|
+
readonly code?: string;
|
|
80
|
+
/** Lifted from the error body's `request_id` / `requestId`, when present. */
|
|
81
|
+
readonly requestId?: string;
|
|
82
|
+
/** The parsed JSON body of the failed response, present when a real HTTP
|
|
83
|
+
* response carried parseable JSON (any shape). Absent on network / timeout
|
|
84
|
+
* / cancelled / invalid failures and on non-JSON / empty bodies.
|
|
85
|
+
*
|
|
86
|
+
* SECURITY: server-controlled content, same trust level as `error` —
|
|
87
|
+
* validate the shape before reading fields, and render any text from it
|
|
88
|
+
* via textContent, never innerHTML. */
|
|
89
|
+
readonly body?: unknown;
|
|
90
|
+
/** Response headers, present only when an HTTP response was actually
|
|
91
|
+
* received (a non-2xx error, or a 2xx whose body failed decoding). Absent
|
|
92
|
+
* on network / timeout / cancelled / invalid failures, the same rule that
|
|
93
|
+
* makes {@link ApiOk.headers} always present. */
|
|
94
|
+
readonly headers?: Headers;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Discriminated union returned by `requestRaw` and the `*Raw` verb helpers. */
|
|
98
|
+
export type ApiResult<T> = ApiOk<T> | ApiErr;
|
|
99
|
+
|
|
100
|
+
/** The options every request takes, whatever its 2xx body is read as. */
|
|
101
|
+
interface RequestOptionsBase {
|
|
102
|
+
/** JSON-encoded and sent on a non-GET request unless it is `null` or
|
|
103
|
+
* `undefined`, so a JSON `null` body is never sent. Ignored on GET. */
|
|
104
|
+
body?: unknown;
|
|
105
|
+
/** Pre-encoded request body for non-GET requests, sent as-is with NO JSON
|
|
106
|
+
* encoding and NO automatic Content-Type — the caller owns the type via
|
|
107
|
+
* `headers`. Mutually exclusive with `body` (both set is a client-side
|
|
108
|
+
* `"invalid"` failure). */
|
|
109
|
+
rawBody?: BodyInit;
|
|
110
|
+
/** Caller cancellation signal, composed with the request timeout. */
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
/** Per-request headers, merged before the global `prepareHeaders` hook. */
|
|
113
|
+
headers?: Record<string, string> | Headers;
|
|
114
|
+
/** Overrides the default request timeout (`API_TIMEOUT_MS`) for this request. */
|
|
115
|
+
timeoutMs?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Options for a request whose 2xx body is read as JSON, the default. */
|
|
119
|
+
export interface RequestOptions<T = unknown> extends RequestOptionsBase {
|
|
120
|
+
as?: "json";
|
|
121
|
+
/** Optional runtime validation applied to a 2xx body. */
|
|
122
|
+
decoder?: Decoder<T>;
|
|
123
|
+
/** Skip reading a 2xx response body entirely: resolves ok with
|
|
124
|
+
* `data: undefined` and any `decoder` is not invoked. Non-2xx error bodies
|
|
125
|
+
* are still parsed. For endpoints whose success body is irrelevant or
|
|
126
|
+
* non-JSON. */
|
|
127
|
+
ignoreBody?: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Options for a request whose 2xx body, a 204 included, is read whole as a
|
|
131
|
+
* `Blob` under `maxResponseBytes`. Its `type` is the response's
|
|
132
|
+
* `Content-Type`, lowercased, or empty when there is none or it holds a
|
|
133
|
+
* character outside printable ASCII. A non-2xx body is still read as JSON. */
|
|
134
|
+
export interface BlobRequestOptions extends RequestOptionsBase {
|
|
135
|
+
as: "blob";
|
|
136
|
+
decoder?: never;
|
|
137
|
+
ignoreBody?: never;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What the one request implementation accepts behind the overloads.
|
|
141
|
+
* @internal */
|
|
142
|
+
export type AnyRequestOptions<T = unknown> = RequestOptions<T> | BlobRequestOptions;
|
|
143
|
+
|
|
144
|
+
/** Admits only `Blob` itself as a Blob read's `T`: a subtype such as `File`
|
|
145
|
+
* would claim fields the read never sets. */
|
|
146
|
+
type ExactlyBlob<T> = [Blob] extends [T] ? unknown : never;
|
|
147
|
+
|
|
148
|
+
/** The non-throwing request core: always resolves to an {@link ApiResult},
|
|
149
|
+
* never throws. */
|
|
150
|
+
export interface RequestRawFn {
|
|
151
|
+
<T>(method: HttpMethod, path: string, opts?: RequestOptions<T>): Promise<ApiResult<T>>;
|
|
152
|
+
<T extends Blob = Blob>(
|
|
153
|
+
method: HttpMethod,
|
|
154
|
+
path: string,
|
|
155
|
+
opts: BlobRequestOptions & ExactlyBlob<T>,
|
|
156
|
+
): Promise<ApiResult<T>>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The null-collapsing request: the decoded body, or `null` on any error or
|
|
160
|
+
* empty JSON body. */
|
|
161
|
+
export interface RequestFn {
|
|
162
|
+
<T>(method: HttpMethod, path: string, opts?: RequestOptions<T>): Promise<T | null>;
|
|
163
|
+
<T extends Blob = Blob>(
|
|
164
|
+
method: HttpMethod,
|
|
165
|
+
path: string,
|
|
166
|
+
opts: BlobRequestOptions & ExactlyBlob<T>,
|
|
167
|
+
): Promise<T | null>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A GET or DELETE helper returning the data or `null`. */
|
|
171
|
+
export interface ReadVerb {
|
|
172
|
+
<T>(path: string, opts?: RequestOptions<T>): Promise<T | null>;
|
|
173
|
+
<T extends Blob = Blob>(
|
|
174
|
+
path: string,
|
|
175
|
+
opts: BlobRequestOptions & ExactlyBlob<T>,
|
|
176
|
+
): Promise<T | null>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A GET or DELETE helper returning the full {@link ApiResult}. */
|
|
180
|
+
export interface ReadVerbRaw {
|
|
181
|
+
<T>(path: string, opts?: RequestOptions<T>): Promise<ApiResult<T>>;
|
|
182
|
+
<T extends Blob = Blob>(
|
|
183
|
+
path: string,
|
|
184
|
+
opts: BlobRequestOptions & ExactlyBlob<T>,
|
|
185
|
+
): Promise<ApiResult<T>>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A helper sending a JSON body and returning the data or `null`. */
|
|
189
|
+
export interface WriteVerb {
|
|
190
|
+
<T>(path: string, body?: unknown, opts?: RequestOptions<T>): Promise<T | null>;
|
|
191
|
+
<T extends Blob = Blob>(
|
|
192
|
+
path: string,
|
|
193
|
+
body: unknown,
|
|
194
|
+
opts: BlobRequestOptions & ExactlyBlob<T>,
|
|
195
|
+
): Promise<T | null>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A helper sending a JSON body and returning the full {@link ApiResult}. */
|
|
199
|
+
export interface WriteVerbRaw {
|
|
200
|
+
<T>(path: string, body?: unknown, opts?: RequestOptions<T>): Promise<ApiResult<T>>;
|
|
201
|
+
<T extends Blob = Blob>(
|
|
202
|
+
path: string,
|
|
203
|
+
body: unknown,
|
|
204
|
+
opts: BlobRequestOptions & ExactlyBlob<T>,
|
|
205
|
+
): Promise<ApiResult<T>>;
|
|
206
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { RequestCore, RequestRawCore } from "./request.js";
|
|
2
|
+
import type {
|
|
3
|
+
AnyRequestOptions,
|
|
4
|
+
ApiResult,
|
|
5
|
+
Decoder,
|
|
6
|
+
ReadVerb,
|
|
7
|
+
ReadVerbRaw,
|
|
8
|
+
RequestOptions,
|
|
9
|
+
WriteVerb,
|
|
10
|
+
WriteVerbRaw,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** The bundle of 12 verb helpers a fetch instance exposes. */
|
|
14
|
+
export interface FetchVerbs {
|
|
15
|
+
/** GET; returns the decoded body or `null` on any error. */
|
|
16
|
+
apiGet: ReadVerb;
|
|
17
|
+
/** POST a JSON body; returns the decoded body or `null` on any error. */
|
|
18
|
+
apiPost: WriteVerb;
|
|
19
|
+
/** PUT a JSON body; returns the decoded body or `null` on any error. */
|
|
20
|
+
apiPut: WriteVerb;
|
|
21
|
+
/** PATCH a JSON body; returns the decoded body or `null` on any error. */
|
|
22
|
+
apiPatch: WriteVerb;
|
|
23
|
+
/** DELETE; returns the decoded body or `null` on any error. */
|
|
24
|
+
apiDelete: ReadVerb;
|
|
25
|
+
/** GET with runtime validation; returns the decoded body or `null` on any error. */
|
|
26
|
+
apiGetTyped: <T>(
|
|
27
|
+
path: string,
|
|
28
|
+
decoder: Decoder<T>,
|
|
29
|
+
opts?: RequestOptions<T>,
|
|
30
|
+
) => Promise<T | null>;
|
|
31
|
+
/** POST with runtime validation; returns the decoded body or `null` on any error. */
|
|
32
|
+
apiPostTyped: <T>(
|
|
33
|
+
path: string,
|
|
34
|
+
body: unknown,
|
|
35
|
+
decoder: Decoder<T>,
|
|
36
|
+
opts?: RequestOptions<T>,
|
|
37
|
+
) => Promise<T | null>;
|
|
38
|
+
/** GET; returns the full {@link ApiResult} envelope. */
|
|
39
|
+
apiGetRaw: ReadVerbRaw;
|
|
40
|
+
/** POST a JSON body; returns the full {@link ApiResult} envelope. */
|
|
41
|
+
apiPostRaw: WriteVerbRaw;
|
|
42
|
+
/** PUT a JSON body; returns the full {@link ApiResult} envelope. */
|
|
43
|
+
apiPutRaw: WriteVerbRaw;
|
|
44
|
+
/** PATCH a JSON body; returns the full {@link ApiResult} envelope. */
|
|
45
|
+
apiPatchRaw: WriteVerbRaw;
|
|
46
|
+
/** DELETE; returns the full {@link ApiResult} envelope. */
|
|
47
|
+
apiDeleteRaw: ReadVerbRaw;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Bind the 12 verb helpers to a `(request, requestRaw)` pair. */
|
|
51
|
+
export function makeVerbs(request: RequestCore, requestRaw: RequestRawCore): FetchVerbs {
|
|
52
|
+
return {
|
|
53
|
+
apiGet<T>(path: string, opts?: AnyRequestOptions<T>): Promise<T | null> {
|
|
54
|
+
return request<T>("GET", path, opts);
|
|
55
|
+
},
|
|
56
|
+
apiPost<T>(path: string, body?: unknown, opts?: AnyRequestOptions<T>): Promise<T | null> {
|
|
57
|
+
return request<T>("POST", path, { ...opts, body });
|
|
58
|
+
},
|
|
59
|
+
apiPut<T>(path: string, body?: unknown, opts?: AnyRequestOptions<T>): Promise<T | null> {
|
|
60
|
+
return request<T>("PUT", path, { ...opts, body });
|
|
61
|
+
},
|
|
62
|
+
apiPatch<T>(path: string, body?: unknown, opts?: AnyRequestOptions<T>): Promise<T | null> {
|
|
63
|
+
return request<T>("PATCH", path, { ...opts, body });
|
|
64
|
+
},
|
|
65
|
+
apiDelete<T>(path: string, opts?: AnyRequestOptions<T>): Promise<T | null> {
|
|
66
|
+
return request<T>("DELETE", path, opts);
|
|
67
|
+
},
|
|
68
|
+
apiGetTyped<T>(path: string, decoder: Decoder<T>, opts?: RequestOptions<T>): Promise<T | null> {
|
|
69
|
+
return request<T>("GET", path, { ...opts, decoder });
|
|
70
|
+
},
|
|
71
|
+
apiPostTyped<T>(
|
|
72
|
+
path: string,
|
|
73
|
+
body: unknown,
|
|
74
|
+
decoder: Decoder<T>,
|
|
75
|
+
opts?: RequestOptions<T>,
|
|
76
|
+
): Promise<T | null> {
|
|
77
|
+
return request<T>("POST", path, { ...opts, body, decoder });
|
|
78
|
+
},
|
|
79
|
+
apiGetRaw<T>(path: string, opts?: AnyRequestOptions<T>): Promise<ApiResult<T>> {
|
|
80
|
+
return requestRaw<T>("GET", path, opts);
|
|
81
|
+
},
|
|
82
|
+
apiPostRaw<T>(
|
|
83
|
+
path: string,
|
|
84
|
+
body?: unknown,
|
|
85
|
+
opts?: AnyRequestOptions<T>,
|
|
86
|
+
): Promise<ApiResult<T>> {
|
|
87
|
+
return requestRaw<T>("POST", path, { ...opts, body });
|
|
88
|
+
},
|
|
89
|
+
apiPutRaw<T>(path: string, body?: unknown, opts?: AnyRequestOptions<T>): Promise<ApiResult<T>> {
|
|
90
|
+
return requestRaw<T>("PUT", path, { ...opts, body });
|
|
91
|
+
},
|
|
92
|
+
apiPatchRaw<T>(
|
|
93
|
+
path: string,
|
|
94
|
+
body?: unknown,
|
|
95
|
+
opts?: AnyRequestOptions<T>,
|
|
96
|
+
): Promise<ApiResult<T>> {
|
|
97
|
+
return requestRaw<T>("PATCH", path, { ...opts, body });
|
|
98
|
+
},
|
|
99
|
+
apiDeleteRaw<T>(path: string, opts?: AnyRequestOptions<T>): Promise<ApiResult<T>> {
|
|
100
|
+
return requestRaw<T>("DELETE", path, opts);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* announce.ts — Screen-reader announcements through pre-mounted live regions:
|
|
3
|
+
* a polite and an assertive one per surface, the root's from `mountLiveRegions`
|
|
4
|
+
* and each `<dialog>`'s that the dialog primitives drive. A region inserted or
|
|
5
|
+
* moved in the mutation that fills it is not reliably announced, so `announce`
|
|
6
|
+
* never creates or moves one.
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { acquireRegions, regionsOf } from "./live-regions.js";
|
|
11
|
+
import type { Politeness } from "./live-regions.js";
|
|
12
|
+
import { trackedModals, whenUncovered } from "./modal-host.js";
|
|
13
|
+
|
|
14
|
+
/** Gap (ms) between clearing the region and setting the new text: the
|
|
15
|
+
* empty-then-text change is what makes assistive tech re-announce identical
|
|
16
|
+
* text, and a microtask gap is too short for it to see two mutations. */
|
|
17
|
+
const ANNOUNCE_DELAY_MS = 100;
|
|
18
|
+
|
|
19
|
+
interface RootMount {
|
|
20
|
+
readonly host: HTMLElement;
|
|
21
|
+
readonly release: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Root mounts still held, oldest first; the newest one's host is the root. */
|
|
25
|
+
const roots: RootMount[] = [];
|
|
26
|
+
|
|
27
|
+
const pending = new Map<Politeness, ReturnType<typeof setTimeout>>();
|
|
28
|
+
|
|
29
|
+
/** The regions holding an announcement's text, emptied before a modal's close or
|
|
30
|
+
* removal uncovers them: a browser can read an alert region again when exposed
|
|
31
|
+
* (Chromium, ui/accessibility/ax_event_generator.cc OnIgnoredChanged). */
|
|
32
|
+
const written = new Set<HTMLElement>();
|
|
33
|
+
|
|
34
|
+
function clearWritten(): void {
|
|
35
|
+
for (const region of written) {
|
|
36
|
+
region.textContent = "";
|
|
37
|
+
}
|
|
38
|
+
written.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Mount the page's root polite and assertive live regions as `host`'s last
|
|
42
|
+
* children and make `host` the root. Call it once at boot, before the first
|
|
43
|
+
* render, so the regions exist before anything is announced. A pair still
|
|
44
|
+
* inside `host` is kept rather than replaced. Returns this mount's release,
|
|
45
|
+
* which is idempotent: the root falls back to the newest mount still held,
|
|
46
|
+
* and the pair leaves `host` when its last owner releases (never, on a dialog
|
|
47
|
+
* the dialog primitives drive, which keeps its pair). */
|
|
48
|
+
export function mountLiveRegions(host: HTMLElement): () => void {
|
|
49
|
+
const mount: RootMount = { host, release: acquireRegions(host) };
|
|
50
|
+
roots.push(mount);
|
|
51
|
+
return () => {
|
|
52
|
+
const at = roots.indexOf(mount);
|
|
53
|
+
if (at === -1) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
roots.splice(at, 1);
|
|
57
|
+
mount.release();
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The regions an announcement reaches now: the pair of every open modal the
|
|
62
|
+
* library drives and the root's. `showModal()` takes everything outside the
|
|
63
|
+
* topmost modal out of the accessibility tree, so the browser, not an opening
|
|
64
|
+
* order, picks the one read. With no modal open every open dialog is exposed,
|
|
65
|
+
* so only the root's is written, else it would be read twice. */
|
|
66
|
+
function currentRegions(politeness: Politeness): HTMLElement[] {
|
|
67
|
+
const hosts: HTMLElement[] = trackedModals();
|
|
68
|
+
const root = roots.at(-1);
|
|
69
|
+
if (root !== undefined) {
|
|
70
|
+
hosts.push(root.host);
|
|
71
|
+
}
|
|
72
|
+
const regions = new Set<HTMLElement>();
|
|
73
|
+
for (const host of hosts) {
|
|
74
|
+
const region = regionsOf(host)?.[politeness];
|
|
75
|
+
if (region !== undefined) {
|
|
76
|
+
regions.add(region);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...regions];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function deliver(message: string, politeness: Politeness): void {
|
|
83
|
+
const cleared = currentRegions(politeness);
|
|
84
|
+
if (cleared.length === 0) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const region of cleared) {
|
|
88
|
+
region.textContent = "";
|
|
89
|
+
written.delete(region);
|
|
90
|
+
}
|
|
91
|
+
pending.set(
|
|
92
|
+
politeness,
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
const live = currentRegions(politeness);
|
|
95
|
+
// A region that joined during the gap (a modal opened, a root mounted) was not
|
|
96
|
+
// cleared, so identical text would not be announced again there: start over.
|
|
97
|
+
if (!live.every((region) => cleared.includes(region))) {
|
|
98
|
+
deliver(message, politeness);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Before the write: a landing run in the change's own task (as under fake timers)
|
|
102
|
+
// sees records not yet delivered, and their clear must not empty this text.
|
|
103
|
+
whenUncovered(clearWritten);
|
|
104
|
+
for (const region of live) {
|
|
105
|
+
region.textContent = message;
|
|
106
|
+
written.add(region);
|
|
107
|
+
}
|
|
108
|
+
}, ANNOUNCE_DELAY_MS),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Announce `message` to screen readers through the live regions of every open
|
|
113
|
+
* modal dialog the dialog primitives drive and the root's from
|
|
114
|
+
* {@link mountLiveRegions}; the browser reads the one inside the topmost modal,
|
|
115
|
+
* or the root's when no modal is open. A modal's close or removal empties every
|
|
116
|
+
* region before it is uncovered. Writes nothing when none is mounted.
|
|
117
|
+
* `"assertive"` interrupts, through its own `role="alert"` region; a second
|
|
118
|
+
* announcement at the same politeness before the first lands replaces it. */
|
|
119
|
+
export function announce(message: string, politeness: Politeness = "polite"): void {
|
|
120
|
+
clearTimeout(pending.get(politeness));
|
|
121
|
+
deliver(message, politeness);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Test-only: cancel pending announcements, forget the written regions and
|
|
125
|
+
* release every root mount. */
|
|
126
|
+
export function _resetForTest(): void {
|
|
127
|
+
for (const timer of pending.values()) {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
}
|
|
130
|
+
written.clear();
|
|
131
|
+
for (const mount of roots.splice(0)) {
|
|
132
|
+
mount.release();
|
|
133
|
+
}
|
|
134
|
+
}
|