hlmnhkl-ui 0.25.1 → 0.27.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/README.md +1 -1
- package/package.json +1 -1
- package/src/components/Fab/Fab.module.css +172 -0
- package/src/components/Fab/Fab.tsx +117 -0
- package/src/components/Fab/index.ts +1 -0
- package/src/components/Prose/Prose.module.css +115 -0
- package/src/components/Prose/Prose.tsx +14 -0
- package/src/components/Prose/index.ts +1 -0
- package/src/components/TableOfContents/TableOfContents.module.css +107 -0
- package/src/components/TableOfContents/TableOfContents.tsx +93 -0
- package/src/components/TableOfContents/index.ts +1 -0
- package/src/components/ThemeToggle/ThemeToggle.module.css +21 -0
- package/src/components/ThemeToggle/ThemeToggle.tsx +69 -0
- package/src/components/ThemeToggle/index.ts +1 -0
- package/src/tokens.css +8 -0
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ Setup for other tools: [MCP server](https://ui.hlmnhkl.my/mcp).
|
|
|
65
65
|
## What you get
|
|
66
66
|
|
|
67
67
|
- **The source, not a package.** The command copies plain files into your app. Change anything; there is no update to wait for.
|
|
68
|
-
- **
|
|
68
|
+
- **63 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
|
|
69
69
|
- **One look, set by tokens.** Every colour, size and timing comes from one file, `tokens.css`. Colour appears only to mean something: good, caution or bad.
|
|
70
70
|
- **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
|
|
71
71
|
- **Accessible.** Keyboard support per the WAI-ARIA patterns, visible focus, screen reader labels, and reduced motion. On touch screens every control gives at least a 44px target, even the small sizes; only the six PIN boxes and the seven calendar days get narrower when the row has no room, about 36px wide on a 320px phone. CalendarHeatmap's days are small by design, since a year must fit on screen; its month table gives the same numbers at full size.
|
package/package.json
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/* In the thumb zone: the bottom corner of the screen, clear of the home indicator and a notch. */
|
|
2
|
+
.root {
|
|
3
|
+
position: fixed;
|
|
4
|
+
bottom: calc(env(safe-area-inset-bottom) + var(--hl-fab-gap));
|
|
5
|
+
z-index: var(--hl-z-sticky);
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
align-items: flex-end;
|
|
9
|
+
gap: var(--hl-space-4);
|
|
10
|
+
pointer-events: none;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.end { right: calc(env(safe-area-inset-right) + var(--hl-fab-gap)); }
|
|
14
|
+
|
|
15
|
+
.center {
|
|
16
|
+
left: 50%;
|
|
17
|
+
align-items: center;
|
|
18
|
+
translate: -50% 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.contained { position: absolute; }
|
|
22
|
+
.contained.end { right: var(--hl-fab-gap); }
|
|
23
|
+
.contained { bottom: var(--hl-fab-gap); }
|
|
24
|
+
|
|
25
|
+
.fab {
|
|
26
|
+
position: relative;
|
|
27
|
+
display: inline-flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
min-width: var(--hl-fab-size);
|
|
30
|
+
height: var(--hl-fab-size);
|
|
31
|
+
padding: 0 calc((var(--hl-fab-size) - var(--hl-icon-md)) / 2);
|
|
32
|
+
border: 0;
|
|
33
|
+
border-radius: var(--hl-radius-full);
|
|
34
|
+
background: var(--hl-color-accent);
|
|
35
|
+
color: var(--hl-color-accent-fg);
|
|
36
|
+
box-shadow: var(--hl-shadow-3);
|
|
37
|
+
font: inherit;
|
|
38
|
+
font-weight: var(--hl-weight-medium);
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
pointer-events: auto;
|
|
41
|
+
-webkit-tap-highlight-color: transparent;
|
|
42
|
+
transition:
|
|
43
|
+
background-color var(--hl-motion-fast) var(--hl-ease-out),
|
|
44
|
+
scale var(--hl-motion-fast) var(--hl-ease-out);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@media (hover: hover) {
|
|
48
|
+
.fab:hover { background: var(--hl-color-inverse-hover); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.fab:active { scale: var(--hl-press-scale); }
|
|
52
|
+
.fab:focus-visible { outline: none; box-shadow: var(--hl-shadow-3), var(--hl-focus-ring); }
|
|
53
|
+
|
|
54
|
+
.icon {
|
|
55
|
+
display: grid;
|
|
56
|
+
place-items: center;
|
|
57
|
+
flex: none;
|
|
58
|
+
width: var(--hl-icon-md);
|
|
59
|
+
height: var(--hl-icon-md);
|
|
60
|
+
transition: rotate var(--hl-motion-slow) var(--hl-ease-spring);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.icon > svg { width: 100%; height: 100%; }
|
|
64
|
+
|
|
65
|
+
/* Open: the icon turns a quarter, the way a plus becomes a cross. */
|
|
66
|
+
.root[data-open] .icon { rotate: 45deg; }
|
|
67
|
+
|
|
68
|
+
/* The label pours away into the icon: its column closes, so the pill shrinks to a circle around the icon. */
|
|
69
|
+
.labelWrap {
|
|
70
|
+
display: grid;
|
|
71
|
+
grid-template-columns: 0fr;
|
|
72
|
+
transition: grid-template-columns var(--hl-motion-slower) var(--hl-ease-in-out);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.fab[data-extended] .labelWrap { grid-template-columns: 1fr; }
|
|
76
|
+
|
|
77
|
+
.label {
|
|
78
|
+
min-width: 0;
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
padding-inline-start: 0;
|
|
81
|
+
white-space: nowrap;
|
|
82
|
+
opacity: 0;
|
|
83
|
+
transition:
|
|
84
|
+
opacity var(--hl-motion-base) var(--hl-ease-out),
|
|
85
|
+
padding var(--hl-motion-slower) var(--hl-ease-in-out);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.fab[data-extended] .label {
|
|
89
|
+
padding-inline: var(--hl-space-3) var(--hl-space-2);
|
|
90
|
+
opacity: 1;
|
|
91
|
+
transition-delay: 0s, 0s;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* The actions pour up out of the button, the nearest first, each a short step after the last. */
|
|
95
|
+
.actions {
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-direction: column;
|
|
98
|
+
align-items: flex-end;
|
|
99
|
+
gap: var(--hl-space-3);
|
|
100
|
+
margin: 0;
|
|
101
|
+
padding: 0;
|
|
102
|
+
list-style: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.center .actions { align-items: center; }
|
|
106
|
+
|
|
107
|
+
.actions li {
|
|
108
|
+
opacity: 0;
|
|
109
|
+
translate: 0 var(--hl-shift-md);
|
|
110
|
+
scale: 0.6;
|
|
111
|
+
transform-origin: 100% 100%;
|
|
112
|
+
transition:
|
|
113
|
+
opacity var(--hl-motion-base) var(--hl-ease-out),
|
|
114
|
+
translate var(--hl-motion-slow) var(--hl-ease-spring),
|
|
115
|
+
scale var(--hl-motion-slow) var(--hl-ease-spring);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.center .actions li { transform-origin: 50% 100%; }
|
|
119
|
+
|
|
120
|
+
.root[data-open] .actions li {
|
|
121
|
+
opacity: 1;
|
|
122
|
+
translate: none;
|
|
123
|
+
scale: 1;
|
|
124
|
+
transition-delay: calc(var(--i) * var(--hl-motion-stagger) * 2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.action {
|
|
128
|
+
display: inline-flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
gap: var(--hl-space-4);
|
|
131
|
+
min-height: var(--hl-hit-min);
|
|
132
|
+
padding: 0;
|
|
133
|
+
border: 0;
|
|
134
|
+
background: none;
|
|
135
|
+
color: var(--hl-color-fg);
|
|
136
|
+
font: inherit;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
pointer-events: auto;
|
|
139
|
+
-webkit-tap-highlight-color: transparent;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.actionLabel {
|
|
143
|
+
padding: var(--hl-space-2) var(--hl-space-4);
|
|
144
|
+
border-radius: var(--hl-radius-full);
|
|
145
|
+
background: var(--hl-color-surface);
|
|
146
|
+
box-shadow: var(--hl-shadow-2);
|
|
147
|
+
font-size: var(--hl-text-sm);
|
|
148
|
+
font-weight: var(--hl-weight-medium);
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Lined up under the button's centre: a smaller circle in the same column. */
|
|
153
|
+
.actionIcon {
|
|
154
|
+
display: grid;
|
|
155
|
+
place-items: center;
|
|
156
|
+
width: var(--hl-hit-min);
|
|
157
|
+
height: var(--hl-hit-min);
|
|
158
|
+
margin-inline-end: calc((var(--hl-fab-size) - var(--hl-hit-min)) / 2);
|
|
159
|
+
border-radius: var(--hl-radius-full);
|
|
160
|
+
background: var(--hl-color-surface);
|
|
161
|
+
box-shadow: var(--hl-shadow-2);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.actionIcon > svg { width: var(--hl-icon-sm); height: var(--hl-icon-sm); }
|
|
165
|
+
|
|
166
|
+
@media (hover: hover) {
|
|
167
|
+
.action:hover .actionIcon,
|
|
168
|
+
.action:hover .actionLabel { background: var(--hl-color-surface-hover); }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.action:focus-visible { outline: none; }
|
|
172
|
+
.action:focus-visible .actionIcon { box-shadow: var(--hl-shadow-2), var(--hl-focus-ring); }
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useRef, useState, type ReactNode } from 'react'
|
|
4
|
+
import { cx } from '../../internal/cx'
|
|
5
|
+
import { useDismiss } from '../../internal/layers'
|
|
6
|
+
import { useScrollFrame } from '../../internal/scrollFrame'
|
|
7
|
+
import styles from './Fab.module.css'
|
|
8
|
+
|
|
9
|
+
export type FabAction = {
|
|
10
|
+
/** Shown beside the action's button and read as its name. */
|
|
11
|
+
label: string
|
|
12
|
+
icon: ReactNode
|
|
13
|
+
onSelect: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type FabProps = {
|
|
17
|
+
/** The button's name. Shown as text when extended, and always read by screen readers. */
|
|
18
|
+
label: string
|
|
19
|
+
/** The icon in the button, such as a plus. With actions it turns a quarter while they are open. */
|
|
20
|
+
icon: ReactNode
|
|
21
|
+
/** Called on tap. With actions, a tap opens them instead. */
|
|
22
|
+
onClick?: () => void
|
|
23
|
+
/** Shows the label beside the icon. It pours away into the icon as the page scrolls down, and back on the way up. */
|
|
24
|
+
extended?: boolean
|
|
25
|
+
/** A short stack of actions that pours up out of the button when it is tapped. */
|
|
26
|
+
actions?: FabAction[]
|
|
27
|
+
/** Where it sits along the bottom of the screen. */
|
|
28
|
+
position?: 'end' | 'center'
|
|
29
|
+
/** Sits at the bottom of its nearest positioned parent instead of the screen, for a demo or a panel. */
|
|
30
|
+
contained?: boolean
|
|
31
|
+
/** Extra class for the root element. */
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// One main action, floating in the thumb zone at the bottom of the screen.
|
|
36
|
+
export function Fab({ label, icon, onClick, extended = false, actions, position = 'end', contained = false, className }: FabProps) {
|
|
37
|
+
const id = useId()
|
|
38
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
39
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
40
|
+
const firstRef = useRef<HTMLButtonElement>(null)
|
|
41
|
+
const [open, setOpen] = useState(false)
|
|
42
|
+
const [compact, setCompact] = useState(false)
|
|
43
|
+
const last = useRef<number | null>(null)
|
|
44
|
+
|
|
45
|
+
// Extended: down the page it folds to the icon, back up it opens again. It reads the scroll, never a timer.
|
|
46
|
+
useScrollFrame(
|
|
47
|
+
() => {
|
|
48
|
+
const y = window.scrollY
|
|
49
|
+
const prev = last.current
|
|
50
|
+
last.current = y
|
|
51
|
+
if (prev === null || Math.abs(y - prev) < 4) return
|
|
52
|
+
const next = y > prev && y > 80
|
|
53
|
+
return () => setCompact(next)
|
|
54
|
+
},
|
|
55
|
+
extended,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
useDismiss(open, [rootRef], (reason) => {
|
|
59
|
+
setOpen(false)
|
|
60
|
+
if (reason === 'escape') buttonRef.current?.focus()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const hasActions = Boolean(actions?.length)
|
|
64
|
+
const toggle = () => {
|
|
65
|
+
if (!hasActions) return onClick?.()
|
|
66
|
+
const next = !open
|
|
67
|
+
setOpen(next)
|
|
68
|
+
if (next) requestAnimationFrame(() => firstRef.current?.focus())
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div ref={rootRef} className={cx(styles.root, styles[position], contained && styles.contained, className)} data-open={open || undefined}>
|
|
73
|
+
{hasActions && (
|
|
74
|
+
<ul id={`${id}-actions`} className={styles.actions} inert={!open}>
|
|
75
|
+
{actions!.map((a, i) => (
|
|
76
|
+
<li key={a.label} style={{ ['--i' as string]: actions!.length - 1 - i }}>
|
|
77
|
+
<button
|
|
78
|
+
ref={i === 0 ? firstRef : undefined}
|
|
79
|
+
type="button"
|
|
80
|
+
className={styles.action}
|
|
81
|
+
onClick={() => {
|
|
82
|
+
setOpen(false)
|
|
83
|
+
buttonRef.current?.focus()
|
|
84
|
+
a.onSelect()
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<span className={styles.actionLabel}>{a.label}</span>
|
|
88
|
+
<span className={styles.actionIcon} aria-hidden="true">
|
|
89
|
+
{a.icon}
|
|
90
|
+
</span>
|
|
91
|
+
</button>
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</ul>
|
|
95
|
+
)}
|
|
96
|
+
<button
|
|
97
|
+
ref={buttonRef}
|
|
98
|
+
type="button"
|
|
99
|
+
className={styles.fab}
|
|
100
|
+
aria-label={extended ? undefined : label}
|
|
101
|
+
aria-expanded={hasActions ? open : undefined}
|
|
102
|
+
aria-controls={hasActions ? `${id}-actions` : undefined}
|
|
103
|
+
data-extended={(extended && !compact) || undefined}
|
|
104
|
+
onClick={toggle}
|
|
105
|
+
>
|
|
106
|
+
<span className={styles.icon} aria-hidden="true">
|
|
107
|
+
{icon}
|
|
108
|
+
</span>
|
|
109
|
+
{extended && (
|
|
110
|
+
<span className={styles.labelWrap}>
|
|
111
|
+
<span className={styles.label}>{label}</span>
|
|
112
|
+
</span>
|
|
113
|
+
)}
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Fab'
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/* A line of about 68 characters, the length an eye can follow back to the next line without losing it.
|
|
2
|
+
:where keeps every rule at zero weight, so a class you add inside always wins. */
|
|
3
|
+
.root {
|
|
4
|
+
max-width: var(--hl-prose-w);
|
|
5
|
+
color: var(--hl-color-fg);
|
|
6
|
+
font-size: var(--hl-text-md);
|
|
7
|
+
line-height: var(--hl-leading-normal);
|
|
8
|
+
overflow-wrap: break-word;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.root > :where(:first-child) { margin-top: 0; }
|
|
12
|
+
.root > :where(:last-child) { margin-bottom: 0; }
|
|
13
|
+
|
|
14
|
+
.root :where(p, ul, ol, blockquote, pre, figure, table, hr) { margin: 0 0 var(--hl-space-6); }
|
|
15
|
+
|
|
16
|
+
/* A heading sits close to the text it opens and well clear of the text before it. */
|
|
17
|
+
.root :where(h2, h3, h4) {
|
|
18
|
+
margin: var(--hl-space-10) 0 var(--hl-space-4);
|
|
19
|
+
font-weight: var(--hl-weight-semibold);
|
|
20
|
+
letter-spacing: var(--hl-tracking-tight);
|
|
21
|
+
line-height: var(--hl-leading-tight);
|
|
22
|
+
text-wrap: balance;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.root :where(h2) { font-size: var(--hl-text-2xl); }
|
|
26
|
+
.root :where(h3) { font-size: var(--hl-text-xl); }
|
|
27
|
+
.root :where(h4) { font-size: var(--hl-text-lg); }
|
|
28
|
+
.root :where(h2, h3, h4) + :where(h3, h4) { margin-top: var(--hl-space-5); }
|
|
29
|
+
|
|
30
|
+
.root :where(strong) { font-weight: var(--hl-weight-semibold); }
|
|
31
|
+
|
|
32
|
+
.root :where(a) {
|
|
33
|
+
color: var(--hl-color-fg);
|
|
34
|
+
font-weight: var(--hl-weight-medium);
|
|
35
|
+
text-decoration: underline;
|
|
36
|
+
text-decoration-color: var(--hl-color-border-strong);
|
|
37
|
+
text-decoration-thickness: var(--hl-line);
|
|
38
|
+
text-underline-offset: 0.2em;
|
|
39
|
+
transition: text-decoration-color var(--hl-motion-fast) var(--hl-ease-out);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@media (hover: hover) {
|
|
43
|
+
.root :where(a):hover { text-decoration-color: currentColor; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.root :where(a):focus-visible { outline: none; border-radius: var(--hl-radius-xs); box-shadow: var(--hl-focus-ring); }
|
|
47
|
+
|
|
48
|
+
.root :where(ul, ol) { padding-left: var(--hl-space-6); }
|
|
49
|
+
.root :where(li) { margin-bottom: var(--hl-space-2); padding-left: var(--hl-space-1); }
|
|
50
|
+
.root :where(li) > :where(ul, ol) { margin: var(--hl-space-2) 0 0; }
|
|
51
|
+
.root :where(li)::marker { color: var(--hl-color-fg-subtle); }
|
|
52
|
+
|
|
53
|
+
/* A quote sits on a soft panel of its own, not behind a line down its side. */
|
|
54
|
+
.root :where(blockquote) {
|
|
55
|
+
padding: var(--hl-space-5) var(--hl-space-6);
|
|
56
|
+
border-radius: var(--hl-radius-md);
|
|
57
|
+
background: var(--hl-color-surface-sunken);
|
|
58
|
+
color: var(--hl-color-fg-muted);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.root :where(blockquote) > :where(:last-child) { margin-bottom: 0; }
|
|
62
|
+
|
|
63
|
+
.root :where(code) {
|
|
64
|
+
padding: 0.1em 0.35em;
|
|
65
|
+
border-radius: var(--hl-radius-xs);
|
|
66
|
+
background: var(--hl-color-surface-sunken);
|
|
67
|
+
font-family: var(--hl-font-mono);
|
|
68
|
+
font-size: 0.9em;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.root :where(pre) {
|
|
72
|
+
overflow-x: auto;
|
|
73
|
+
padding: var(--hl-space-5);
|
|
74
|
+
border-radius: var(--hl-radius-md);
|
|
75
|
+
background: var(--hl-code-bg);
|
|
76
|
+
color: var(--hl-code-fg);
|
|
77
|
+
font-size: var(--hl-text-sm);
|
|
78
|
+
line-height: var(--hl-leading-normal);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.root :where(pre) :where(code) { padding: 0; background: none; font-size: inherit; }
|
|
82
|
+
|
|
83
|
+
.root :where(img, video) {
|
|
84
|
+
display: block;
|
|
85
|
+
max-width: 100%;
|
|
86
|
+
height: auto;
|
|
87
|
+
border-radius: var(--hl-radius-md);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.root :where(figcaption) {
|
|
91
|
+
margin-top: var(--hl-space-3);
|
|
92
|
+
color: var(--hl-color-fg-muted);
|
|
93
|
+
font-size: var(--hl-text-sm);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.root :where(hr) {
|
|
97
|
+
height: 0;
|
|
98
|
+
border: 0;
|
|
99
|
+
border-top: var(--hl-line) solid var(--hl-color-border);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.root :where(table) {
|
|
103
|
+
display: block;
|
|
104
|
+
overflow-x: auto;
|
|
105
|
+
border-collapse: collapse;
|
|
106
|
+
font-size: var(--hl-text-sm);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.root :where(th, td) {
|
|
110
|
+
padding: var(--hl-space-3) var(--hl-space-4);
|
|
111
|
+
border-bottom: var(--hl-line) solid var(--hl-color-border);
|
|
112
|
+
text-align: left;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.root :where(th) { font-weight: var(--hl-weight-semibold); }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ElementType } from 'react'
|
|
2
|
+
import { cx } from '../../internal/cx'
|
|
3
|
+
import styles from './Prose.module.css'
|
|
4
|
+
|
|
5
|
+
export type ProseProps = {
|
|
6
|
+
/** The element to render, such as article. */
|
|
7
|
+
as?: ElementType
|
|
8
|
+
} & ComponentPropsWithRef<'div'>
|
|
9
|
+
|
|
10
|
+
// Long text written elsewhere, such as Markdown or a CMS: headings, paragraphs, lists, quotes, code and
|
|
11
|
+
// pictures, set for reading. It styles plain HTML inside it, so nothing needs a class.
|
|
12
|
+
export function Prose({ as: Tag = 'div', className, ...rest }: ProseProps) {
|
|
13
|
+
return <Tag className={cx(styles.root, className)} {...rest} />
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Prose'
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--hl-space-3);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.title {
|
|
8
|
+
margin: 0 0 var(--hl-space-2);
|
|
9
|
+
font-size: var(--hl-text-sm);
|
|
10
|
+
font-weight: var(--hl-weight-semibold);
|
|
11
|
+
color: var(--hl-color-fg);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* A strip of frames, one per section, threaded on a faint line through their middles. */
|
|
15
|
+
.list {
|
|
16
|
+
position: relative;
|
|
17
|
+
margin: 0;
|
|
18
|
+
padding: 0;
|
|
19
|
+
list-style: none;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.list::before {
|
|
23
|
+
content: "";
|
|
24
|
+
position: absolute;
|
|
25
|
+
top: var(--hl-space-6);
|
|
26
|
+
bottom: var(--hl-space-6);
|
|
27
|
+
left: calc(var(--hl-space-3) + var(--hl-space-2) - var(--hl-line) / 2);
|
|
28
|
+
border-left: var(--hl-line) solid var(--hl-color-border);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Looks compact but keeps a 44px hit area with an invisible layer. */
|
|
32
|
+
.link {
|
|
33
|
+
position: relative;
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: baseline;
|
|
36
|
+
gap: var(--hl-space-4);
|
|
37
|
+
width: 100%;
|
|
38
|
+
min-height: var(--hl-control-h-sm);
|
|
39
|
+
padding: var(--hl-space-2) var(--hl-space-3);
|
|
40
|
+
border-radius: var(--hl-radius-sm);
|
|
41
|
+
color: var(--hl-color-fg-muted);
|
|
42
|
+
font-size: var(--hl-text-sm);
|
|
43
|
+
line-height: var(--hl-leading-snug);
|
|
44
|
+
text-decoration: none;
|
|
45
|
+
transition: color var(--hl-motion-fast) var(--hl-ease-out), background-color var(--hl-motion-fast) var(--hl-ease-out);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.link::after {
|
|
49
|
+
content: "";
|
|
50
|
+
position: absolute;
|
|
51
|
+
inset-inline: 0;
|
|
52
|
+
inset-block: min(0px, calc((100% - var(--hl-hit-min)) / 2));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@media (hover: hover) {
|
|
56
|
+
.link:hover { color: var(--hl-color-fg); background: var(--hl-color-surface-hover); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.link:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
|
|
60
|
+
|
|
61
|
+
/* Each section's frame: open ahead, filled once read, ringed where the reader is. */
|
|
62
|
+
.node {
|
|
63
|
+
position: relative;
|
|
64
|
+
z-index: 1;
|
|
65
|
+
flex: none;
|
|
66
|
+
width: var(--hl-space-4);
|
|
67
|
+
height: var(--hl-space-4);
|
|
68
|
+
translate: 0 calc(var(--hl-space-1) / 2);
|
|
69
|
+
border: var(--hl-line-thick) solid var(--hl-color-border-strong);
|
|
70
|
+
border-radius: var(--hl-radius-full);
|
|
71
|
+
background: var(--hl-color-bg);
|
|
72
|
+
transition:
|
|
73
|
+
background-color var(--hl-motion-base) var(--hl-ease-out),
|
|
74
|
+
border-color var(--hl-motion-base) var(--hl-ease-out),
|
|
75
|
+
box-shadow var(--hl-motion-base) var(--hl-ease-out);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
[data-state="read"] .node { border-color: var(--hl-color-fg-subtle); background: var(--hl-color-fg-subtle); }
|
|
79
|
+
|
|
80
|
+
[data-state="here"] .link { color: var(--hl-color-fg); font-weight: var(--hl-weight-medium); }
|
|
81
|
+
|
|
82
|
+
[data-state="here"] .node {
|
|
83
|
+
border-color: var(--hl-color-fg);
|
|
84
|
+
background: var(--hl-color-fg);
|
|
85
|
+
box-shadow: 0 0 0 var(--hl-space-1) var(--hl-color-bg), 0 0 0 calc(var(--hl-space-1) + var(--hl-line-thick)) var(--hl-color-fg);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.toTop {
|
|
89
|
+
display: inline-flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: var(--hl-space-2);
|
|
92
|
+
align-self: flex-start;
|
|
93
|
+
min-height: var(--hl-hit-min);
|
|
94
|
+
padding-inline: var(--hl-space-3);
|
|
95
|
+
border-radius: var(--hl-radius-sm);
|
|
96
|
+
color: var(--hl-color-fg-muted);
|
|
97
|
+
font-size: var(--hl-text-sm);
|
|
98
|
+
text-decoration: none;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.toTop > svg { width: var(--hl-icon-sm); height: var(--hl-icon-sm); }
|
|
102
|
+
|
|
103
|
+
@media (hover: hover) {
|
|
104
|
+
.toTop:hover { color: var(--hl-color-fg); background: var(--hl-color-surface-hover); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.toTop:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type MouseEvent } from 'react'
|
|
4
|
+
import { cx } from '../../internal/cx'
|
|
5
|
+
import { ArrowUpIcon } from '../../internal/icons'
|
|
6
|
+
import { prefersReducedMotion } from '../../internal/motion'
|
|
7
|
+
import styles from './TableOfContents.module.css'
|
|
8
|
+
|
|
9
|
+
export type TocItem = {
|
|
10
|
+
/** The id of the heading on the page. */
|
|
11
|
+
id: string
|
|
12
|
+
label: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TableOfContentsProps = {
|
|
16
|
+
/** The sections, in page order: each heading's id and the text to show for it. */
|
|
17
|
+
items: TocItem[]
|
|
18
|
+
/** Heading above the list, also the nav's name. */
|
|
19
|
+
title?: string
|
|
20
|
+
/** Adds a way back to the top once the reader has passed the first section. */
|
|
21
|
+
backToTop?: boolean
|
|
22
|
+
/** Extra class for the root element. */
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The sections of a page as links to their headings. Each is a frame on a strip: filled once read, ringed
|
|
27
|
+
// where the reader is, open ahead, so it shows how far through the page they are, not only where.
|
|
28
|
+
export function TableOfContents({ items, title = 'On this page', backToTop = false, className }: TableOfContentsProps) {
|
|
29
|
+
const [active, setActive] = useState(items[0]?.id)
|
|
30
|
+
|
|
31
|
+
// The section being read is the last heading past the top third of the screen.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const headings = items.map((t) => document.getElementById(t.id)).filter((h): h is HTMLElement => !!h)
|
|
34
|
+
const update = () => {
|
|
35
|
+
const line = window.innerHeight / 3
|
|
36
|
+
let current = headings[0]?.id
|
|
37
|
+
for (const h of headings) if (h.getBoundingClientRect().top <= line) current = h.id
|
|
38
|
+
// A short last section never reaches the line, so the bottom of the page selects it.
|
|
39
|
+
const atBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2
|
|
40
|
+
setActive(atBottom ? headings[headings.length - 1]?.id : current)
|
|
41
|
+
}
|
|
42
|
+
update()
|
|
43
|
+
window.addEventListener('scroll', update, { passive: true })
|
|
44
|
+
window.addEventListener('resize', update)
|
|
45
|
+
return () => {
|
|
46
|
+
window.removeEventListener('scroll', update)
|
|
47
|
+
window.removeEventListener('resize', update)
|
|
48
|
+
}
|
|
49
|
+
}, [items])
|
|
50
|
+
|
|
51
|
+
const plain = (e: MouseEvent) => e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey
|
|
52
|
+
|
|
53
|
+
// A plain click scrolls there and keeps one history entry; Cmd-click still opens a new tab.
|
|
54
|
+
const go = (e: MouseEvent<HTMLAnchorElement>, id: string) => {
|
|
55
|
+
const heading = document.getElementById(id)
|
|
56
|
+
if (!plain(e) || !heading) return
|
|
57
|
+
e.preventDefault()
|
|
58
|
+
heading.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' })
|
|
59
|
+
if (!heading.hasAttribute('tabindex')) heading.setAttribute('tabindex', '-1')
|
|
60
|
+
heading.focus({ preventScroll: true })
|
|
61
|
+
window.history.replaceState(window.history.state, '', `#${id}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const toTop = (e: MouseEvent<HTMLAnchorElement>) => {
|
|
65
|
+
if (!plain(e)) return
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' })
|
|
68
|
+
window.history.replaceState(window.history.state, '', window.location.pathname + window.location.search)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const at = Math.max(0, items.findIndex((t) => t.id === active))
|
|
72
|
+
return (
|
|
73
|
+
<nav className={cx(styles.root, className)} aria-label={title}>
|
|
74
|
+
<p className={styles.title}>{title}</p>
|
|
75
|
+
<ol className={styles.list}>
|
|
76
|
+
{items.map((t, n) => (
|
|
77
|
+
<li key={t.id} data-state={n < at ? 'read' : n === at ? 'here' : 'ahead'}>
|
|
78
|
+
<a href={`#${t.id}`} className={styles.link} aria-current={active === t.id ? 'location' : undefined} onClick={(e) => go(e, t.id)}>
|
|
79
|
+
<span className={styles.node} aria-hidden="true" />
|
|
80
|
+
{t.label}
|
|
81
|
+
</a>
|
|
82
|
+
</li>
|
|
83
|
+
))}
|
|
84
|
+
</ol>
|
|
85
|
+
{backToTop && at > 0 && (
|
|
86
|
+
<a href="#" className={styles.toTop} onClick={toTop}>
|
|
87
|
+
<ArrowUpIcon aria-hidden="true" />
|
|
88
|
+
Back to top
|
|
89
|
+
</a>
|
|
90
|
+
)}
|
|
91
|
+
</nav>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './TableOfContents'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/* The sun and the moon share one spot: the one for the other theme turns away and shrinks as the
|
|
2
|
+
current one turns in. */
|
|
3
|
+
.icon {
|
|
4
|
+
display: grid;
|
|
5
|
+
width: 1em;
|
|
6
|
+
height: 1em;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.sun,
|
|
10
|
+
.moon {
|
|
11
|
+
grid-area: 1 / 1;
|
|
12
|
+
width: 100%;
|
|
13
|
+
height: 100%;
|
|
14
|
+
transition:
|
|
15
|
+
transform var(--hl-motion-slower) var(--hl-ease-spring),
|
|
16
|
+
opacity var(--hl-motion-base) var(--hl-ease-out);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Shows the theme the button switches to: the moon in light, the sun in dark. */
|
|
20
|
+
.icon:not([data-theme-shown="dark"]) .sun { opacity: 0; transform: rotate(90deg) scale(0.4); }
|
|
21
|
+
.icon[data-theme-shown="dark"] .moon { opacity: 0; transform: rotate(-90deg) scale(0.4); }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { MoonIcon, SunIcon } from '../../internal/icons'
|
|
5
|
+
import { IconButton } from '../IconButton'
|
|
6
|
+
import styles from './ThemeToggle.module.css'
|
|
7
|
+
|
|
8
|
+
export type Theme = 'light' | 'dark'
|
|
9
|
+
|
|
10
|
+
export type ThemeToggleProps = {
|
|
11
|
+
/** The theme, when your app keeps it. Leave it out and the toggle sets data-theme on <html> itself. */
|
|
12
|
+
theme?: Theme
|
|
13
|
+
/** Called with the new theme on every flip. */
|
|
14
|
+
onThemeChange?: (theme: Theme) => void
|
|
15
|
+
/** Where the choice is remembered when the toggle runs the page. Match it in themeScript. */
|
|
16
|
+
storageKey?: string
|
|
17
|
+
/** The button's size, the same as IconButton. */
|
|
18
|
+
size?: 'sm' | 'md' | 'lg'
|
|
19
|
+
/** Extra class for the button. */
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Put in <head>, before the page paints, so a saved dark theme never flashes light first. */
|
|
24
|
+
export const themeScript = (storageKey = 'hl-theme') =>
|
|
25
|
+
`try{var t=localStorage.getItem(${JSON.stringify(storageKey)});if(t!=='light'&&t!=='dark')t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.dataset.theme=t}catch(e){}`
|
|
26
|
+
|
|
27
|
+
const pageTheme = (): Theme => (document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')
|
|
28
|
+
|
|
29
|
+
// One button that flips between the light and dark theme. The sun turns into the moon and back.
|
|
30
|
+
export function ThemeToggle({ theme: themeProp, onThemeChange, storageKey = 'hl-theme', size = 'sm', className }: ThemeToggleProps) {
|
|
31
|
+
const controlled = themeProp !== undefined
|
|
32
|
+
const [own, setOwn] = useState<Theme>('light')
|
|
33
|
+
const theme = controlled ? themeProp : own
|
|
34
|
+
|
|
35
|
+
// Read after mount: the server cannot know the page's theme, and themeScript has already set it.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!controlled) setOwn(pageTheme())
|
|
38
|
+
}, [controlled])
|
|
39
|
+
|
|
40
|
+
const next: Theme = theme === 'dark' ? 'light' : 'dark'
|
|
41
|
+
const flip = () => {
|
|
42
|
+
if (!controlled) {
|
|
43
|
+
document.documentElement.dataset.theme = next
|
|
44
|
+
try {
|
|
45
|
+
localStorage.setItem(storageKey, next)
|
|
46
|
+
} catch {
|
|
47
|
+
// Not saved, so it lasts only this visit; the theme still changes.
|
|
48
|
+
}
|
|
49
|
+
setOwn(next)
|
|
50
|
+
}
|
|
51
|
+
onThemeChange?.(next)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<IconButton
|
|
56
|
+
label={`Switch to ${next} theme`}
|
|
57
|
+
variant="secondary"
|
|
58
|
+
size={size}
|
|
59
|
+
className={className}
|
|
60
|
+
onClick={flip}
|
|
61
|
+
icon={
|
|
62
|
+
<span className={styles.icon} data-theme-shown={theme}>
|
|
63
|
+
<SunIcon className={styles.sun} />
|
|
64
|
+
<MoonIcon className={styles.moon} />
|
|
65
|
+
</span>
|
|
66
|
+
}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ThemeToggle'
|
package/src/tokens.css
CHANGED
|
@@ -72,6 +72,8 @@
|
|
|
72
72
|
--hl-leading-tight: 1.2;
|
|
73
73
|
--hl-leading-snug: 1.4;
|
|
74
74
|
--hl-leading-normal: 1.6;
|
|
75
|
+
/* Prose: the longest line of reading text, about the most an eye follows back to the next line */
|
|
76
|
+
--hl-prose-w: 68ch;
|
|
75
77
|
--hl-weight-regular: 400;
|
|
76
78
|
--hl-weight-medium: 500;
|
|
77
79
|
--hl-weight-semibold: 600;
|
|
@@ -166,6 +168,8 @@
|
|
|
166
168
|
--hl-motion-base: calc(220ms * var(--hl-motion-scale));
|
|
167
169
|
--hl-motion-slow: calc(360ms * var(--hl-motion-scale));
|
|
168
170
|
--hl-motion-slower: calc(600ms * var(--hl-motion-scale));
|
|
171
|
+
/* Navbar dock: the trip to and from the bottom of the screen, long enough to read as liquid */
|
|
172
|
+
--hl-motion-pour: calc(760ms * var(--hl-motion-scale));
|
|
169
173
|
--hl-motion-stagger: calc(30ms * var(--hl-motion-scale));
|
|
170
174
|
--hl-motion-delay-tooltip: 400ms;
|
|
171
175
|
--hl-motion-shimmer: 1400ms;
|
|
@@ -296,6 +300,7 @@
|
|
|
296
300
|
--hl-motion-base: 0ms;
|
|
297
301
|
--hl-motion-slow: 0ms;
|
|
298
302
|
--hl-motion-slower: 0ms;
|
|
303
|
+
--hl-motion-pour: 0ms;
|
|
299
304
|
--hl-motion-stagger: 0ms;
|
|
300
305
|
--hl-motion-shimmer: 0ms;
|
|
301
306
|
--hl-motion-spin: 1600ms;
|
|
@@ -378,6 +383,9 @@
|
|
|
378
383
|
--hl-navbar-h: 64px;
|
|
379
384
|
/* Navbar float: how far the page scrolls while the bar lifts into a floating card */
|
|
380
385
|
--hl-navbar-float-run: 160px;
|
|
386
|
+
/* Fab: the round button's size, and its gap from the bottom and side of the screen */
|
|
387
|
+
--hl-fab-size: 56px;
|
|
388
|
+
--hl-fab-gap: 16px;
|
|
381
389
|
--hl-sidebar-w: 248px;
|
|
382
390
|
--hl-sidebar-w-collapsed: 64px;
|
|
383
391
|
--hl-tabbar-h: 64px;
|