@docpensieve/components 0.1.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/src/site.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Page context handed to components.
3
+ *
4
+ * The compiler plugins rewrite the URLs of the tree built from the Markdown,
5
+ * but that work happens **before** React renders the components: a link
6
+ * produced by a component escapes them. This module gives it what it needs
7
+ * to resolve itself, following the same rules (ADR-006).
8
+ *
9
+ * It also carries what is needed to find a **file** of the version, which a
10
+ * component that includes a resource at build time requires.
11
+ *
12
+ * @module @docpensieve/components/site
13
+ */
14
+
15
+ import path from 'node:path';
16
+
17
+ /**
18
+ * Targets left as they are: external link, anchor, mailto, data:.
19
+ * Same rule as in the compiler.
20
+ */
21
+ const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
22
+
23
+ /**
24
+ * @typedef {object} SiteContext
25
+ * @property {string} url URL of the page being rendered.
26
+ * @property {string} [dirUrl] Folder of the source file, mapped into URL
27
+ * space. Base of relative targets: the page URL has one more level.
28
+ * @property {string} basePath Version root, deployment prefix included.
29
+ * @property {string} [filepath] Source file of the page, on disk.
30
+ * @property {string} [sourceDir] Source folder of the version.
31
+ */
32
+
33
+ /** @type {SiteContext} */
34
+ let context = { url: '/', basePath: '/' };
35
+
36
+ /**
37
+ * Declares the page being rendered.
38
+ *
39
+ * Set by the generator before every page, like the class table.
40
+ *
41
+ * @param {Partial<SiteContext>} [page]
42
+ */
43
+ export function setSiteContext(page = {}) {
44
+ context = {
45
+ url: page.url ?? '/',
46
+ dirUrl: page.dirUrl,
47
+ basePath: page.basePath ?? '/',
48
+ filepath: page.filepath,
49
+ sourceDir: page.sourceDir,
50
+ };
51
+ }
52
+
53
+ /** @returns {SiteContext} The current context. */
54
+ export function getSiteContext() {
55
+ return context;
56
+ }
57
+
58
+ /**
59
+ * Resolves a target written by an author into a site URL.
60
+ *
61
+ * Same rules as for Markdown content: a relative target resolves against the
62
+ * page's folder, an absolute target starts from the version root.
63
+ *
64
+ * @param {string | undefined} target
65
+ * @returns {string | undefined} The resolved target, or as is when external.
66
+ */
67
+ export function resolveUrl(target) {
68
+ if (typeof target !== 'string' || target === '' || EXTERNAL.test(target)) return target;
69
+
70
+ const { url, dirUrl, basePath } = context;
71
+
72
+ if (target.startsWith('/')) {
73
+ if (basePath === '/' || target.startsWith(basePath)) return target;
74
+ return `${basePath.replace(/\/$/, '')}${target}`;
75
+ }
76
+
77
+ // The origin is throwaway: only the resolved path matters. The base is the
78
+ // source file's folder, not the page URL — see `dirUrl`.
79
+ const resolved = new URL(target, `https://docpensieve.invalid${dirUrl ?? url}`);
80
+ return resolved.pathname + resolved.search + resolved.hash;
81
+ }
82
+
83
+ /**
84
+ * Resolves a target into a file path, for a resource read at build time.
85
+ *
86
+ * Same landmarks as for a URL, mapped to the disk: a relative target starts
87
+ * from the page's file, an absolute one from the version folder. Nothing can
88
+ * leave that folder — a page does not read the rest of the machine.
89
+ *
90
+ * @param {string} target
91
+ * @returns {string} Absolute path, inside the source folder.
92
+ * @throws {Error} When the context is missing or the target escapes it.
93
+ */
94
+ export function resolveFile(target) {
95
+ const { filepath, sourceDir } = context;
96
+ if (!sourceDir) {
97
+ throw new Error('No source folder known: the page context was not set.');
98
+ }
99
+
100
+ const base = target.startsWith('/')
101
+ ? path.join(sourceDir, target.slice(1))
102
+ : path.resolve(path.dirname(filepath ?? sourceDir), target);
103
+
104
+ const resolved = path.resolve(base);
105
+ const root = path.resolve(sourceDir);
106
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
107
+ throw new Error(`The target "${target}" leaves the version folder.`);
108
+ }
109
+ return resolved;
110
+ }
package/src/skill.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Level gauge, as a bar or a circle.
3
+ *
4
+ * No JavaScript: the fill is done in CSS, and animates as it enters the
5
+ * viewport where `animation-timeline` exists. Elsewhere, the gauge is simply
6
+ * full — the value stays readable, which is what matters.
7
+ *
8
+ * @module @docpensieve/components/skill
9
+ */
10
+
11
+ import { Fragment, createElement as h } from 'react';
12
+
13
+ import { DocPensieveError } from '@docpensieve/shared';
14
+
15
+ import { classNames, cls } from './classes.js';
16
+
17
+ /** Shapes accepted by the gauge. */
18
+ export const SKILL_SHAPES = Object.freeze(['bar', 'circle']);
19
+
20
+ /**
21
+ * Radius giving the circle a circumference of a hundred units.
22
+ *
23
+ * Chosen for that reason: the level then goes as is into `stroke-dasharray`,
24
+ * with no multiplication or rounding. `100 / (2 * Math.PI)`.
25
+ */
26
+ const RADIUS = 15.915_494;
27
+
28
+ /**
29
+ * Bar gauge.
30
+ *
31
+ * @param {{ level: number, name: any, showValue: boolean, icon?: any, accessibleName: string }} props
32
+ * @returns {any}
33
+ */
34
+ function Bar({ level, name, showValue, icon, accessibleName }) {
35
+ // Fragment rather than a div: one more wrapper would bring nothing and
36
+ // would stand between the gauge and its parts.
37
+ return h(
38
+ Fragment,
39
+ null,
40
+ h(
41
+ 'div',
42
+ { className: cls('skillHead') },
43
+ h(
44
+ 'span',
45
+ { className: cls('skillName') },
46
+ icon ? h('span', { className: cls('skillIcon') }, icon) : null,
47
+ name,
48
+ ),
49
+ showValue ? h('span', { className: cls('skillValue') }, `${level}%`) : null,
50
+ ),
51
+ h(
52
+ 'div',
53
+ { className: cls('skillTrack'), ...measure(level, accessibleName) },
54
+ h('div', {
55
+ className: cls('skillFill'),
56
+ // The value goes through a variable: the entry animation uses it as
57
+ // its end point, and the base rule as the width.
58
+ style: { '--dp-skill-level': `${level}%` },
59
+ }),
60
+ ),
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Circle gauge.
66
+ *
67
+ * The SVG is decorative: the wrapper carries the role and the value, so what
68
+ * is announced does not depend on what is drawn.
69
+ *
70
+ * @param {{ level: number, name: any, showValue: boolean, icon?: any, accessibleName: string }} props
71
+ * @returns {any}
72
+ */
73
+ function Circle({ level, name, showValue, icon, accessibleName }) {
74
+ return h(
75
+ Fragment,
76
+ null,
77
+ h(
78
+ 'div',
79
+ { className: cls('skillDial'), ...measure(level, accessibleName) },
80
+ h(
81
+ 'svg',
82
+ { viewBox: '0 0 36 36', 'aria-hidden': 'true', focusable: 'false' },
83
+ h('circle', { className: cls('skillDialTrack'), cx: 18, cy: 18, r: RADIUS }),
84
+ h('circle', {
85
+ className: cls('skillDialFill'),
86
+ cx: 18,
87
+ cy: 18,
88
+ r: RADIUS,
89
+ // With a circumference of a hundred, the level is directly the
90
+ // drawn share. The entry animation starts from zero and ends here.
91
+ style: { '--dp-skill-level': String(level) },
92
+ }),
93
+ ),
94
+ showValue ? h('span', { className: cls('skillDialValue') }, `${level}%`) : null,
95
+ ),
96
+ h(
97
+ 'div',
98
+ { className: cls('skillName') },
99
+ icon ? h('span', { className: cls('skillIcon') }, icon) : null,
100
+ name,
101
+ ),
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Attributes describing the measure.
107
+ *
108
+ * `meter` describes exactly this: a value within a known range. The role
109
+ * carries it, so hiding it on screen does not remove it from what a screen
110
+ * reader announces.
111
+ *
112
+ * @param {number} level
113
+ * @param {string} name Name announced by a screen reader.
114
+ * @returns {Record<string, unknown>}
115
+ */
116
+ function measure(level, name) {
117
+ return {
118
+ role: 'meter',
119
+ 'aria-valuenow': level,
120
+ 'aria-valuemin': 0,
121
+ 'aria-valuemax': 100,
122
+ 'aria-label': name,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Named gauge, from 0 to 100.
128
+ *
129
+ * @example
130
+ * <Skill name="Accessibility" level={80} />
131
+ * <Skill name="Accessibility" level={80} shape="circle" />
132
+ *
133
+ * @param {{
134
+ * className?: string, style?: object, children?: any,
135
+ * name?: any, level?: number, showValue?: boolean, shape?: string,
136
+ * icon?: any, color?: string, label?: string,
137
+ * }} props `children` stands as a comment under the gauge. `showValue` hides
138
+ * the numeric percentage without touching what the gauge announces.
139
+ * `shape` picks between the bar and the circle. `icon` goes before the
140
+ * name — a `LogoIcon` fits there. `color` tints the fill: any CSS colour,
141
+ * the accent colour by default. `label` names the gauge for screen readers
142
+ * when `name` is not text.
143
+ * @throws {DocPensieveError} Without a name, outside 0–100, or with an
144
+ * unknown shape.
145
+ */
146
+ export function Skill({
147
+ className,
148
+ style,
149
+ children,
150
+ name,
151
+ level,
152
+ showValue = true,
153
+ shape = 'bar',
154
+ icon,
155
+ color,
156
+ label,
157
+ }) {
158
+ if (name === undefined || name === null || name === '') {
159
+ throw new DocPensieveError('A <Skill> without a name.', {
160
+ hint: 'Give it a name: <Skill name="CSS" level={80} />.',
161
+ });
162
+ }
163
+
164
+ if (typeof level !== 'number' || Number.isNaN(level) || level < 0 || level > 100) {
165
+ throw new DocPensieveError(`Invalid level for "${name}": "${level}".`, {
166
+ hint: 'level expects a number from 0 to 100.',
167
+ });
168
+ }
169
+
170
+ if (!SKILL_SHAPES.includes(shape)) {
171
+ throw new DocPensieveError(`Unknown gauge shape: "${shape}".`, {
172
+ hint: `Accepted values: ${SKILL_SHAPES.join(', ')}.`,
173
+ });
174
+ }
175
+
176
+ // The name a screen reader announces: `name` if it is text, otherwise
177
+ // `label`. With neither, the gauge announced itself as “45%” without saying
178
+ // of what — the only case where this component kept quiet instead of
179
+ // throwing.
180
+ const accessibleName = typeof name === 'string' ? name : label;
181
+ if (typeof accessibleName !== 'string' || accessibleName === '') {
182
+ throw new DocPensieveError('A <Skill> whose name is not text must carry label.', {
183
+ hint: 'Add label="…": it is the name a screen reader will read.',
184
+ });
185
+ }
186
+
187
+ const Shape = shape === 'circle' ? Circle : Bar;
188
+
189
+ return h(
190
+ 'div',
191
+ {
192
+ className: classNames(cls('skill', shape === 'circle' && 'circle'), className),
193
+ // The tint goes through a variable: bar and circle read it in the same
194
+ // place, and a project can set it higher up for a whole group.
195
+ style: color ? { '--dp-skill-color': color, ...style } : style,
196
+ },
197
+ h(Shape, { level, name, showValue, icon, accessibleName }),
198
+ children ? h('div', { className: cls('skillNote') }, children) : null,
199
+ );
200
+ }
package/src/styles.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Stylesheet of the shipped components.
3
+ *
4
+ * @module @docpensieve/components/styles
5
+ */
6
+
7
+ import { readFile } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { DocPensieveError } from '@docpensieve/shared';
12
+
13
+ /** Stylesheet folder, resolved from this module rather than from the cwd. */
14
+ const STYLES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'styles');
15
+
16
+ /**
17
+ * Reads the default look of the components.
18
+ *
19
+ * It is concatenated with the theme's by the caller: a provider that
20
+ * redefines a slot replaces the `dp-*` class with its own, and these rules
21
+ * then stop applying by themselves.
22
+ *
23
+ * @returns {Promise<string>} CSS, trimmed.
24
+ * @throws {DocPensieveError} When the stylesheet is missing.
25
+ */
26
+ export async function componentsCss() {
27
+ try {
28
+ return (await readFile(path.join(STYLES_DIR, 'components.css'), 'utf8')).trim();
29
+ } catch (cause) {
30
+ throw new DocPensieveError('Components stylesheet not found.', {
31
+ cause,
32
+ hint: 'The @docpensieve/components package looks incomplete: reinstall the dependencies.',
33
+ });
34
+ }
35
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Conditional display by date or duration.
3
+ *
4
+ * **Mind the meaning on a static site**: “right now” means the moment of the
5
+ * **build**, not of reading. A page built in November will still show “the
6
+ * offer starts soon” in January if the site has not been rebuilt in between.
7
+ *
8
+ * This is not a flaw: it is what a time-based component becomes without
9
+ * client-side JavaScript. A scheduled build — a cron job in CI — is enough to
10
+ * keep it right.
11
+ *
12
+ * @module @docpensieve/components/time-timer
13
+ */
14
+
15
+ import { Children, Fragment, cloneElement, createElement as h, isValidElement } from 'react';
16
+
17
+ import { DocPensieveError } from '@docpensieve/shared';
18
+
19
+ /** Matches a duration: `30d`, `2h`, `45m`. */
20
+ const DURATION = /^(\d+)([dhm])$/;
21
+
22
+ /**
23
+ * Content shown before the period.
24
+ *
25
+ * No wrapper: a `span` around the author's content would become invalid
26
+ * markup as soon as they write a paragraph in it — which happens as soon as a
27
+ * blank line separates their text. The content therefore keeps its nature,
28
+ * inline or block.
29
+ *
30
+ * @param {{ children?: any, start?: string }} props
31
+ */
32
+ export function FallbackBefore({ children }) {
33
+ return h(Fragment, null, children);
34
+ }
35
+
36
+ /**
37
+ * Content shown after the period.
38
+ *
39
+ * Without a wrapper, for the same reason as {@link FallbackBefore}.
40
+ *
41
+ * @param {{ children?: any, end?: string }} props
42
+ */
43
+ export function FallbackAfter({ children }) {
44
+ return h(Fragment, null, children);
45
+ }
46
+
47
+ /**
48
+ * Parses a date in the `DD/MM[/YYYY] [HH:mm]` format.
49
+ *
50
+ * @param {string | undefined} text
51
+ * @param {boolean} strict Read in UTC rather than in local time.
52
+ * @param {Date} now Provides the default year when it is omitted.
53
+ * @returns {Date | null}
54
+ */
55
+ function parseDate(text, strict, now) {
56
+ if (!text) return null;
57
+
58
+ const [datePart, timePart] = String(text).trim().split(/\s+/);
59
+ const [day, month, year] = datePart.split('/');
60
+ const [hours = '0', minutes = '0'] = timePart ? timePart.split(':') : [];
61
+
62
+ const targetYear = year ? Number(year) : strict ? now.getUTCFullYear() : now.getFullYear();
63
+
64
+ // Named fields rather than a spread array: `new Date(...array)` has no
65
+ // valid signature, the constructor expects distinct arguments.
66
+ const m = Number(month) - 1;
67
+ const d = Number(day);
68
+ const hh = Number(hours);
69
+ const mm = Number(minutes);
70
+
71
+ const date = strict
72
+ ? new Date(Date.UTC(targetYear, m, d, hh, mm))
73
+ : new Date(targetYear, m, d, hh, mm);
74
+
75
+ /*
76
+ * The constructor rolls over rather than failing: month 13 becomes January
77
+ * of the following year, 30 February becomes 1 March. A `NaN` therefore
78
+ * almost never happens, and a typo passed for a valid date — shifting the
79
+ * display without a word.
80
+ *
81
+ * So the resulting date is read back: if it does not say what was written,
82
+ * what was written does not exist.
83
+ */
84
+ const readBack = strict
85
+ ? [
86
+ date.getUTCFullYear(),
87
+ date.getUTCMonth(),
88
+ date.getUTCDate(),
89
+ date.getUTCHours(),
90
+ date.getUTCMinutes(),
91
+ ]
92
+ : [date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes()];
93
+
94
+ const invalid =
95
+ Number.isNaN(date.getTime()) ||
96
+ [targetYear, m, d, hh, mm].some((value, index) => value !== readBack[index]);
97
+
98
+ if (invalid) {
99
+ throw new DocPensieveError(`Invalid date in TimeTimer: "${text}".`, {
100
+ hint: 'Expected format: DD/MM/YYYY, optionally followed by HH:mm.',
101
+ });
102
+ }
103
+ return date;
104
+ }
105
+
106
+ /**
107
+ * Shifts a date by a duration.
108
+ *
109
+ * @param {Date} from
110
+ * @param {string} duration
111
+ * @param {boolean} strict
112
+ * @returns {Date}
113
+ */
114
+ function addDuration(from, duration, strict) {
115
+ const match = String(duration).trim().match(DURATION);
116
+ if (!match) {
117
+ // Showing nothing after a mere console warning would let a typo make a
118
+ // block vanish without a word — exactly what the project sets out to avoid.
119
+ throw new DocPensieveError(`Invalid duration in TimeTimer: "${duration}".`, {
120
+ hint: 'Accepted formats: 30d (days), 2h (hours), 45m (minutes).',
121
+ });
122
+ }
123
+
124
+ const [, amount, unit] = match;
125
+ const end = new Date(from);
126
+ const n = Number(amount);
127
+
128
+ if (unit === 'd') strict ? end.setUTCDate(end.getUTCDate() + n) : end.setDate(end.getDate() + n);
129
+ else if (unit === 'h')
130
+ strict ? end.setUTCHours(end.getUTCHours() + n) : end.setHours(end.getHours() + n);
131
+ else strict ? end.setUTCMinutes(end.getUTCMinutes() + n) : end.setMinutes(end.getMinutes() + n);
132
+
133
+ return end;
134
+ }
135
+
136
+ /**
137
+ * Places the current moment relative to the period.
138
+ *
139
+ * @param {Date} now
140
+ * @param {Date | null} start
141
+ * @param {Date | null} end
142
+ * @param {Date | null} beforeStart Lower bound of the “before” fallback.
143
+ * @param {Date | null} afterEnd Upper bound of the “after” fallback.
144
+ * @returns {'during' | 'before' | 'after' | 'none'}
145
+ */
146
+ function locate(now, start, end, beforeStart, afterEnd) {
147
+ if (!start || !end) return 'none';
148
+ if (now >= start && now <= end) return 'during';
149
+
150
+ if (now < start) {
151
+ if (beforeStart) return now >= beforeStart ? 'before' : 'none';
152
+ return 'before';
153
+ }
154
+ if (afterEnd) return now <= afterEnd ? 'after' : 'none';
155
+ return 'after';
156
+ }
157
+
158
+ /**
159
+ * Shows its content during a period, with fallbacks before and after.
160
+ *
161
+ * @example
162
+ * <TimeTimer date="25/12/2025">
163
+ * Merry Christmas
164
+ * <FallbackBefore>It is not Christmas yet</FallbackBefore>
165
+ * <FallbackAfter>Christmas is over</FallbackAfter>
166
+ * </TimeTimer>
167
+ *
168
+ * @param {{
169
+ * date?: string, start?: string, duration?: string,
170
+ * strict?: boolean, children?: any, now?: Date,
171
+ * }} props `now` only exists for tests: without it, the build moment stands.
172
+ */
173
+ export function TimeTimer({ date, start, duration, strict = false, children, now }) {
174
+ const current = now ?? new Date();
175
+
176
+ let startDate = null;
177
+ let endDate = null;
178
+
179
+ if (date && !start) {
180
+ startDate = parseDate(date, strict, current);
181
+ // A date alone covers the whole day.
182
+ if (startDate) endDate = addDuration(startDate, '1d', strict);
183
+ } else if (start && duration) {
184
+ startDate = parseDate(start, strict, current);
185
+ if (startDate) endDate = addDuration(startDate, duration, strict);
186
+ }
187
+
188
+ const { before, after, main } = extractFallbacks(children);
189
+
190
+ const state = locate(
191
+ current,
192
+ startDate,
193
+ endDate,
194
+ before?.props?.start ? parseDate(before.props.start, strict, current) : null,
195
+ after?.props?.end ? parseDate(after.props.end, strict, current) : null,
196
+ );
197
+
198
+ if (state === 'before') return before ?? null;
199
+ if (state === 'after') return after ?? null;
200
+ if (state === 'during') return h(Fragment, null, ...main);
201
+ return null;
202
+ }
203
+
204
+ /**
205
+ * Separates the fallbacks from the main content, at any depth.
206
+ *
207
+ * MDX wraps a component's children in a `<p>` when no blank line separates
208
+ * them — the most natural way to write, and the one of the usage examples. A
209
+ * search limited to the first level would then find nothing, and the
210
+ * component would show nothing at all, without saying so.
211
+ *
212
+ * @param {any} children
213
+ * @returns {{ before: any, after: any, main: any[] }}
214
+ */
215
+ function extractFallbacks(children) {
216
+ let before = null;
217
+ let after = null;
218
+
219
+ /**
220
+ * @param {any} nodes
221
+ * @returns {any[]} The same nodes, fallbacks removed.
222
+ */
223
+ function walk(nodes) {
224
+ return Children.toArray(nodes)
225
+ .map((child) => {
226
+ if (!isValidElement(child)) return child;
227
+
228
+ if (child.type === FallbackBefore) {
229
+ before ??= child;
230
+ return null;
231
+ }
232
+ if (child.type === FallbackAfter) {
233
+ after ??= child;
234
+ return null;
235
+ }
236
+
237
+ // `props` is not typed on an arbitrary element: open it here rather
238
+ // than impose a shape on everything an author can write.
239
+ const props = /** @type {{ children?: any }} */ (child.props);
240
+ if (props?.children === undefined) return child;
241
+ const rest = walk(props.children);
242
+ return cloneElement(child, { key: child.key }, ...rest);
243
+ })
244
+ .filter((child) => child !== null);
245
+ }
246
+
247
+ // The walk must come before building the object: in a literal, `before` and
248
+ // `after` would be read before `walk` fills them, and would always be null.
249
+ const main = walk(children);
250
+ return { before, after, main };
251
+ }
package/src/tooltip.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Tooltip on hover and from the keyboard.
3
+ *
4
+ * No JavaScript: the bubble is a real element that CSS reveals on hover and
5
+ * on focus. The trigger is therefore reachable from the keyboard, and the
6
+ * bubble announced through `aria-describedby` rather than guessed.
7
+ *
8
+ * @module @docpensieve/components/tooltip
9
+ */
10
+
11
+ import { Children, createElement as h, isValidElement, useId } from 'react';
12
+
13
+ import { DocPensieveError } from '@docpensieve/shared';
14
+
15
+ import { classNames, cls } from './classes.js';
16
+
17
+ /** Sides the bubble can sit on. */
18
+ export const TOOLTIP_PLACEMENTS = Object.freeze(['top', 'bottom', 'left', 'right']);
19
+
20
+ /**
21
+ * Removes the paragraph MDX sometimes wraps the term in.
22
+ *
23
+ * The content of a tag left alone on its line becomes a paragraph — and the
24
+ * formatter puts that content on its own line as soon as the tag is somewhat
25
+ * indented, inside a card or a column for instance. The trigger being an
26
+ * inline element, the result would be invalid markup that the browser would
27
+ * silently undo.
28
+ *
29
+ * A tooltip term is text by nature: unwrapping that paragraph gives back what
30
+ * the author meant to write.
31
+ *
32
+ * @param {any} children
33
+ * @returns {any}
34
+ */
35
+ function withoutParagraph(children) {
36
+ const nodes = Children.toArray(children);
37
+ if (nodes.length !== 1) return children;
38
+
39
+ const only = nodes[0];
40
+ if (!isValidElement(only) || only.type !== 'p') return children;
41
+
42
+ return /** @type {{ children?: any }} */ (only.props).children;
43
+ }
44
+
45
+ /**
46
+ * Term with a tooltip.
47
+ *
48
+ * @example
49
+ * <Tooltip text="Generation of a complete site">build</Tooltip>
50
+ *
51
+ * @param {{
52
+ * className?: string, style?: object, children?: any,
53
+ * text?: string, placement?: string,
54
+ * }} props `text` is the content of the bubble; the children are the term it
55
+ * explains.
56
+ * @throws {DocPensieveError} Without text, or with an unknown side.
57
+ */
58
+ export function Tooltip({ className, style, children, text, placement = 'top' }) {
59
+ // An empty bubble would never appear: better to say so at build time than
60
+ // to leave a hover with no effect.
61
+ if (typeof text !== 'string' || text.trim() === '') {
62
+ throw new DocPensieveError('A <Tooltip> without text.', {
63
+ hint: 'Give the content of the bubble: <Tooltip text="…">term</Tooltip>.',
64
+ });
65
+ }
66
+
67
+ if (!TOOLTIP_PLACEMENTS.includes(placement)) {
68
+ throw new DocPensieveError(`Unknown tooltip placement: "${placement}".`, {
69
+ hint: `Accepted values: ${TOOLTIP_PLACEMENTS.join(', ')}.`,
70
+ });
71
+ }
72
+
73
+ // `useId` gives an identifier stable for the page; its colons are valid in
74
+ // HTML but get in the way everywhere else, so they are removed.
75
+ const id = `dp-tooltip-${useId().replace(/:/g, '')}`;
76
+
77
+ return h(
78
+ 'span',
79
+ { className: classNames(cls('tooltip', placement), className), style },
80
+ // `tabIndex` makes the term reachable from the keyboard: without it, the
81
+ // bubble would only exist for mouse users.
82
+ h(
83
+ 'span',
84
+ { className: cls('tooltipTrigger'), tabIndex: 0, 'aria-describedby': id },
85
+ withoutParagraph(children),
86
+ ),
87
+ h('span', { className: cls('tooltipBubble'), id, role: 'tooltip' }, text),
88
+ );
89
+ }