@stnd/ui 0.5.2 → 0.6.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/CHANGELOG.md +13 -0
- package/TableOfContents.astro +255 -83
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
package/TableOfContents.astro
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* @component TableOfContents
|
|
4
|
-
* @description
|
|
5
|
-
*
|
|
4
|
+
* @description Refined floating reading rail & outline for articles and notes.
|
|
5
|
+
* Subtle, architectural tick marks that expand into a polished frosted-glass
|
|
6
|
+
* navigator on hover and highlight the reader's current section.
|
|
6
7
|
*
|
|
7
8
|
* @example astro - Usage
|
|
8
9
|
* import TableOfContents from "@stnd/ui/TableOfContents.astro";
|
|
9
10
|
* <TableOfContents {headings} />
|
|
10
|
-
*
|
|
11
|
-
* @example js - Extracting headings from rendered HTML
|
|
12
|
-
* const headings = [];
|
|
13
|
-
* const headingRegex = /<h([23])\s[^>]*id="([^"]*)"[^>]*>(.*?)<\/h\1>/gi;
|
|
14
|
-
* let m;
|
|
15
|
-
* while ((m = headingRegex.exec(html)) !== null) {
|
|
16
|
-
* headings.push({ depth: parseInt(m[1]), text: m[3].replace(/<[^>]+>/g, "").trim(), slug: m[2] });
|
|
17
|
-
* }
|
|
18
11
|
*/
|
|
19
12
|
|
|
20
13
|
interface Heading {
|
|
@@ -24,7 +17,7 @@ interface Heading {
|
|
|
24
17
|
}
|
|
25
18
|
|
|
26
19
|
interface Props {
|
|
27
|
-
/** The headings to show — typically extracted from rendered HTML
|
|
20
|
+
/** The headings to show — typically extracted from rendered HTML. */
|
|
28
21
|
headings: Heading[];
|
|
29
22
|
/** Minimum headings to show the TOC. Default: 2 */
|
|
30
23
|
minHeadings?: number;
|
|
@@ -32,25 +25,62 @@ interface Props {
|
|
|
32
25
|
breakpoint?: number;
|
|
33
26
|
}
|
|
34
27
|
|
|
28
|
+
function cleanHeadingText(raw: string): string {
|
|
29
|
+
if (!raw) return "";
|
|
30
|
+
return raw
|
|
31
|
+
.replace(/<[^>]+>/g, "")
|
|
32
|
+
// Decode common numeric and named entities
|
|
33
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(dec))
|
|
34
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) =>
|
|
35
|
+
String.fromCharCode(parseInt(hex, 16)),
|
|
36
|
+
)
|
|
37
|
+
.replace(/ /gi, " ")
|
|
38
|
+
.replace(/&/gi, "&")
|
|
39
|
+
.replace(/"/gi, '"')
|
|
40
|
+
.replace(/'|'/gi, "'")
|
|
41
|
+
.replace(/</gi, "<")
|
|
42
|
+
.replace(/>/gi, ">")
|
|
43
|
+
// Replace non-breaking and special spaces with standard space
|
|
44
|
+
.replace(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g, " ")
|
|
45
|
+
.replace(/\s+/g, " ")
|
|
46
|
+
.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
const { headings, minHeadings = 2, breakpoint = 1100 } = Astro.props;
|
|
36
|
-
|
|
50
|
+
|
|
51
|
+
const tocHeadings = (headings || [])
|
|
52
|
+
.filter((h) => h.depth === 2 || h.depth === 3)
|
|
53
|
+
.map((h) => ({
|
|
54
|
+
...h,
|
|
55
|
+
text: cleanHeadingText(h.text),
|
|
56
|
+
}));
|
|
37
57
|
---
|
|
38
58
|
|
|
39
59
|
{
|
|
40
60
|
tocHeadings.length >= minHeadings && (
|
|
41
|
-
<nav
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
<nav
|
|
62
|
+
class="stnd-toc"
|
|
63
|
+
aria-label="Table of contents"
|
|
64
|
+
data-breakpoint={breakpoint}
|
|
65
|
+
>
|
|
66
|
+
<div class="toc-spine" aria-hidden="true" />
|
|
67
|
+
<header class="toc-header" aria-hidden="true">
|
|
68
|
+
<span class="toc-header-title">Contents</span>
|
|
69
|
+
</header>
|
|
70
|
+
<div class="toc-list no-rhythm">
|
|
71
|
+
{tocHeadings.map((h) => (
|
|
72
|
+
<a
|
|
73
|
+
href={`#${h.slug}`}
|
|
74
|
+
class="toc-item"
|
|
75
|
+
data-slug={h.slug}
|
|
76
|
+
data-depth={h.depth}
|
|
77
|
+
aria-label={h.text}
|
|
78
|
+
>
|
|
79
|
+
<span class="toc-label">{h.text}</span>
|
|
80
|
+
<span class="toc-bar" aria-hidden="true" />
|
|
81
|
+
</a>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
54
84
|
</nav>
|
|
55
85
|
)
|
|
56
86
|
}
|
|
@@ -58,105 +88,235 @@ const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|
|
58
88
|
<style>
|
|
59
89
|
.stnd-toc {
|
|
60
90
|
position: fixed;
|
|
61
|
-
right: var(--space);
|
|
91
|
+
right: max(1rem, var(--space));
|
|
62
92
|
top: 50%;
|
|
63
93
|
transform: translateY(-50%);
|
|
64
94
|
display: flex;
|
|
65
95
|
flex-direction: column;
|
|
66
|
-
gap: var(--space-d8);
|
|
67
|
-
z-index: 10;
|
|
68
|
-
padding: var(--space-d2);
|
|
69
96
|
align-items: flex-end;
|
|
97
|
+
z-index: 80;
|
|
98
|
+
box-sizing: border-box;
|
|
99
|
+
padding: 0.5rem 0.35rem;
|
|
100
|
+
border-radius: var(--radius-lg, 12px);
|
|
101
|
+
background: transparent;
|
|
102
|
+
border: 1px solid transparent;
|
|
103
|
+
opacity: 0.45;
|
|
104
|
+
transition:
|
|
105
|
+
opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
|
106
|
+
background 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
|
107
|
+
border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
|
108
|
+
box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
|
109
|
+
backdrop-filter 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
|
110
|
+
padding 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
111
|
+
user-select: none;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.stnd-toc[data-visible] {
|
|
115
|
+
opacity: 0.85;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Floating Card Mode on Hover or Focus */
|
|
119
|
+
.stnd-toc:hover,
|
|
120
|
+
.stnd-toc:focus-within {
|
|
121
|
+
opacity: 1;
|
|
122
|
+
background: color-mix(
|
|
123
|
+
in oklab,
|
|
124
|
+
var(--color-surface, var(--color-background)) 92%,
|
|
125
|
+
transparent
|
|
126
|
+
);
|
|
127
|
+
backdrop-filter: blur(24px) saturate(160%);
|
|
128
|
+
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
|
129
|
+
border-color: color-mix(in oklab, var(--color-foreground) 10%, transparent);
|
|
130
|
+
box-shadow:
|
|
131
|
+
0 4px 20px -2px rgba(0, 0, 0, 0.08),
|
|
132
|
+
0 16px 36px -4px rgba(0, 0, 0, 0.08);
|
|
133
|
+
padding: 0.75rem 1rem 0.85rem 1.25rem;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* Architectural vertical spine guide */
|
|
137
|
+
.toc-spine {
|
|
138
|
+
position: absolute;
|
|
139
|
+
right: 14px;
|
|
140
|
+
top: 0.75rem;
|
|
141
|
+
bottom: 0.75rem;
|
|
142
|
+
width: 1px;
|
|
143
|
+
background: color-mix(in oklab, var(--color-foreground) 8%, transparent);
|
|
144
|
+
pointer-events: none;
|
|
145
|
+
z-index: 1;
|
|
146
|
+
transition: opacity 0.25s ease;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.stnd-toc:hover .toc-spine,
|
|
150
|
+
.stnd-toc:focus-within .toc-spine {
|
|
151
|
+
opacity: 0.4;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Header */
|
|
155
|
+
.toc-header {
|
|
156
|
+
width: 100%;
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
justify-content: space-between;
|
|
160
|
+
max-height: 0;
|
|
70
161
|
opacity: 0;
|
|
71
|
-
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
margin-bottom: 0;
|
|
164
|
+
pointer-events: none;
|
|
165
|
+
transition:
|
|
166
|
+
max-height 0.25s cubic-bezier(0.16, 1, 0.3, 1),
|
|
167
|
+
opacity 0.2s ease,
|
|
168
|
+
margin-bottom 0.25s ease;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.toc-header-title {
|
|
172
|
+
font-family: var(--font-monospace, monospace);
|
|
173
|
+
font-size: var(--size-2xs, 0.65rem);
|
|
174
|
+
letter-spacing: 0.12em;
|
|
175
|
+
text-transform: uppercase;
|
|
176
|
+
color: var(--color-muted);
|
|
177
|
+
font-weight: 600;
|
|
72
178
|
}
|
|
73
179
|
|
|
74
|
-
.stnd-toc
|
|
75
|
-
.stnd-toc:
|
|
180
|
+
.stnd-toc:hover .toc-header,
|
|
181
|
+
.stnd-toc:focus-within .toc-header {
|
|
182
|
+
max-height: 1.5rem;
|
|
76
183
|
opacity: 1;
|
|
184
|
+
margin-bottom: 0.5rem;
|
|
185
|
+
padding-bottom: 0.35rem;
|
|
186
|
+
border-bottom: 1px solid
|
|
187
|
+
color-mix(in oklab, var(--color-foreground) 7%, transparent);
|
|
77
188
|
}
|
|
78
189
|
|
|
79
|
-
|
|
80
|
-
|
|
190
|
+
/* List with stable gap */
|
|
191
|
+
.toc-list {
|
|
192
|
+
display: flex;
|
|
193
|
+
flex-direction: column;
|
|
194
|
+
gap: 6px;
|
|
195
|
+
position: relative;
|
|
196
|
+
z-index: 2;
|
|
197
|
+
max-height: calc(75vh - 2rem);
|
|
198
|
+
overflow-y: auto;
|
|
199
|
+
overflow-x: hidden;
|
|
200
|
+
padding-right: 1px;
|
|
81
201
|
}
|
|
82
202
|
|
|
203
|
+
.toc-list::-webkit-scrollbar {
|
|
204
|
+
width: 3px;
|
|
205
|
+
}
|
|
206
|
+
.toc-list::-webkit-scrollbar-thumb {
|
|
207
|
+
background: color-mix(in oklab, var(--color-foreground) 20%, transparent);
|
|
208
|
+
border-radius: 3px;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Item */
|
|
83
212
|
.toc-item {
|
|
84
213
|
display: flex;
|
|
85
214
|
align-items: center;
|
|
86
|
-
|
|
215
|
+
justify-content: flex-end;
|
|
216
|
+
gap: var(--space-half, 0.65rem);
|
|
87
217
|
text-decoration: none;
|
|
218
|
+
padding: 3px 0 3px 6px;
|
|
219
|
+
border-radius: var(--radius-sm, 4px);
|
|
88
220
|
cursor: pointer;
|
|
221
|
+
transition: background 0.15s ease;
|
|
222
|
+
outline: none;
|
|
89
223
|
}
|
|
90
224
|
|
|
91
|
-
.toc-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
225
|
+
.stnd-toc:hover .toc-item:hover,
|
|
226
|
+
.stnd-toc:focus-within .toc-item:hover,
|
|
227
|
+
.toc-item:focus-visible {
|
|
228
|
+
background: color-mix(in oklab, var(--color-foreground) 5%, transparent);
|
|
229
|
+
}
|
|
96
230
|
|
|
231
|
+
/* Label */
|
|
232
|
+
.toc-label {
|
|
233
|
+
font-family: var(--font-interface, sans-serif);
|
|
234
|
+
font-size: var(--size-xs, 0.8125rem);
|
|
235
|
+
color: var(--color-muted);
|
|
236
|
+
text-align: right;
|
|
237
|
+
line-height: 1.35;
|
|
97
238
|
white-space: nowrap;
|
|
98
239
|
max-width: 0;
|
|
99
240
|
overflow: hidden;
|
|
100
241
|
text-overflow: ellipsis;
|
|
101
242
|
opacity: 0;
|
|
102
|
-
|
|
243
|
+
pointer-events: none;
|
|
244
|
+
transition:
|
|
245
|
+
max-width 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
|
246
|
+
opacity 0.25s ease,
|
|
247
|
+
color 0.15s ease;
|
|
103
248
|
}
|
|
104
249
|
|
|
105
|
-
.toc-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
opacity: 0.35;
|
|
111
|
-
transition: all var(--transition);
|
|
112
|
-
flex-shrink: 0;
|
|
250
|
+
.stnd-toc:hover .toc-label,
|
|
251
|
+
.stnd-toc:focus-within .toc-label {
|
|
252
|
+
max-width: min(22rem, 36vw);
|
|
253
|
+
opacity: 0.85;
|
|
254
|
+
pointer-events: auto;
|
|
113
255
|
}
|
|
114
256
|
|
|
115
|
-
.toc-item[data-depth="
|
|
116
|
-
|
|
257
|
+
.toc-item[data-depth="3"] .toc-label {
|
|
258
|
+
font-size: var(--size-2xs, 0.725rem);
|
|
259
|
+
opacity: 0.65;
|
|
260
|
+
padding-right: 4px;
|
|
117
261
|
}
|
|
118
262
|
|
|
119
|
-
.toc-item
|
|
120
|
-
|
|
263
|
+
.toc-item:hover .toc-label {
|
|
264
|
+
opacity: 1;
|
|
265
|
+
color: var(--color-foreground);
|
|
121
266
|
}
|
|
122
|
-
|
|
123
|
-
|
|
267
|
+
|
|
268
|
+
/* Active item */
|
|
269
|
+
.toc-item[data-active] .toc-label {
|
|
270
|
+
opacity: 1 !important;
|
|
271
|
+
font-weight: 600;
|
|
272
|
+
color: var(--color-foreground);
|
|
124
273
|
}
|
|
125
274
|
|
|
126
|
-
/*
|
|
127
|
-
.
|
|
128
|
-
|
|
129
|
-
|
|
275
|
+
/* Tick Bars */
|
|
276
|
+
.toc-bar {
|
|
277
|
+
display: block;
|
|
278
|
+
height: 2px;
|
|
279
|
+
border-radius: 1px;
|
|
280
|
+
background: color-mix(in oklab, var(--color-foreground) 25%, transparent);
|
|
281
|
+
flex-shrink: 0;
|
|
282
|
+
transition:
|
|
283
|
+
width 0.25s cubic-bezier(0.16, 1, 0.3, 1),
|
|
284
|
+
background 0.2s ease,
|
|
285
|
+
box-shadow 0.2s ease,
|
|
286
|
+
transform 0.2s ease;
|
|
130
287
|
}
|
|
131
288
|
|
|
132
|
-
.
|
|
133
|
-
|
|
289
|
+
.toc-item[data-depth="2"] .toc-bar {
|
|
290
|
+
width: 14px;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.toc-item[data-depth="3"] .toc-bar {
|
|
294
|
+
width: 8px;
|
|
295
|
+
opacity: 0.75;
|
|
134
296
|
}
|
|
135
297
|
|
|
136
298
|
.stnd-toc:hover .toc-bar {
|
|
137
|
-
|
|
138
|
-
background: var(--color-muted);
|
|
299
|
+
background: color-mix(in oklab, var(--color-foreground) 35%, transparent);
|
|
139
300
|
}
|
|
140
301
|
|
|
141
302
|
.toc-item:hover .toc-bar {
|
|
142
|
-
|
|
303
|
+
background: var(--color-foreground) !important;
|
|
304
|
+
transform: scaleX(1.15);
|
|
305
|
+
transform-origin: right center;
|
|
143
306
|
}
|
|
144
307
|
|
|
145
308
|
/* Active section */
|
|
146
309
|
.toc-item[data-active] .toc-bar {
|
|
147
|
-
|
|
148
|
-
background: var(--color-foreground);
|
|
149
|
-
|
|
310
|
+
width: 22px !important;
|
|
311
|
+
background: var(--color-accent, var(--color-foreground)) !important;
|
|
312
|
+
box-shadow: 0 0 6px
|
|
313
|
+
color-mix(
|
|
314
|
+
in oklab,
|
|
315
|
+
var(--color-accent, var(--color-foreground)) 45%,
|
|
316
|
+
transparent
|
|
317
|
+
);
|
|
150
318
|
}
|
|
151
319
|
|
|
152
|
-
.toc-item[data-active] .toc-label {
|
|
153
|
-
opacity: 1 !important;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/* Set by the script below via matchMedia(breakpoint) — a CSS @media
|
|
157
|
-
condition can't take its threshold from a prop (media queries need a
|
|
158
|
-
literal value at parse time, not a per-instance one), so the
|
|
159
|
-
hide-below-breakpoint behavior has to live in JS instead. */
|
|
160
320
|
.stnd-toc[data-below-breakpoint] {
|
|
161
321
|
display: none;
|
|
162
322
|
}
|
|
@@ -165,23 +325,35 @@ const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|
|
165
325
|
<script>
|
|
166
326
|
document.addEventListener("astro:page-load", () => {
|
|
167
327
|
const toc = document.querySelector<HTMLElement>(".stnd-toc");
|
|
168
|
-
const tocLinks =
|
|
328
|
+
const tocLinks =
|
|
329
|
+
document.querySelectorAll<HTMLElement>(".toc-item[data-slug]");
|
|
169
330
|
if (tocLinks.length === 0) return;
|
|
170
331
|
|
|
171
|
-
// Hide below `breakpoint` (default 1100, set via the `breakpoint` prop
|
|
172
|
-
// and read back from data-breakpoint) — a plain CSS @media rule can't
|
|
173
|
-
// take its threshold from a prop, so this does the equivalent in JS.
|
|
174
332
|
if (toc) {
|
|
175
|
-
// `|| 1100` would silently discard an intentional breakpoint={0}
|
|
176
|
-
// (0 is falsy) — parse explicitly instead.
|
|
177
333
|
const parsedBreakpoint = Number(toc.dataset.breakpoint);
|
|
178
|
-
const breakpoint = Number.isFinite(parsedBreakpoint)
|
|
334
|
+
const breakpoint = Number.isFinite(parsedBreakpoint)
|
|
335
|
+
? parsedBreakpoint
|
|
336
|
+
: 1100;
|
|
179
337
|
const mql = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
|
180
|
-
const applyBreakpoint = () =>
|
|
338
|
+
const applyBreakpoint = () =>
|
|
339
|
+
toc.toggleAttribute("data-below-breakpoint", mql.matches);
|
|
181
340
|
applyBreakpoint();
|
|
182
341
|
mql.addEventListener("change", applyBreakpoint);
|
|
183
342
|
}
|
|
184
343
|
|
|
344
|
+
// Smooth scroll on click
|
|
345
|
+
tocLinks.forEach((link) => {
|
|
346
|
+
link.addEventListener("click", (e) => {
|
|
347
|
+
const slug = link.dataset.slug;
|
|
348
|
+
const target = slug ? document.getElementById(slug) : null;
|
|
349
|
+
if (target) {
|
|
350
|
+
e.preventDefault();
|
|
351
|
+
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
352
|
+
history.pushState(null, "", `#${slug}`);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
185
357
|
// Show on scroll or mouse move, hide after inactivity
|
|
186
358
|
let hideTimer: ReturnType<typeof setTimeout>;
|
|
187
359
|
const show = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stnd/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"svelte": "./Combobox.svelte",
|
|
6
6
|
"exports": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"wrangler": "^4.129.0",
|
|
33
|
-
"@stnd/icon": "0.2.
|
|
33
|
+
"@stnd/icon": "0.2.2",
|
|
34
34
|
"@stnd/store": "0.5.1",
|
|
35
35
|
"@stnd/utils": "0.5.1"
|
|
36
36
|
}
|