@tinvar/react 0.0.0-stage → 0.4.0
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 +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- package/README.md +0 -3
package/src/head.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// The document's title, meta tags and links, from any component.
|
|
4
|
+
//
|
|
5
|
+
// tinvar-spec.md, "Operations and tooling": "React 19 hoists <title> and
|
|
6
|
+
// <meta> from components; add key-based dedupe, one title, a global title
|
|
7
|
+
// template, and PHP-rendered fallback tags marked for replacement."
|
|
8
|
+
//
|
|
9
|
+
// React hoists every <title>, <meta> and <link> a component renders into
|
|
10
|
+
// <head>, all of them: a layout and its page each naming a title leaves two,
|
|
11
|
+
// and the browser shows whichever comes first; each naming a canonical URL
|
|
12
|
+
// leaves two, and a crawler believes neither. So the tags go through these
|
|
13
|
+
// components instead, which agree among themselves on one per key:
|
|
14
|
+
//
|
|
15
|
+
// - the one deepest in the route wins -- the page over its layouts, read
|
|
16
|
+
// from the <Segment> it sits in, whatever order they render in -- and
|
|
17
|
+
// among equals, the one mounted last;
|
|
18
|
+
// - <Title> applies the provider's template, and the provider's default
|
|
19
|
+
// stands under every title, so a page that names none still has one;
|
|
20
|
+
// - a <Meta> is keyed by its name, property or http-equiv, or `headKey`;
|
|
21
|
+
// - a <HeadLink> by what makes two links the same link: see linkKey().
|
|
22
|
+
//
|
|
23
|
+
// A raw <title>, written without <Title>, takes part too, at the document's
|
|
24
|
+
// depth: on the server the renderer keeps one title of all of them, and in
|
|
25
|
+
// the browser the provider keeps the winner first in <head>, where the
|
|
26
|
+
// browser reads a document's title from (arrangeTitles()). A raw <link>
|
|
27
|
+
// takes part on the server alone, at the document's depth and keyed as
|
|
28
|
+
// <HeadLink> keys it, unless it is one of React's resources; in the browser
|
|
29
|
+
// it is React's to hoist and remove, so a link that changes with the page
|
|
30
|
+
// belongs in a <HeadLink>.
|
|
31
|
+
//
|
|
32
|
+
// On the server every tag renders, marked with its key and depth, and the
|
|
33
|
+
// renderer keeps one of each key in the document's head (packages/renderer
|
|
34
|
+
// src/head.js), dropping the PHP-rendered fallbacks any of them replaces. In
|
|
35
|
+
// the browser the same agreement is reached once they mount, and the losers
|
|
36
|
+
// render nothing.
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
createContext,
|
|
40
|
+
createElement,
|
|
41
|
+
useContext,
|
|
42
|
+
useId,
|
|
43
|
+
useLayoutEffect,
|
|
44
|
+
useMemo,
|
|
45
|
+
useState,
|
|
46
|
+
useSyncExternalStore,
|
|
47
|
+
} from 'react';
|
|
48
|
+
import { useSegmentDepth } from './segment.js';
|
|
49
|
+
|
|
50
|
+
/** Marks a tag PHP rendered for a component's to replace (see Tinvar\Protocol\HeadTag). */
|
|
51
|
+
export const FALLBACK_ATTRIBUTE = 'data-tinvar-head';
|
|
52
|
+
|
|
53
|
+
/** What a fallback is marked with. */
|
|
54
|
+
export const FALLBACK = 'fallback';
|
|
55
|
+
|
|
56
|
+
/** The key a tag is deduplicated by, on the element. */
|
|
57
|
+
export const KEY_ATTRIBUTE = 'data-tinvar-key';
|
|
58
|
+
|
|
59
|
+
/** Its depth in the route, on the element. */
|
|
60
|
+
export const DEPTH_ATTRIBUTE = 'data-tinvar-depth';
|
|
61
|
+
|
|
62
|
+
/** The key every title shares: there is one title. */
|
|
63
|
+
export const TITLE_KEY = 'title';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The key a meta tag is deduplicated by.
|
|
67
|
+
*
|
|
68
|
+
* @param {Record<string, unknown>} props
|
|
69
|
+
* @returns {string | null} null for a tag with nothing to key it by, which is never deduplicated
|
|
70
|
+
*/
|
|
71
|
+
export function metaKey(props) {
|
|
72
|
+
if (typeof props.headKey === 'string' && props.headKey !== '') return props.headKey;
|
|
73
|
+
if (typeof props.name === 'string') return `name:${props.name}`;
|
|
74
|
+
if (typeof props.property === 'string') return `property:${props.property}`;
|
|
75
|
+
if (typeof props.httpEquiv === 'string') return `http-equiv:${props.httpEquiv.toLowerCase()}`;
|
|
76
|
+
if (props.charSet !== undefined) return 'charset';
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Rels whose links are the page's icon at a size: a deeper one stands in
|
|
82
|
+
* for a shallower one of the same size and media, whatever its href.
|
|
83
|
+
*/
|
|
84
|
+
const ICON_RELS = new Set([
|
|
85
|
+
'icon',
|
|
86
|
+
'shortcut icon',
|
|
87
|
+
'apple-touch-icon',
|
|
88
|
+
'apple-touch-icon-precomposed',
|
|
89
|
+
'mask-icon',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The key a link is deduplicated by.
|
|
94
|
+
*
|
|
95
|
+
* - `headKey`, given: the application's own.
|
|
96
|
+
* - canonical: the rel alone. A document has one canonical URL, so the
|
|
97
|
+
* page's replaces its layout's, whatever either names.
|
|
98
|
+
* - alternate with an hreflang: the rel and the language -- one
|
|
99
|
+
* translation of the page per language.
|
|
100
|
+
* - an icon: the rel, its sizes, its media and its type -- one icon per
|
|
101
|
+
* size, per colour scheme, per format. A browser picks among icons of
|
|
102
|
+
* different types by what it can draw, so an SVG and a PNG fallback
|
|
103
|
+
* with no sizes are two icons, not one replacing the other.
|
|
104
|
+
* - anything else: the rel and the href. Two links to the same thing for
|
|
105
|
+
* the same reason are one link; a preload, a preconnect, a feed or a
|
|
106
|
+
* manifest named twice -- by a layout and a page -- is written once.
|
|
107
|
+
*
|
|
108
|
+
* A stylesheet is never keyed: React's own resources dedupe stylesheets by
|
|
109
|
+
* href and order them by precedence, and a <HeadLink rel="stylesheet">
|
|
110
|
+
* renders React's <link> exactly as given (see HeadLink).
|
|
111
|
+
*
|
|
112
|
+
* @param {Record<string, unknown>} props
|
|
113
|
+
* @returns {string | null} null for a link with nothing to key it by, which is never deduplicated
|
|
114
|
+
*/
|
|
115
|
+
export function linkKey(props) {
|
|
116
|
+
if (typeof props.headKey === 'string' && props.headKey !== '') return `link:${props.headKey}`;
|
|
117
|
+
const rel = typeof props.rel === 'string' ? props.rel.trim().toLowerCase().replace(/\s+/g, ' ') : '';
|
|
118
|
+
if (rel === '' || rel === 'stylesheet') return null;
|
|
119
|
+
if (rel === 'canonical') return 'link:canonical';
|
|
120
|
+
|
|
121
|
+
const hrefLang = props.hrefLang ?? props.hreflang;
|
|
122
|
+
if (rel === 'alternate' && typeof hrefLang === 'string' && hrefLang !== '') {
|
|
123
|
+
return `link:alternate:${hrefLang.toLowerCase()}`;
|
|
124
|
+
}
|
|
125
|
+
if (ICON_RELS.has(rel)) {
|
|
126
|
+
const sizes = typeof props.sizes === 'string' ? props.sizes.trim().toLowerCase() : '';
|
|
127
|
+
const media = typeof props.media === 'string' ? props.media.trim() : '';
|
|
128
|
+
const type = typeof props.type === 'string' ? props.type.trim().toLowerCase() : '';
|
|
129
|
+
return `link:${rel}:${sizes}:${media}:${type}`;
|
|
130
|
+
}
|
|
131
|
+
return typeof props.href === 'string' && props.href !== '' ? `link:${rel}:${props.href}` : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The tags mounted in the browser, and which of each key wins.
|
|
136
|
+
*
|
|
137
|
+
* @returns {{
|
|
138
|
+
* register(id: string, key: string, depth: number): () => void,
|
|
139
|
+
* wins(id: string): boolean,
|
|
140
|
+
* subscribe(listener: () => void): () => void,
|
|
141
|
+
* }}
|
|
142
|
+
*/
|
|
143
|
+
export function createHeadStore() {
|
|
144
|
+
/** @type {Map<string, { key: string, depth: number, order: number }>} */
|
|
145
|
+
const entries = new Map();
|
|
146
|
+
const listeners = new Set();
|
|
147
|
+
let order = 0;
|
|
148
|
+
|
|
149
|
+
const notify = () => {
|
|
150
|
+
for (const listener of listeners) listener();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
register(id, key, depth) {
|
|
155
|
+
entries.set(id, { key, depth, order: order++ });
|
|
156
|
+
notify();
|
|
157
|
+
return () => {
|
|
158
|
+
entries.delete(id);
|
|
159
|
+
notify();
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
wins(id) {
|
|
163
|
+
const mine = entries.get(id);
|
|
164
|
+
// Not registered yet -- the render before its effect -- or not in a
|
|
165
|
+
// provider's view at all: it renders, as the server did.
|
|
166
|
+
if (mine === undefined) return true;
|
|
167
|
+
for (const [other, entry] of entries) {
|
|
168
|
+
if (other === id || entry.key !== mine.key) continue;
|
|
169
|
+
if (entry.depth > mine.depth || (entry.depth === mine.depth && entry.order > mine.order))
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
},
|
|
174
|
+
subscribe(listener) {
|
|
175
|
+
listeners.add(listener);
|
|
176
|
+
return () => listeners.delete(listener);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @typedef {{ store: ReturnType<typeof createHeadStore> | null, titleTemplate: string | null }} HeadContextValue
|
|
183
|
+
*/
|
|
184
|
+
const HeadContext = createContext(/** @type {HeadContextValue} */ ({ store: null, titleTemplate: null }));
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The title template and default for the page, around the document.
|
|
188
|
+
*
|
|
189
|
+
* @param {{ titleTemplate?: string, defaultTitle?: string, children?: unknown }} props `titleTemplate`
|
|
190
|
+
* holds `%s` where a <Title>'s text goes; `defaultTitle` is the title when no
|
|
191
|
+
* component names one, used as it is
|
|
192
|
+
*/
|
|
193
|
+
export function HeadProvider({ titleTemplate, defaultTitle, children }) {
|
|
194
|
+
// One store for the provider's life: the document's, kept across every
|
|
195
|
+
// page the router mounts into it.
|
|
196
|
+
const [store] = useState(createHeadStore);
|
|
197
|
+
const value = useMemo(() => ({ store, titleTemplate: titleTemplate ?? null }), [store, titleTemplate]);
|
|
198
|
+
|
|
199
|
+
// A raw <title> is React's to hoist, and in the browser React puts each
|
|
200
|
+
// newly mounted title ahead of the others -- so a page's raw title showed
|
|
201
|
+
// over its <Title>, or a layout's raw title over the page's. The winner is
|
|
202
|
+
// kept first instead, whenever the store's tags change or the head does.
|
|
203
|
+
useLayoutEffect(() => {
|
|
204
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return undefined;
|
|
205
|
+
const head = document.head;
|
|
206
|
+
if (head === null) return undefined;
|
|
207
|
+
const arrange = () => arrangeTitles(head);
|
|
208
|
+
const observer = new MutationObserver(arrange);
|
|
209
|
+
observer.observe(head, { childList: true });
|
|
210
|
+
const unsubscribe = store.subscribe(arrange);
|
|
211
|
+
arrange();
|
|
212
|
+
return () => {
|
|
213
|
+
observer.disconnect();
|
|
214
|
+
unsubscribe();
|
|
215
|
+
};
|
|
216
|
+
}, [store]);
|
|
217
|
+
|
|
218
|
+
return createElement(
|
|
219
|
+
HeadContext.Provider,
|
|
220
|
+
{ value },
|
|
221
|
+
defaultTitle === undefined
|
|
222
|
+
? null
|
|
223
|
+
: createElement(HeadTag, { kind: 'title', depth: -1, text: defaultTitle }),
|
|
224
|
+
children,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The document's title. Its text goes through the provider's template.
|
|
230
|
+
*
|
|
231
|
+
* @param {{ children: string | number | readonly (string | number)[] }} props
|
|
232
|
+
*/
|
|
233
|
+
export function Title({ children }) {
|
|
234
|
+
const { titleTemplate } = useContext(HeadContext);
|
|
235
|
+
// React hoists a <title> with exactly one text child, so the text is one
|
|
236
|
+
// string however it was written.
|
|
237
|
+
const text = Array.isArray(children) ? children.join('') : String(children);
|
|
238
|
+
// Given by a function, so the text goes in as written: given as a
|
|
239
|
+
// string, `$'` or `$&` in a product's name was a replacement pattern.
|
|
240
|
+
const shown = titleTemplate === null ? text : titleTemplate.replace('%s', () => text);
|
|
241
|
+
|
|
242
|
+
return createElement(HeadTag, { kind: 'title', depth: useSegmentDepth(), text: shown });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A meta tag, one per name, property or http-equiv -- or per `headKey`,
|
|
247
|
+
* for a key of your own.
|
|
248
|
+
*
|
|
249
|
+
* @param {Record<string, unknown> & { headKey?: string }} props
|
|
250
|
+
*/
|
|
251
|
+
export function Meta(props) {
|
|
252
|
+
return createElement(HeadTag, { kind: 'meta', depth: useSegmentDepth(), props });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A link in the document's head, one per key (linkKey()): one canonical
|
|
257
|
+
* URL, one icon per size and type, one alternate per language, and no link written
|
|
258
|
+
* twice -- or per `headKey`, for a key of your own.
|
|
259
|
+
*
|
|
260
|
+
* A stylesheet goes to React as it is, unkeyed and unmarked: React already
|
|
261
|
+
* keeps one of each href, orders them by `precedence`, and holds the page
|
|
262
|
+
* until they have loaded, none of which a link that gives way to another
|
|
263
|
+
* could do.
|
|
264
|
+
*
|
|
265
|
+
* @param {Record<string, unknown> & { rel: string, headKey?: string }} props
|
|
266
|
+
*/
|
|
267
|
+
export function HeadLink(props) {
|
|
268
|
+
const depth = useSegmentDepth();
|
|
269
|
+
if (typeof props.rel === 'string' && props.rel.trim().toLowerCase() === 'stylesheet') {
|
|
270
|
+
const { headKey: _ignored, ...attributes } = props;
|
|
271
|
+
return createElement('link', attributes);
|
|
272
|
+
}
|
|
273
|
+
return createElement(HeadTag, { kind: 'link', depth, props });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* One tag, rendered only while it wins its key.
|
|
278
|
+
*
|
|
279
|
+
* @param {{ kind: 'title' | 'meta' | 'link', depth: number, text?: string, props?: Record<string, unknown> }} props
|
|
280
|
+
*/
|
|
281
|
+
function HeadTag({ kind, depth, text, props }) {
|
|
282
|
+
const { store } = useContext(HeadContext);
|
|
283
|
+
const id = useId();
|
|
284
|
+
const key = kind === 'title' ? TITLE_KEY : kind === 'link' ? linkKey(props ?? {}) : metaKey(props ?? {});
|
|
285
|
+
|
|
286
|
+
useLayoutEffect(() => {
|
|
287
|
+
if (store === null || key === null) return undefined;
|
|
288
|
+
return store.register(id, key, depth);
|
|
289
|
+
}, [store, id, key, depth]);
|
|
290
|
+
|
|
291
|
+
// The server and the hydrating render show every tag, as the document
|
|
292
|
+
// has them; the renderer already kept one of each key there.
|
|
293
|
+
const wins = useSyncExternalStore(
|
|
294
|
+
store?.subscribe ?? noSubscription,
|
|
295
|
+
() => store === null || key === null || store.wins(id),
|
|
296
|
+
() => true,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// A title that has just come to win -- the default once the page's went --
|
|
300
|
+
// is put first as it mounts, before the browser paints; a raw title is
|
|
301
|
+
// put in its place by the provider's observer, a microtask later.
|
|
302
|
+
useLayoutEffect(() => {
|
|
303
|
+
if (kind === 'title' && wins && store !== null && typeof document !== 'undefined' && document.head) {
|
|
304
|
+
arrangeTitles(document.head);
|
|
305
|
+
}
|
|
306
|
+
}, [kind, wins, store, text]);
|
|
307
|
+
|
|
308
|
+
if (!wins) return null;
|
|
309
|
+
|
|
310
|
+
const marks = { [DEPTH_ATTRIBUTE]: String(depth), ...(key === null ? {} : { [KEY_ATTRIBUTE]: key }) };
|
|
311
|
+
if (kind === 'title') return createElement('title', marks, text);
|
|
312
|
+
|
|
313
|
+
const { headKey: _ignored, ...attributes } = props ?? {};
|
|
314
|
+
return createElement(kind, { ...attributes, ...marks });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const noSubscription = () => () => {};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Puts the title that wins first in $head, where the browser reads the
|
|
321
|
+
* document's title from, as the renderer resolves them on the server: the
|
|
322
|
+
* deepest, a raw <title> at the document's depth (0), a PHP fallback below
|
|
323
|
+
* everything, and among equals the newest -- the first in the head, since
|
|
324
|
+
* React inserts each title it mounts ahead of the others.
|
|
325
|
+
*
|
|
326
|
+
* Moved, never removed: every title in the head is React's, which removes
|
|
327
|
+
* it itself when its component unmounts, from wherever it is. The losers
|
|
328
|
+
* stay in the head, after the winner, where they change nothing the
|
|
329
|
+
* browser shows.
|
|
330
|
+
*
|
|
331
|
+
* @param {HTMLHeadElement} head
|
|
332
|
+
* @returns {Element | null} the winner, or null for a head with no title
|
|
333
|
+
*/
|
|
334
|
+
export function arrangeTitles(head) {
|
|
335
|
+
const titles = [...head.querySelectorAll('title')].filter((title) => title.parentNode === head);
|
|
336
|
+
if (titles.length < 2) return titles[0] ?? null;
|
|
337
|
+
|
|
338
|
+
let winner = titles[0];
|
|
339
|
+
let best = depthOf(winner);
|
|
340
|
+
for (const title of titles.slice(1)) {
|
|
341
|
+
const depth = depthOf(title);
|
|
342
|
+
if (depth > best) {
|
|
343
|
+
winner = title;
|
|
344
|
+
best = depth;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Only when it is not first already: a move is a mutation the observer
|
|
349
|
+
// hears, and this would otherwise answer it for ever.
|
|
350
|
+
if (titles[0] !== winner) head.insertBefore(winner, titles[0]);
|
|
351
|
+
return winner;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** @param {Element} title */
|
|
355
|
+
function depthOf(title) {
|
|
356
|
+
if (title.getAttribute(FALLBACK_ATTRIBUTE) === FALLBACK) return -Infinity;
|
|
357
|
+
if (!title.hasAttribute(KEY_ATTRIBUTE)) return 0;
|
|
358
|
+
const depth = Number(title.getAttribute(DEPTH_ATTRIBUTE));
|
|
359
|
+
return Number.isFinite(depth) ? depth : 0;
|
|
360
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import type { InboxClient, InboxEntry } from './inbox.js';
|
|
3
|
+
|
|
4
|
+
export interface InboxProps<T extends string = string> {
|
|
5
|
+
client: InboxClient<T>;
|
|
6
|
+
/** The bell's name, and the panel's: "Notifications" by default. */
|
|
7
|
+
label?: string;
|
|
8
|
+
/** What an empty inbox says. */
|
|
9
|
+
empty?: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
limit?: number;
|
|
12
|
+
/** Called when an entry is opened, after it is marked read; without it, its path is navigated to. */
|
|
13
|
+
onOpen?: (entry: InboxEntry<T>) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A bell and the user's notifications, unstyled: every part carries `data-inbox` to style it by. */
|
|
17
|
+
export declare function Inbox<T extends string>(props: InboxProps<T>): ReactElement;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// <Inbox>: a bell and the user's notifications, unstyled (ADR 0155).
|
|
4
|
+
//
|
|
5
|
+
// <Inbox client={inbox.customer} onOpen={(entry) => navigate(entry.url)} />
|
|
6
|
+
//
|
|
7
|
+
// A button saying how many are unread, which opens a list of the newest
|
|
8
|
+
// entries -- each its title and body as text, never markup, read when it
|
|
9
|
+
// is opened -- with "Mark all read" and, while there are more, "Show
|
|
10
|
+
// more". Everything carries a `data-inbox` attribute to style it by, and
|
|
11
|
+
// the button its count as `data-unread`. An application with a design
|
|
12
|
+
// system of its own builds its bell on useInbox() instead.
|
|
13
|
+
|
|
14
|
+
import { createElement as h, useId, useState } from 'react';
|
|
15
|
+
import { sitePath } from './inbox.js';
|
|
16
|
+
import { useInbox } from './use-inbox.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{
|
|
20
|
+
* client: ReturnType<typeof import('./inbox.js').inboxClient>,
|
|
21
|
+
* label?: string,
|
|
22
|
+
* empty?: string,
|
|
23
|
+
* className?: string,
|
|
24
|
+
* limit?: number,
|
|
25
|
+
* onOpen?: (entry: { id: string, url: string | null }) => void,
|
|
26
|
+
* }} props
|
|
27
|
+
*/
|
|
28
|
+
export function Inbox({ client, label = 'Notifications', empty = 'Nothing yet.', className, limit, onOpen }) {
|
|
29
|
+
const inbox = useInbox(client, limit === undefined ? {} : { limit });
|
|
30
|
+
const [open, setOpen] = useState(false);
|
|
31
|
+
const panel = useId();
|
|
32
|
+
|
|
33
|
+
/** @param {{ id: string, url: string | null, readAt: string | null }} entry */
|
|
34
|
+
const openEntry = (entry) => {
|
|
35
|
+
if (entry.readAt === null) void inbox.markRead([entry.id]);
|
|
36
|
+
if (onOpen) {
|
|
37
|
+
onOpen(entry);
|
|
38
|
+
} else {
|
|
39
|
+
// A path on this site alone: `/\\host` and `/\t/host` lead off it as
|
|
40
|
+
// `//host` does.
|
|
41
|
+
const path = sitePath(entry.url);
|
|
42
|
+
if (path !== null) globalThis.location?.assign(path);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return h(
|
|
47
|
+
'div',
|
|
48
|
+
{ className, 'data-inbox': 'root' },
|
|
49
|
+
h(
|
|
50
|
+
'button',
|
|
51
|
+
{
|
|
52
|
+
type: 'button',
|
|
53
|
+
'data-inbox': 'bell',
|
|
54
|
+
'data-unread': inbox.unread,
|
|
55
|
+
'aria-expanded': open,
|
|
56
|
+
'aria-controls': panel,
|
|
57
|
+
'aria-label': inbox.unread > 0 ? `${label}, ${inbox.unread} unread` : label,
|
|
58
|
+
onClick: () => setOpen((was) => !was),
|
|
59
|
+
},
|
|
60
|
+
label,
|
|
61
|
+
inbox.unread > 0
|
|
62
|
+
? h('span', { 'data-inbox': 'count', 'aria-hidden': true }, String(inbox.unread))
|
|
63
|
+
: null,
|
|
64
|
+
),
|
|
65
|
+
open
|
|
66
|
+
? h(
|
|
67
|
+
'section',
|
|
68
|
+
{ id: panel, 'data-inbox': 'panel', 'aria-label': label },
|
|
69
|
+
inbox.unread > 0
|
|
70
|
+
? h(
|
|
71
|
+
'button',
|
|
72
|
+
{ type: 'button', 'data-inbox': 'mark-all', onClick: () => void inbox.markAllRead() },
|
|
73
|
+
'Mark all read',
|
|
74
|
+
)
|
|
75
|
+
: null,
|
|
76
|
+
inbox.entries.length === 0 && !inbox.loading
|
|
77
|
+
? h('p', { 'data-inbox': 'empty' }, empty)
|
|
78
|
+
: h(
|
|
79
|
+
'ul',
|
|
80
|
+
{ 'data-inbox': 'list' },
|
|
81
|
+
inbox.entries.map((entry) =>
|
|
82
|
+
h(
|
|
83
|
+
'li',
|
|
84
|
+
{ key: entry.id, 'data-inbox': 'entry', 'data-read': entry.readAt !== null },
|
|
85
|
+
h(
|
|
86
|
+
'button',
|
|
87
|
+
{ type: 'button', 'data-inbox': 'open', onClick: () => openEntry(entry) },
|
|
88
|
+
h('strong', null, entry.title),
|
|
89
|
+
entry.body === '' ? null : h('span', null, entry.body),
|
|
90
|
+
h('time', { dateTime: entry.createdAt }, new Date(entry.createdAt).toLocaleString()),
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
inbox.hasMore
|
|
96
|
+
? h(
|
|
97
|
+
'button',
|
|
98
|
+
{ type: 'button', 'data-inbox': 'more', onClick: () => void inbox.loadMore() },
|
|
99
|
+
'Show more',
|
|
100
|
+
)
|
|
101
|
+
: null,
|
|
102
|
+
)
|
|
103
|
+
: null,
|
|
104
|
+
);
|
|
105
|
+
}
|
package/src/inbox.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** A signed-in user's in-app inbox (ADR 0155). */
|
|
2
|
+
|
|
3
|
+
import type { ListenOptions } from './channel.js';
|
|
4
|
+
|
|
5
|
+
export declare const INBOX_PATH: '/_inbox/';
|
|
6
|
+
|
|
7
|
+
/** The channels a notification goes out on. */
|
|
8
|
+
export type NotificationChannel = 'mail' | 'push' | 'inbox';
|
|
9
|
+
|
|
10
|
+
/** What codegen writes for each #[Notification]: the channels it sends on. */
|
|
11
|
+
export interface NotificationShape {
|
|
12
|
+
channels: NotificationChannel;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One entry: text to draw as text, and a path on this site. */
|
|
16
|
+
export interface InboxEntry<T extends string = string> {
|
|
17
|
+
id: string;
|
|
18
|
+
type: T;
|
|
19
|
+
title: string;
|
|
20
|
+
body: string;
|
|
21
|
+
url: string | null;
|
|
22
|
+
/** ISO 8601. */
|
|
23
|
+
createdAt: string;
|
|
24
|
+
readAt: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface InboxPage<T extends string = string> {
|
|
28
|
+
entries: InboxEntry<T>[];
|
|
29
|
+
unread: number;
|
|
30
|
+
/** The id the next page goes on from, or null for the last page. */
|
|
31
|
+
next: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the inbox's personal channel says when the feed changes. */
|
|
35
|
+
export interface InboxUpdate<T extends string = string> {
|
|
36
|
+
unread: number;
|
|
37
|
+
entry: InboxEntry<T> | null;
|
|
38
|
+
read: string[];
|
|
39
|
+
allRead: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NotificationPreference<T extends string = string> {
|
|
43
|
+
type: T;
|
|
44
|
+
label: string;
|
|
45
|
+
description: string;
|
|
46
|
+
channels: NotificationChannel[];
|
|
47
|
+
enabled: NotificationChannel[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NotificationPreferences<T extends string = string> {
|
|
51
|
+
types: NotificationPreference<T>[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NotificationChoice<T extends string = string> {
|
|
55
|
+
type: T;
|
|
56
|
+
channel: NotificationChannel;
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** $url where it is a path on this site, else null: never `//host`, `/\\host` or a control character. */
|
|
61
|
+
export declare function sitePath(url: unknown): string | null;
|
|
62
|
+
|
|
63
|
+
/** An entry as the server writes one, its link kept only on this site; null for anything else. */
|
|
64
|
+
export declare function readEntry(value: unknown): InboxEntry | null;
|
|
65
|
+
|
|
66
|
+
/** An update as the inbox's channel says one; null for anything else. */
|
|
67
|
+
export declare function readUpdate(value: unknown): InboxUpdate | null;
|
|
68
|
+
|
|
69
|
+
/** A call the server refused, or that never got an answer (status 0). */
|
|
70
|
+
export declare class InboxError extends Error {
|
|
71
|
+
readonly inbox: string;
|
|
72
|
+
readonly status: number;
|
|
73
|
+
readonly body: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface InboxClient<T extends string = string> {
|
|
77
|
+
readonly name: string;
|
|
78
|
+
/** Its personal channel: `inbox.{name}`. */
|
|
79
|
+
readonly channel: string;
|
|
80
|
+
page(page?: { before?: string | null; limit?: number }): Promise<InboxPage<T>>;
|
|
81
|
+
markRead(ids: string[]): Promise<{ unread: number }>;
|
|
82
|
+
markAllRead(): Promise<{ unread: number }>;
|
|
83
|
+
preferences(): Promise<NotificationPreferences<T>>;
|
|
84
|
+
choose(choices: NotificationChoice<T>[]): Promise<NotificationPreferences<T>>;
|
|
85
|
+
/** Hears the feed change until the returned function is called. */
|
|
86
|
+
listen(
|
|
87
|
+
onUpdate: (update: InboxUpdate<T>) => void,
|
|
88
|
+
options?: Pick<ListenOptions, 'onError' | 'onGap'>,
|
|
89
|
+
): () => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface InboxClientOptions {
|
|
93
|
+
fetch?: typeof fetch;
|
|
94
|
+
document?: { cookie?: string } | null;
|
|
95
|
+
/** How the personal channel is listened to: the channel runtime's listen(), unless a test hands in its own. */
|
|
96
|
+
listen?: typeof import('./channel.js').listen;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export declare function inboxClient<T extends string = string>(
|
|
100
|
+
name: string,
|
|
101
|
+
options?: InboxClientOptions,
|
|
102
|
+
): InboxClient<T>;
|
|
103
|
+
|
|
104
|
+
/** Builds the typed `inbox` codegen exports: a client for each #[Inbox], by name. */
|
|
105
|
+
export declare function inboxes<M extends { [K in keyof M]: NotificationShape }, N extends string>(
|
|
106
|
+
names: N[],
|
|
107
|
+
options?: InboxClientOptions,
|
|
108
|
+
): { [K in N]: InboxClient<keyof M & string> };
|