@nubisco/docs-sdk 0.0.0-stage → 0.0.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.
@@ -0,0 +1,599 @@
1
+ <template>
2
+ <header
3
+ ref="hero"
4
+ class="doc-header"
5
+ :class="{ 'doc-header--plain': !tabs.length }"
6
+ >
7
+ <div class="doc-header__hero">
8
+ <div class="doc-header__lead">
9
+ <h1 class="doc-header__title">{{ title }}</h1>
10
+ <div v-if="status || updatedLabel" class="doc-header__meta">
11
+ <NbBadge v-if="status" :variant="statusVariant" size="sm">{{
12
+ statusLabel
13
+ }}</NbBadge>
14
+ <span v-if="updatedLabel">Last updated {{ updatedLabel }}</span>
15
+ </div>
16
+ <!-- eslint-disable-next-line vue/no-v-html -->
17
+ <p v-if="intro" class="doc-header__intro" v-html="introHtml" />
18
+ </div>
19
+
20
+ <aside v-if="links.length" class="doc-header__side" aria-label="Links">
21
+ <div class="doc-header__side-title">
22
+ <img v-if="logoSrc" :src="logoSrc" alt="" width="20" height="20" />
23
+ <span>{{ siteTitle }}</span>
24
+ </div>
25
+ <ul class="doc-header__links">
26
+ <li v-for="link in links" :key="link.href">
27
+ <a
28
+ :href="link.href"
29
+ target="_blank"
30
+ rel="noreferrer"
31
+ class="doc-header__link"
32
+ >
33
+ <NbIcon :name="link.icon" :size="16" aria-hidden="true" />
34
+ <span>{{ link.text }}</span>
35
+ </a>
36
+ </li>
37
+ </ul>
38
+ </aside>
39
+ </div>
40
+ </header>
41
+
42
+ <!--
43
+ The tab bar, and the compact title that joins it once the hero has
44
+ scrolled away. The title is in the same row as the tabs and only grows
45
+ in, so the bar keeps one height and nothing under it moves when it
46
+ sticks.
47
+
48
+ It is a sibling of the hero, not a child: a sticky element only sticks
49
+ inside its parent, and the hero scrolls away.
50
+ -->
51
+ <div
52
+ v-if="tabs.length"
53
+ ref="bar"
54
+ class="doc-header doc-header__bar"
55
+ :class="{ 'is-stuck': stuck }"
56
+ >
57
+ <nav class="doc-header__tabs" aria-label="Page sections">
58
+ <span class="doc-header__compact-title" :aria-hidden="!stuck">{{
59
+ title
60
+ }}</span>
61
+ <component
62
+ :is="tab.href ? 'a' : 'button'"
63
+ v-for="tab in tabs"
64
+ :key="tab.key"
65
+ :href="tab.href"
66
+ :type="tab.href ? undefined : 'button'"
67
+ class="doc-header__tab"
68
+ :class="{ 'is-active': tab.active }"
69
+ :aria-current="tab.active && tab.href ? 'page' : undefined"
70
+ :aria-pressed="tab.href ? undefined : tab.active"
71
+ @click="onTab($event, tab)"
72
+ >
73
+ {{ tab.label }}
74
+ </component>
75
+ <span class="doc-header__tabs-end">
76
+ <a
77
+ v-for="link in links.slice(0, 1)"
78
+ :key="link.href"
79
+ :href="link.href"
80
+ target="_blank"
81
+ rel="noreferrer"
82
+ class="doc-header__compact-link"
83
+ :aria-label="link.text"
84
+ :tabindex="stuck ? 0 : -1"
85
+ >
86
+ <NbIcon :name="link.icon" :size="18" aria-hidden="true" />
87
+ </a>
88
+ </span>
89
+ </nav>
90
+ </div>
91
+ </template>
92
+
93
+ <script setup lang="ts">
94
+ /**
95
+ * The page header every documentation page wears, modelled on Carbon's.
96
+ *
97
+ * A large title, a status tag when the page declares one, the intro paragraph
98
+ * and a block of links, then the tab bar. Once the hero scrolls under the
99
+ * site header, the bar sticks with the title in a compact row above the tabs,
100
+ * so a reader deep in a long page still knows which component and which tab
101
+ * they are on.
102
+ */
103
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
104
+ import { useData, useRouter, withBase } from 'vitepress'
105
+ import { NbBadge, NbIcon } from '@nubisco/ui'
106
+ import * as iconGithub from '@nubisco/ui/icons/github-logo'
107
+ import * as iconPencil from '@nubisco/ui/icons/pencil-simple'
108
+ import * as iconBug from '@nubisco/ui/icons/bug'
109
+ import type { INubiscoDocsThemeConfig } from '../../node/types'
110
+
111
+ export interface IDocHeaderTab {
112
+ key: string
113
+ label: string
114
+ href?: string
115
+ active: boolean
116
+ }
117
+
118
+ const props = defineProps<{
119
+ title?: string
120
+ intro?: string
121
+ status?: string
122
+ source?: string
123
+ editLink?: string | null
124
+ tabs: IDocHeaderTab[]
125
+ }>()
126
+
127
+ const emit = defineEmits<{ select: [key: string] }>()
128
+
129
+ const { site, page, theme } = useData()
130
+ const router = useRouter()
131
+
132
+ const STATUS: Record<string, string> = {
133
+ stable: 'green',
134
+ beta: 'blue',
135
+ experimental: 'purple',
136
+ deprecated: 'red',
137
+ draft: 'grey',
138
+ labs: 'purple',
139
+ }
140
+ const statusLabel = computed(() => {
141
+ const s = props.status ?? ''
142
+ return s.charAt(0).toUpperCase() + s.slice(1)
143
+ })
144
+ const statusVariant = computed(
145
+ () => (STATUS[(props.status ?? '').toLowerCase()] ?? 'grey') as 'grey',
146
+ )
147
+
148
+ const updatedLabel = computed(() =>
149
+ page.value.lastUpdated
150
+ ? new Date(page.value.lastUpdated).toLocaleDateString('en-GB', {
151
+ day: 'numeric',
152
+ month: 'short',
153
+ year: 'numeric',
154
+ })
155
+ : '',
156
+ )
157
+
158
+ // The site's own name and mark, from the VitePress config, so the links block
159
+ // reads as the product the page belongs to.
160
+ const siteTitle = computed(() => theme.value.siteTitle || site.value.title)
161
+ const logoSrc = computed(() => {
162
+ const logo = theme.value.logo
163
+ const src = typeof logo === 'string' ? logo : (logo?.src ?? logo?.light)
164
+ return src ? withBase(src) : ''
165
+ })
166
+
167
+ const nubisco = computed<INubiscoDocsThemeConfig | undefined>(
168
+ () => theme.value.nubisco,
169
+ )
170
+
171
+ const links = computed(() => {
172
+ const out: { href: string; text: string; icon: unknown }[] = []
173
+ const repo = nubisco.value?.repo
174
+ const branch = nubisco.value?.branch ?? 'master'
175
+ if (props.source && repo)
176
+ out.push({
177
+ href: `https://github.com/${repo}/blob/${branch}/${props.source}`,
178
+ text: 'Source on GitHub',
179
+ icon: iconGithub,
180
+ })
181
+ if (props.editLink)
182
+ out.push({
183
+ href: props.editLink,
184
+ text: 'Edit this page',
185
+ icon: iconPencil,
186
+ })
187
+ if (props.source && repo)
188
+ out.push({
189
+ href: `https://github.com/${repo}/issues/new`,
190
+ text: 'Report an issue',
191
+ icon: iconBug,
192
+ })
193
+ return out
194
+ })
195
+
196
+ /*
197
+ * The intro is one paragraph of inline Markdown carried in frontmatter, so it
198
+ * can be shown on every tab of a page without repeating it in each file. Only
199
+ * the inline forms an intro uses are handled: code, bold, emphasis and links.
200
+ */
201
+ function escape(s: string) {
202
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
203
+ }
204
+ function href(url: string) {
205
+ if (!url.startsWith('/')) return url
206
+ const [path, hash] = url.split('#')
207
+ const ext = site.value.cleanUrls || /\.\w+$/.test(path) ? '' : '.html'
208
+ return withBase(path + ext) + (hash ? `#${hash}` : '')
209
+ }
210
+ const introHtml = computed(() => {
211
+ const codes: string[] = []
212
+ let s = escape(props.intro ?? '').replace(/`([^`]+)`/g, (_, c) => {
213
+ codes.push(`<code>${c}</code>`)
214
+ return `\u0000${codes.length - 1}\u0000`
215
+ })
216
+ s = s
217
+ .replace(
218
+ /\[([^\]]+)\]\(([^)\s]+)\)/g,
219
+ (_, t, u) => `<a href="${href(u)}">${t}</a>`,
220
+ )
221
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
222
+ .replace(/(^|[^\w])_([^_]+)_(?=[^\w]|$)/g, '$1<em>$2</em>')
223
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
224
+ // eslint-disable-next-line no-control-regex
225
+ return s.replace(/\u0000(\d+)\u0000/g, (_, i) => codes[Number(i)])
226
+ })
227
+
228
+ // ── Sticky state ──────────────────────────────────────────────────────────
229
+ const hero = ref<HTMLElement | null>(null)
230
+ const bar = ref<HTMLElement | null>(null)
231
+ const stuck = ref(false)
232
+
233
+ /** Where the bar sticks: under the site header, or the local nav on narrow screens. */
234
+ function stickTop() {
235
+ return bar.value ? parseFloat(getComputedStyle(bar.value).top) || 0 : 0
236
+ }
237
+
238
+ const clamp = (value: number) => Math.min(1, Math.max(0, value))
239
+
240
+ /**
241
+ * `--dock` runs from 0 (top of the page) to 1 (the bar is pinned under the
242
+ * site header). It closes the inset around the hero and the bar together, so
243
+ * the card turns into a band edge to edge and the two edges stay flush the
244
+ * whole way. `--dock-late` brings the compact title in only near the end,
245
+ * once the big title has left. Both are written straight to the element as
246
+ * custom properties: going through reactive state would re-render the header
247
+ * on every scroll frame.
248
+ */
249
+ let frame = 0
250
+ function measure() {
251
+ frame = 0
252
+ if (!bar.value || !hero.value) return
253
+ // The distance the bar has to travel to reach the site header, measured
254
+ // from the hero's own bottom edge so it holds at any height.
255
+ const travel = Math.max(
256
+ 1,
257
+ hero.value.getBoundingClientRect().bottom + window.scrollY - stickTop(),
258
+ )
259
+ const dock = clamp(window.scrollY / travel)
260
+ const late = clamp((dock - 0.6) / 0.4)
261
+
262
+ const host = hero.value.parentElement
263
+ host?.style.setProperty('--dock', dock.toFixed(3))
264
+ host?.style.setProperty('--dock-late', late.toFixed(3))
265
+ stuck.value = late > 0.5
266
+ }
267
+ function onScroll() {
268
+ if (!frame) frame = requestAnimationFrame(measure)
269
+ }
270
+
271
+ onMounted(() => {
272
+ window.addEventListener('scroll', onScroll, { passive: true })
273
+ window.addEventListener('resize', onScroll, { passive: true })
274
+ measure()
275
+ })
276
+ onBeforeUnmount(() => {
277
+ window.removeEventListener('scroll', onScroll)
278
+ window.removeEventListener('resize', onScroll)
279
+ if (frame) cancelAnimationFrame(frame)
280
+ })
281
+
282
+ /**
283
+ * Switching tab while the bar is stuck keeps it stuck: the new tab opens at
284
+ * its own first line, under the bar, instead of jumping back to the top of
285
+ * the hero.
286
+ */
287
+ async function onTab(event: MouseEvent, tab: IDocHeaderTab) {
288
+ if (!tab.href) {
289
+ emit('select', tab.key)
290
+ keepPlace()
291
+ return
292
+ }
293
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0)
294
+ return
295
+ event.preventDefault()
296
+ const wasStuck = stuck.value
297
+ await router.go(tab.href)
298
+ if (wasStuck) keepPlace()
299
+ }
300
+
301
+ function keepPlace() {
302
+ requestAnimationFrame(() => {
303
+ if (!bar.value || !hero.value) return
304
+ // The scroll position at which the bar has just reached its sticky top.
305
+ const heroBottom =
306
+ hero.value.getBoundingClientRect().bottom + window.scrollY
307
+ const y = heroBottom - stickTop()
308
+ if (window.scrollY > y) window.scrollTo({ top: y })
309
+ })
310
+ }
311
+ </script>
312
+
313
+ <style lang="scss" scoped>
314
+ @use '@nubisco/ui/variables' as v;
315
+
316
+ /*
317
+ * Carbon's structure, in Nubisco's tokens: the header is a layer-1 card on
318
+ * the layer-0 page, divided from it by a hairline, and the only colour in it
319
+ * is the accent on the active tab.
320
+ *
321
+ * At the top of the page the card is inset by `--doc-inset`, so the ground
322
+ * shows around it. The hero and the bar close that inset together, on the one
323
+ * `--dock` curve the script writes, and end as a band edge to edge. The text
324
+ * does not move while they do: each side's padding takes up what its margin
325
+ * gives.
326
+ */
327
+ .doc-header {
328
+ --doc-inset: calc(var(--nb-base-unit) * 2);
329
+
330
+ color: var(--nb-c-text);
331
+
332
+ // The hairline closes whichever comes last: the tab bar, or the hero on a
333
+ // page without tabs.
334
+ &--plain &__hero,
335
+ &__bar {
336
+ border-bottom: 1px solid var(--nb-c-layer-2);
337
+ }
338
+
339
+ &__hero {
340
+ // The card is the hero and the bar, not the whole header: the root stays
341
+ // clear so the ground shows in the inset.
342
+ background: var(--nb-c-layer-1);
343
+ display: grid;
344
+ grid-template-columns: minmax(0, 1fr) auto;
345
+ gap: calc(var(--nb-base-unit) * 4);
346
+ align-items: end;
347
+ // The top inset stays fixed: it changes the card's height, and that
348
+ // height is what `--dock` is measured against.
349
+ margin: var(--doc-inset) calc((1 - var(--dock, 0)) * var(--doc-inset)) 0;
350
+ // The left edge lines up with the tab labels below, which sit one tab
351
+ // padding in from the bar.
352
+ padding: calc(var(--nb-base-unit) * 6)
353
+ calc(var(--nb-base-unit) * 3 + var(--dock, 0) * var(--doc-inset))
354
+ calc(var(--nb-base-unit) * 3)
355
+ calc(var(--nb-base-unit) * 5 + var(--dock, 0) * var(--doc-inset));
356
+ }
357
+
358
+ &__title {
359
+ margin: 0;
360
+ font-size: clamp(2.25rem, 4.2vw, 3.5rem);
361
+ font-weight: 300;
362
+ line-height: 1.1;
363
+ letter-spacing: -0.015em;
364
+ color: var(--nb-c-text);
365
+ }
366
+
367
+ &__meta {
368
+ display: flex;
369
+ align-items: center;
370
+ gap: calc(var(--nb-base-unit) * 1.5);
371
+ margin-top: calc(var(--nb-base-unit) * 2);
372
+ font-size: 0.875rem;
373
+ color: var(--nb-c-text-muted);
374
+ }
375
+
376
+ &__intro {
377
+ max-width: 44rem;
378
+ margin: calc(var(--nb-base-unit) * 5) 0 0;
379
+ font-size: 1.25rem;
380
+ line-height: 1.5;
381
+ color: var(--nb-c-text);
382
+
383
+ :deep(code) {
384
+ font-family: var(--nb-font-family-mono, monospace);
385
+ font-size: 0.85em;
386
+ background: var(--nb-c-layer-2);
387
+ padding: 1px 6px;
388
+ border-radius: 4px;
389
+ }
390
+
391
+ :deep(a) {
392
+ color: var(--vp-c-brand-1);
393
+ text-decoration: underline;
394
+ text-underline-offset: 3px;
395
+ }
396
+ }
397
+
398
+ &__side {
399
+ min-width: 13rem;
400
+ padding-bottom: calc(var(--nb-base-unit) * 0.5);
401
+ }
402
+
403
+ &__side-title {
404
+ display: flex;
405
+ align-items: center;
406
+ gap: var(--nb-base-unit);
407
+ font-weight: 600;
408
+ font-size: 0.95rem;
409
+ margin-bottom: calc(var(--nb-base-unit) * 1.5);
410
+ }
411
+
412
+ &__links {
413
+ list-style: none;
414
+ margin: 0;
415
+ padding: 0;
416
+ display: grid;
417
+ gap: var(--nb-base-unit);
418
+ }
419
+
420
+ &__link {
421
+ display: inline-flex;
422
+ align-items: center;
423
+ gap: var(--nb-base-unit);
424
+ font-size: 0.9rem;
425
+ color: var(--vp-c-brand-1);
426
+ text-decoration: none;
427
+
428
+ &:hover {
429
+ text-decoration: underline;
430
+ text-underline-offset: 3px;
431
+ }
432
+ }
433
+
434
+ /* ── The bar ─────────────────────────────────────────────────────────── */
435
+ &__bar {
436
+ position: sticky;
437
+ top: var(--vp-nav-height, 64px);
438
+ z-index: 20;
439
+ background: var(--nb-c-layer-1);
440
+ margin: 0 calc((1 - var(--dock, 0)) * var(--doc-inset));
441
+ padding: 0 calc(var(--dock, 0) * var(--doc-inset));
442
+ }
443
+
444
+ // The compact title sits in the tab row and grows in near the end of the
445
+ // dock, so the bar is one row at every point and nothing under it moves.
446
+ &__compact-title {
447
+ flex: none;
448
+ align-self: center;
449
+ overflow: hidden;
450
+ max-width: calc(var(--dock-late, 0) * 16rem);
451
+ margin-right: calc(var(--dock-late, 0) * var(--nb-base-unit) * 3);
452
+ opacity: var(--dock-late, 0);
453
+ font-size: 1rem;
454
+ font-weight: 600;
455
+ white-space: nowrap;
456
+ text-overflow: ellipsis;
457
+ }
458
+
459
+ &__tabs {
460
+ display: flex;
461
+ align-items: stretch;
462
+ padding: 0 calc(var(--nb-base-unit) * 3);
463
+ overflow-x: auto;
464
+ scrollbar-width: none;
465
+ }
466
+
467
+ &__tab {
468
+ appearance: none;
469
+ position: relative;
470
+ display: inline-flex;
471
+ align-items: center;
472
+ flex: none;
473
+ min-height: calc(var(--nb-base-unit) * 6);
474
+ padding: 0 calc(var(--nb-base-unit) * 2);
475
+ border: 0;
476
+ background: transparent;
477
+ font: inherit;
478
+ font-size: 0.95rem;
479
+ color: var(--nb-c-text-subtle);
480
+ text-decoration: none;
481
+ cursor: pointer;
482
+ transition: color 0.15s ease;
483
+
484
+ // Carbon draws a hairline under every tab and the accent under the
485
+ // current one. Both sit on the bar's bottom edge.
486
+ &::after {
487
+ content: '';
488
+ position: absolute;
489
+ left: 0;
490
+ right: 0;
491
+ bottom: 0;
492
+ height: 2px;
493
+ background: var(--nb-c-layer-2);
494
+ transition:
495
+ background 0.15s ease,
496
+ height 0.15s ease;
497
+ }
498
+
499
+ &:hover {
500
+ color: var(--nb-c-text);
501
+
502
+ &::after {
503
+ background: var(--nb-c-layer-border-1);
504
+ }
505
+ }
506
+
507
+ &.is-active {
508
+ color: var(--nb-c-text);
509
+ font-weight: 600;
510
+
511
+ &::after {
512
+ height: 3px;
513
+ background: var(--nb-c-primary);
514
+ }
515
+ }
516
+
517
+ &:focus-visible {
518
+ outline: 2px solid var(--nb-c-primary);
519
+ outline-offset: -2px;
520
+ }
521
+ }
522
+
523
+ &__tabs-end {
524
+ margin-left: auto;
525
+ display: flex;
526
+ align-items: center;
527
+ padding-left: calc(var(--nb-base-unit) * 2);
528
+ }
529
+
530
+ &__compact-link {
531
+ display: inline-flex;
532
+ padding: calc(var(--nb-base-unit) * 0.75);
533
+ color: var(--nb-c-text);
534
+ opacity: 0;
535
+ pointer-events: none;
536
+ transition: opacity 0.18s ease;
537
+
538
+ .is-stuck & {
539
+ opacity: 1;
540
+ pointer-events: auto;
541
+ }
542
+ }
543
+ }
544
+
545
+ /* Below VitePress's own breakpoint the site header scrolls away and the local
546
+ nav (the "Menu" row) is what stays at the top, so the bar sticks under it. */
547
+ @media (max-width: 959px) {
548
+ .doc-header__bar {
549
+ top: 48px;
550
+ }
551
+ }
552
+
553
+ @include v.phone {
554
+ .doc-header {
555
+ // No ground to show around the card on a phone.
556
+ --doc-inset: 0px;
557
+
558
+ &__hero {
559
+ grid-template-columns: minmax(0, 1fr);
560
+ gap: calc(var(--nb-base-unit) * 3);
561
+ padding: calc(var(--nb-base-unit) * 4) calc(var(--nb-base-unit) * 2)
562
+ calc(var(--nb-base-unit) * 2);
563
+ }
564
+
565
+ &__intro {
566
+ margin-top: calc(var(--nb-base-unit) * 3);
567
+ font-size: 1.075rem;
568
+ }
569
+
570
+ &__side-title {
571
+ display: none;
572
+ }
573
+
574
+ &__links {
575
+ display: flex;
576
+ flex-wrap: wrap;
577
+ gap: var(--nb-base-unit) calc(var(--nb-base-unit) * 2.5);
578
+ }
579
+
580
+ &__link {
581
+ white-space: nowrap;
582
+ }
583
+
584
+ // On a phone the tab row is the whole bar, with no compact title: the
585
+ // name is already in the local nav's breadcrumb.
586
+ &__compact-title {
587
+ display: none;
588
+ }
589
+
590
+ &__tabs {
591
+ padding: 0 var(--nb-base-unit);
592
+ }
593
+
594
+ &__tabs-end {
595
+ display: none;
596
+ }
597
+ }
598
+ }
599
+ </style>