@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/LICENSE +21 -0
- package/README.md +34 -0
- package/package.json +52 -0
- package/src/card.js +94 -0
- package/src/classes.js +90 -0
- package/src/columns.js +124 -0
- package/src/index.js +23 -0
- package/src/logo-icon.js +125 -0
- package/src/registry.js +62 -0
- package/src/scroll-to-top.js +65 -0
- package/src/site.js +110 -0
- package/src/skill.js +200 -0
- package/src/styles.js +35 -0
- package/src/time-timer.js +251 -0
- package/src/tooltip.js +89 -0
- package/src/tree.js +88 -0
- package/styles/components.css +590 -0
- package/types/card.d.ts +72 -0
- package/types/classes.d.ts +54 -0
- package/types/columns.d.ts +56 -0
- package/types/index.d.ts +22 -0
- package/types/logo-icon.d.ts +39 -0
- package/types/registry.d.ts +29 -0
- package/types/scroll-to-top.d.ts +32 -0
- package/types/site.d.ts +68 -0
- package/types/skill.d.ts +46 -0
- package/types/styles.d.ts +16 -0
- package/types/time-timer.d.ts +61 -0
- package/types/tooltip.d.ts +34 -0
- package/types/tree.d.ts +44 -0
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
|
+
}
|