@stnd/ui 0.5.3 → 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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @stnd/ui
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Auto-bumped @stnd/ui due to modified code.
8
+
3
9
  ## 0.5.3
4
10
 
5
11
  ### Patch Changes
@@ -1,20 +1,13 @@
1
1
  ---
2
2
  /**
3
3
  * @component TableOfContents
4
- * @description Subtle Notion-style TOC — small lines fixed to the right side
5
- * that expand with heading labels on hover and highlight the current section.
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, see the example below. */
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(/&nbsp;/gi, " ")
38
+ .replace(/&amp;/gi, "&")
39
+ .replace(/&quot;/gi, '"')
40
+ .replace(/&apos;|&#39;/gi, "'")
41
+ .replace(/&lt;/gi, "<")
42
+ .replace(/&gt;/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
- const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
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 class="stnd-toc" aria-label="Table des matières" data-breakpoint={breakpoint}>
42
- {tocHeadings.map((h) => (
43
- <a
44
- href={`#${h.slug}`}
45
- class="toc-item"
46
- data-slug={h.slug}
47
- data-depth={h.depth}
48
- aria-label={h.text}
49
- >
50
- <span class="toc-label">{h.text}</span>
51
- <span class="toc-bar" />
52
- </a>
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
- transition: all var(--transition);
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[data-visible],
75
- .stnd-toc:hover {
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
- .stnd-toc:hover {
80
- gap: var(--space);
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
- gap: var(--space-d4);
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-label {
92
- font-size: var(--size-sm);
93
- color: var(--color-foreground);
94
- font-family: var(--font-interface);
95
- font-weight: var(--font-weight-bold);
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
- transition: all var(--transition-fast);
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-bar {
106
- display: block;
107
- height: var(--space-d6);
108
- border-radius: var(--radius);
109
- background: var(--color-subtle);
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="2"] .toc-bar {
116
- width: var(--space);
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[data-depth="3"] .toc-label {
120
- margin-right: var(--space-d2);
263
+ .toc-item:hover .toc-label {
264
+ opacity: 1;
265
+ color: var(--color-foreground);
121
266
  }
122
- .toc-item[data-depth="3"] .toc-bar {
123
- width: var(--space-d2);
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
- /* Hover: expand labels, brighten bars */
127
- .stnd-toc:hover .toc-label {
128
- max-width: 16em;
129
- opacity: 0.6;
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
- .stnd-toc:hover .toc-item:hover .toc-label {
133
- opacity: 1;
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
- opacity: 1;
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
- opacity: 1 !important;
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
- opacity: 1;
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 = document.querySelectorAll<HTMLElement>(".toc-item[data-slug]");
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) ? parsedBreakpoint : 1100;
334
+ const breakpoint = Number.isFinite(parsedBreakpoint)
335
+ ? parsedBreakpoint
336
+ : 1100;
179
337
  const mql = window.matchMedia(`(max-width: ${breakpoint}px)`);
180
- const applyBreakpoint = () => toc.toggleAttribute("data-below-breakpoint", mql.matches);
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.5.3",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "svelte": "./Combobox.svelte",
6
6
  "exports": {