@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/tree.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Collapsible tree.
3
+ *
4
+ * No JavaScript: expanding is the native behaviour of `<details>`, which
5
+ * works from the keyboard and stays printable.
6
+ *
7
+ * The structure is a nested list, not a `role="tree"`. That role promises a
8
+ * screen reader arrow-key navigation that nothing here would implement:
9
+ * announcing it would lie about what the page can do.
10
+ *
11
+ * @module @docpensieve/components/tree
12
+ */
13
+
14
+ import { createContext, createElement as h, useContext } from 'react';
15
+
16
+ import { DocPensieveError } from '@docpensieve/shared';
17
+
18
+ import { classNames, cls } from './classes.js';
19
+
20
+ /**
21
+ * Marks the inside of a tree.
22
+ *
23
+ * A lone entry would produce an `<li>` outside any list: invalid HTML that no
24
+ * browser reports and nobody notices.
25
+ */
26
+ const InTree = createContext(false);
27
+
28
+ /**
29
+ * Root of a tree.
30
+ *
31
+ * @param {{ className?: string, style?: object, children?: any }} props
32
+ */
33
+ export function Tree({ className, style, children }) {
34
+ return h(
35
+ InTree.Provider,
36
+ { value: true },
37
+ h('ul', { className: classNames(cls('tree'), className), style }, children),
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Entry of a tree.
43
+ *
44
+ * With children, it is a collapsible branch; without, a leaf. The difference
45
+ * is read from the writing, with no prop to set.
46
+ *
47
+ * @param {{
48
+ * className?: string, style?: object, children?: any,
49
+ * label?: any, open?: boolean,
50
+ * }} props `open` expands the branch as soon as the page opens.
51
+ * @throws {DocPensieveError} Outside a `Tree`, or without a label.
52
+ */
53
+ export function TreeItem({ className, style, children, label, open = false }) {
54
+ const inTree = useContext(InTree);
55
+
56
+ if (!inTree) {
57
+ throw new DocPensieveError('A <TreeItem> was written outside a <Tree>.', {
58
+ hint: 'Wrap the entries: <Tree><TreeItem label="…" /></Tree>.',
59
+ });
60
+ }
61
+
62
+ if (label === undefined || label === null || label === '') {
63
+ throw new DocPensieveError('A <TreeItem> without a label.', {
64
+ hint: 'Give it a label: <TreeItem label="src" />.',
65
+ });
66
+ }
67
+
68
+ const leaf = children === undefined || children === null || children === false;
69
+
70
+ if (leaf) {
71
+ return h(
72
+ 'li',
73
+ { className: classNames(cls('treeItem', 'leaf'), className), style },
74
+ h('span', { className: cls('treeLabel') }, label),
75
+ );
76
+ }
77
+
78
+ return h(
79
+ 'li',
80
+ { className: classNames(cls('treeItem', 'branch'), className), style },
81
+ h(
82
+ 'details',
83
+ { className: cls('treeDetails'), open },
84
+ h('summary', { className: cls('treeLabel') }, label),
85
+ h('ul', { className: cls('tree') }, children),
86
+ ),
87
+ );
88
+ }
@@ -0,0 +1,590 @@
1
+ /*
2
+ * Default structure of the shipped components.
3
+ *
4
+ * Everything is in `@layer components`, below the utilities in layer order. A
5
+ * `className="border-0"` set at use therefore always wins, whatever the place
6
+ * of this stylesheet in the final file. Without this layer, these rules beat
7
+ * the utilities and the author's `className` was silently ignored.
8
+ *
9
+ * Only structure lives here — wrappers, separators, spacing. Typography and
10
+ * colours are set at use, through `className`.
11
+ *
12
+ * Values come from the `--dp-*` tokens: the components follow the active
13
+ * theme's palette without knowing anything about it.
14
+ */
15
+
16
+ @layer components {
17
+ /* --- Card -------------------------------------------------------------- */
18
+
19
+ /*
20
+ * No `overflow: hidden` here, though it was the shortest path for the image
21
+ * to follow the corners: it would clip **everything** that sticks out,
22
+ * starting with a tooltip opened from the card body. The image corners are
23
+ * therefore rounded one by one, below.
24
+ */
25
+ .dp-card {
26
+ display: flex;
27
+ flex-direction: column;
28
+ /*
29
+ * Two cards following each other in the flow of the text would touch. In
30
+ * a column, the grid gap already does that job: the margin is taken back
31
+ * below, otherwise both would add up.
32
+ */
33
+ margin-block-end: 1.3rem;
34
+ background: var(--dp-bg);
35
+ border: 1px solid var(--dp-border);
36
+ border-radius: var(--dp-radius);
37
+ color: inherit;
38
+ text-decoration: none;
39
+ }
40
+
41
+ .dp-card--elevated {
42
+ box-shadow: 0 4px 14px var(--dp-shadow);
43
+ }
44
+
45
+ .dp-column > .dp-card,
46
+ .dp-card-body > .dp-card:last-child {
47
+ margin-block-end: 0;
48
+ }
49
+
50
+ /* A clickable card must signal itself on hover. */
51
+ a.dp-card:hover {
52
+ border-color: var(--dp-accent);
53
+ }
54
+
55
+ .dp-card-header {
56
+ padding: 0.85rem 1.1rem;
57
+ border-bottom: 1px solid var(--dp-border);
58
+ font-weight: 600;
59
+ }
60
+
61
+ .dp-card-body {
62
+ flex: 1;
63
+ padding: 1.1rem;
64
+ }
65
+
66
+ .dp-card-footer {
67
+ padding: 0.85rem 1.1rem;
68
+ border-top: 1px solid var(--dp-border);
69
+ font-size: 0.9rem;
70
+ color: var(--dp-text-soft);
71
+ }
72
+
73
+ /* The content of a card does not have to inherit the prose margins. */
74
+ .dp-card-header > *:first-child,
75
+ .dp-card-body > *:first-child,
76
+ .dp-card-footer > *:first-child {
77
+ margin-top: 0;
78
+ }
79
+ .dp-card-header > *:last-child,
80
+ .dp-card-body > *:last-child,
81
+ .dp-card-footer > *:last-child {
82
+ margin-bottom: 0;
83
+ }
84
+
85
+ .dp-card-image {
86
+ display: block;
87
+ width: 100%;
88
+ height: auto;
89
+ }
90
+
91
+ /* The image only rounds the corners it actually occupies. */
92
+ .dp-card-image:first-child {
93
+ border-start-start-radius: var(--dp-radius);
94
+ border-start-end-radius: var(--dp-radius);
95
+ }
96
+
97
+ .dp-card-image:last-child {
98
+ border-end-start-radius: var(--dp-radius);
99
+ border-end-end-radius: var(--dp-radius);
100
+ }
101
+
102
+ /* --- Columns ----------------------------------------------------------- */
103
+
104
+ /*
105
+ * Without a declared width: as many columns as children, all equal.
106
+ * `minmax(0, 1fr)` rather than `1fr` — otherwise unbreakable content, a
107
+ * code block for instance, widens its column and unbalances the row.
108
+ */
109
+ .dp-columns {
110
+ display: grid;
111
+ grid-auto-flow: column;
112
+ grid-auto-columns: minmax(0, 1fr);
113
+ gap: 1rem;
114
+ align-items: stretch;
115
+ width: 100%;
116
+ margin: 0 0 1.3rem;
117
+ }
118
+
119
+ /*
120
+ * With declared widths: twelve tracks, and each column takes what it asks
121
+ * for. The grid subtracts the gaps by itself, so a width has no calculation
122
+ * to make and changing `gap` breaks nothing.
123
+ */
124
+ .dp-columns--twelfths {
125
+ grid-auto-flow: row;
126
+ grid-template-columns: repeat(12, minmax(0, 1fr));
127
+ }
128
+
129
+ .dp-column {
130
+ min-width: 0;
131
+ }
132
+
133
+ .dp-column--span-1 {
134
+ grid-column: span 1;
135
+ }
136
+ .dp-column--span-2 {
137
+ grid-column: span 2;
138
+ }
139
+ .dp-column--span-3 {
140
+ grid-column: span 3;
141
+ }
142
+ .dp-column--span-4 {
143
+ grid-column: span 4;
144
+ }
145
+ .dp-column--span-5 {
146
+ grid-column: span 5;
147
+ }
148
+ .dp-column--span-6 {
149
+ grid-column: span 6;
150
+ }
151
+ .dp-column--span-7 {
152
+ grid-column: span 7;
153
+ }
154
+ .dp-column--span-8 {
155
+ grid-column: span 8;
156
+ }
157
+ .dp-column--span-9 {
158
+ grid-column: span 9;
159
+ }
160
+ .dp-column--span-10 {
161
+ grid-column: span 10;
162
+ }
163
+ .dp-column--span-11 {
164
+ grid-column: span 11;
165
+ }
166
+ .dp-column--span-12 {
167
+ grid-column: span 12;
168
+ }
169
+
170
+ /* Below tablet width, a grid of columns becomes unreadable. */
171
+ @media (max-width: 48rem) {
172
+ .dp-columns,
173
+ .dp-columns--twelfths {
174
+ grid-auto-flow: row;
175
+ grid-template-columns: minmax(0, 1fr);
176
+ }
177
+
178
+ .dp-column,
179
+ .dp-column[class*='dp-column--span-'] {
180
+ grid-column: auto;
181
+ }
182
+ }
183
+
184
+ /* --- Tooltip ----------------------------------------------------------- */
185
+
186
+ /*
187
+ * The bubble is positioned absolutely: inside a container that clips its
188
+ * overflow, it will be cut. That is the limit of a tooltip without
189
+ * JavaScript, and it is accepted.
190
+ */
191
+ .dp-tooltip {
192
+ position: relative;
193
+ display: inline-block;
194
+ }
195
+
196
+ /*
197
+ * Underlined in the text colour. The border tint fell to 1.2:1 against the
198
+ * background: nobody saw there was something to hover.
199
+ */
200
+ .dp-tooltip-trigger {
201
+ border-bottom: 1px dashed currentColor;
202
+ cursor: help;
203
+ }
204
+
205
+ /*
206
+ * An invisible margin around the bubble. Without it, the pointer left the
207
+ * term, crossed the gap and lost the hover before reaching the bubble, which
208
+ * closed under it. Hidden along with the bubble, it catches nothing when the
209
+ * bubble is closed.
210
+ */
211
+ .dp-tooltip-bubble::after {
212
+ content: '';
213
+ position: absolute;
214
+ inset: -0.6rem;
215
+ }
216
+
217
+ .dp-tooltip-bubble {
218
+ position: absolute;
219
+ z-index: 20;
220
+ width: max-content;
221
+ max-width: 16rem;
222
+ padding: 0.4rem 0.6rem;
223
+ border-radius: var(--dp-radius);
224
+ background: var(--dp-text);
225
+ color: var(--dp-bg);
226
+ font-size: 0.85rem;
227
+ line-height: 1.35;
228
+ text-align: left;
229
+ /*
230
+ * `visibility` on top of the opacity: a merely transparent bubble would
231
+ * stay hoverable and intercept the pointer.
232
+ */
233
+ visibility: hidden;
234
+ opacity: 0;
235
+ transition:
236
+ opacity 0.12s ease,
237
+ visibility 0.12s;
238
+ }
239
+
240
+ .dp-tooltip:hover .dp-tooltip-bubble,
241
+ .dp-tooltip:focus-within .dp-tooltip-bubble {
242
+ visibility: visible;
243
+ opacity: 1;
244
+ }
245
+
246
+ .dp-tooltip--top .dp-tooltip-bubble {
247
+ bottom: calc(100% + 0.45rem);
248
+ left: 50%;
249
+ translate: -50% 0;
250
+ }
251
+
252
+ .dp-tooltip--bottom .dp-tooltip-bubble {
253
+ top: calc(100% + 0.45rem);
254
+ left: 50%;
255
+ translate: -50% 0;
256
+ }
257
+
258
+ .dp-tooltip--left .dp-tooltip-bubble {
259
+ top: 50%;
260
+ right: calc(100% + 0.45rem);
261
+ translate: 0 -50%;
262
+ }
263
+
264
+ .dp-tooltip--right .dp-tooltip-bubble {
265
+ top: 50%;
266
+ left: calc(100% + 0.45rem);
267
+ translate: 0 -50%;
268
+ }
269
+
270
+ /* --- Tree -------------------------------------------------------------- */
271
+
272
+ .dp-tree {
273
+ margin: 0 0 1.3rem;
274
+ padding-left: 0;
275
+ font-family: var(--dp-font-mono);
276
+ font-size: 0.9rem;
277
+ list-style: none;
278
+ }
279
+
280
+ /* Nested levels are indented and carry a reminder rule. */
281
+ .dp-tree .dp-tree {
282
+ margin: 0;
283
+ padding-left: 0.85rem;
284
+ border-left: 1px solid var(--dp-rule);
285
+ }
286
+
287
+ .dp-tree-item {
288
+ margin: 0.1rem 0;
289
+ }
290
+
291
+ .dp-tree-label {
292
+ display: inline-flex;
293
+ align-items: center;
294
+ gap: 0.4rem;
295
+ padding: 0.1rem 0.35rem;
296
+ border-radius: var(--dp-radius);
297
+ }
298
+
299
+ summary.dp-tree-label {
300
+ cursor: pointer;
301
+ /* The native triangle changes shape from one browser to another. */
302
+ list-style: none;
303
+ }
304
+
305
+ summary.dp-tree-label::-webkit-details-marker {
306
+ display: none;
307
+ }
308
+
309
+ summary.dp-tree-label:hover {
310
+ background: var(--dp-bg-soft);
311
+ }
312
+
313
+ summary.dp-tree-label::before {
314
+ content: '';
315
+ width: 0.38rem;
316
+ height: 0.38rem;
317
+ border-right: 1.5px solid currentColor;
318
+ border-bottom: 1.5px solid currentColor;
319
+ rotate: -45deg;
320
+ transition: rotate 0.15s ease;
321
+ }
322
+
323
+ .dp-tree-details[open] > summary.dp-tree-label::before {
324
+ rotate: 45deg;
325
+ }
326
+
327
+ /* A leaf has no chevron: its label lines up with the branches. */
328
+ .dp-tree-item--leaf > .dp-tree-label {
329
+ padding-left: 1.13rem;
330
+ }
331
+
332
+ /* --- Back to top ------------------------------------------------------- */
333
+
334
+ .dp-scroll-top {
335
+ position: fixed;
336
+ right: 1.25rem;
337
+ bottom: 1.25rem;
338
+ z-index: 30;
339
+ display: inline-flex;
340
+ align-items: center;
341
+ justify-content: center;
342
+ width: 2.5rem;
343
+ height: 2.5rem;
344
+ border: 1px solid var(--dp-border);
345
+ border-radius: 999px;
346
+ background: var(--dp-bg);
347
+ box-shadow: 0 4px 14px var(--dp-shadow);
348
+ color: var(--dp-text);
349
+ text-decoration: none;
350
+ }
351
+
352
+ .dp-scroll-top:hover {
353
+ border-color: var(--dp-accent);
354
+ color: var(--dp-accent);
355
+ }
356
+
357
+ .dp-scroll-top-icon {
358
+ width: 1.1rem;
359
+ height: 1.1rem;
360
+ }
361
+
362
+ @keyframes dp-scroll-top-appear {
363
+ from {
364
+ visibility: hidden;
365
+ opacity: 0;
366
+ scale: 0.8;
367
+ }
368
+ to {
369
+ visibility: visible;
370
+ opacity: 1;
371
+ scale: 1;
372
+ }
373
+ }
374
+
375
+ /*
376
+ * The button only appears once the page has scrolled. Where the scroll
377
+ * timeline is missing, it simply stays visible: always there is better than
378
+ * never there.
379
+ */
380
+ @supports (animation-timeline: scroll()) {
381
+ @media (prefers-reduced-motion: no-preference) {
382
+ .dp-scroll-top {
383
+ animation: dp-scroll-top-appear linear both;
384
+ animation-timeline: scroll(root block);
385
+ animation-range: 0 6rem;
386
+ }
387
+ }
388
+ }
389
+
390
+ /*
391
+ * The component exists to scroll: smooth scrolling is therefore its
392
+ * business, but only on the pages that use it.
393
+ */
394
+ @media (prefers-reduced-motion: no-preference) {
395
+ html:has(.dp-scroll-top) {
396
+ scroll-behavior: smooth;
397
+ }
398
+ }
399
+
400
+ /* --- Level gauge ------------------------------------------------------- */
401
+
402
+ .dp-skill {
403
+ margin: 0 0 1rem;
404
+ }
405
+
406
+ .dp-skill-head {
407
+ display: flex;
408
+ gap: 0.5rem;
409
+ align-items: baseline;
410
+ justify-content: space-between;
411
+ margin-bottom: 0.3rem;
412
+ }
413
+
414
+ .dp-skill-name {
415
+ display: inline-flex;
416
+ align-items: center;
417
+ gap: 0.4rem;
418
+ font-weight: 600;
419
+ }
420
+
421
+ /*
422
+ * The icon takes the gauge's tint: both designate the same thing, and seeing
423
+ * them match helps read a column of gauges at a glance.
424
+ */
425
+ .dp-skill-icon {
426
+ display: inline-flex;
427
+ color: var(--dp-skill-color, var(--dp-accent));
428
+ }
429
+
430
+ .dp-skill-value {
431
+ color: var(--dp-text-soft);
432
+ font-size: 0.85rem;
433
+ /* Fixed-width digits: a column of values stays aligned. */
434
+ font-variant-numeric: tabular-nums;
435
+ }
436
+
437
+ .dp-skill-track {
438
+ overflow: hidden;
439
+ height: 0.5rem;
440
+ border-radius: 999px;
441
+ background: var(--dp-bg-soft);
442
+ }
443
+
444
+ .dp-skill-fill {
445
+ width: var(--dp-skill-level, 0%);
446
+ height: 100%;
447
+ border-radius: inherit;
448
+ background: var(--dp-skill-color, var(--dp-accent));
449
+ }
450
+
451
+ .dp-skill-note {
452
+ margin-top: 0.35rem;
453
+ color: var(--dp-text-soft);
454
+ font-size: 0.85rem;
455
+ }
456
+
457
+ @keyframes dp-skill-grow {
458
+ from {
459
+ width: 0;
460
+ }
461
+ to {
462
+ width: var(--dp-skill-level, 0%);
463
+ }
464
+ }
465
+
466
+ /* The fill plays as it enters the viewport, where that is possible.
467
+ Elsewhere the bar is full from the start: the value is still read. */
468
+ @supports (animation-timeline: view()) {
469
+ @media (prefers-reduced-motion: no-preference) {
470
+ .dp-skill-fill {
471
+ animation: dp-skill-grow linear both;
472
+ animation-timeline: view();
473
+ animation-range: entry 10% cover 60%;
474
+ }
475
+ }
476
+ }
477
+
478
+ /* --- Icon -------------------------------------------------------------- */
479
+
480
+ .dp-logo-icon {
481
+ display: inline-flex;
482
+ align-items: center;
483
+ justify-content: center;
484
+ inline-size: var(--dp-logo-icon-size, 1.5rem);
485
+ block-size: var(--dp-logo-icon-size, 1.5rem);
486
+ color: inherit;
487
+ vertical-align: -0.15em;
488
+ }
489
+
490
+ /*
491
+ * The SVG fills its wrapper. Its colour stays the file's business: a `fill`
492
+ * declaration here would override the `fill="none"` of an outline icon —
493
+ * CSS always wins over a presentation attribute — and make it solid. A file
494
+ * that relies on `currentColor` already follows the text colour through the
495
+ * inheritance set on the wrapper.
496
+ */
497
+ .dp-logo-icon > svg {
498
+ inline-size: 100%;
499
+ block-size: 100%;
500
+ }
501
+
502
+ /* --- Reduced motion ---------------------------------------------------- */
503
+
504
+ @media (prefers-reduced-motion: reduce) {
505
+ .dp-tooltip-bubble,
506
+ summary.dp-tree-label::before {
507
+ transition: none;
508
+ }
509
+ }
510
+
511
+ /* --- Circle gauge ------------------------------------------------------ */
512
+
513
+ .dp-skill--circle {
514
+ display: inline-flex;
515
+ flex-direction: column;
516
+ align-items: center;
517
+ gap: 0.4rem;
518
+ margin-inline-end: 1.25rem;
519
+ text-align: center;
520
+ }
521
+
522
+ .dp-skill-dial {
523
+ position: relative;
524
+ inline-size: var(--dp-skill-size, 6rem);
525
+ block-size: var(--dp-skill-size, 6rem);
526
+ }
527
+
528
+ .dp-skill-dial svg {
529
+ inline-size: 100%;
530
+ block-size: 100%;
531
+ /* The stroke starts at three o'clock: bring it back to the top. */
532
+ rotate: -90deg;
533
+ }
534
+
535
+ .dp-skill-dial-track,
536
+ .dp-skill-dial-fill {
537
+ fill: none;
538
+ stroke-width: 3.2;
539
+ }
540
+
541
+ .dp-skill-dial-track {
542
+ stroke: var(--dp-bg-soft);
543
+ }
544
+
545
+ /*
546
+ * The radius gives the circle a circumference of a hundred: the level is
547
+ * therefore directly the drawn share, with no multiplication or rounding.
548
+ */
549
+ .dp-skill-dial-fill {
550
+ stroke: var(--dp-skill-color, var(--dp-accent));
551
+ stroke-dasharray: var(--dp-skill-level, 0) 100;
552
+ stroke-linecap: round;
553
+ }
554
+
555
+ .dp-skill-dial-value {
556
+ position: absolute;
557
+ inset: 0;
558
+ display: flex;
559
+ align-items: center;
560
+ justify-content: center;
561
+ font-size: 1.05rem;
562
+ font-weight: 600;
563
+ /* Fixed-width digits: the value does not dance from one gauge to the next. */
564
+ font-variant-numeric: tabular-nums;
565
+ }
566
+
567
+ .dp-skill--circle .dp-skill-name {
568
+ justify-content: center;
569
+ font-size: 0.9rem;
570
+ }
571
+
572
+ @keyframes dp-skill-draw {
573
+ from {
574
+ stroke-dasharray: 0 100;
575
+ }
576
+ to {
577
+ stroke-dasharray: var(--dp-skill-level, 0) 100;
578
+ }
579
+ }
580
+
581
+ @supports (animation-timeline: view()) {
582
+ @media (prefers-reduced-motion: no-preference) {
583
+ .dp-skill-dial-fill {
584
+ animation: dp-skill-draw linear both;
585
+ animation-timeline: view();
586
+ animation-range: entry 10% cover 60%;
587
+ }
588
+ }
589
+ }
590
+ }