@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.
- package/LICENSE +20 -0
- package/README.md +101 -2
- package/THIRD_PARTY_NOTICES.md +100 -0
- package/dist/cli.js +33 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.js +361 -0
- package/package.json +122 -4
- package/src/plugins/mermaid/Mermaid.vue +93 -0
- package/src/plugins/mermaid/index.ts +66 -0
- package/src/plugins/mermaid/mermaid-markdown.ts +34 -0
- package/src/plugins/mermaid/mermaid-plugin.ts +46 -0
- package/src/plugins/mermaid/mermaid.ts +21 -0
- package/src/plugins/tabs/components/ContentTab.vue +41 -0
- package/src/plugins/tabs/components/ContentTabs.vue +150 -0
- package/src/plugins/tabs/components/index.ts +10 -0
- package/src/plugins/tabs/components/useStabilizeScrollPosition.ts +36 -0
- package/src/plugins/tabs/components/useTabLabels.ts +18 -0
- package/src/plugins/tabs/components/useTabsSelectedState.ts +95 -0
- package/src/plugins/tabs/components/useTabsSingleState.ts +22 -0
- package/src/plugins/tabs/index.ts +1 -0
- package/src/plugins/tabs/markdown/index.ts +1 -0
- package/src/plugins/tabs/markdown/markdownPlugin.ts +47 -0
- package/src/plugins/tabs/markdown/ruleBlockTab.ts +97 -0
- package/src/theme/Layout.vue +32 -0
- package/src/theme/Nubisco.vue +480 -0
- package/src/theme/components/DocHeader.vue +599 -0
- package/src/theme/components/DocOutline.vue +235 -0
- package/src/theme/components/DocsControls.vue +173 -0
- package/src/theme/components/DocsSidebarNav.vue +313 -0
- package/src/theme/custom.css +591 -0
- package/src/theme/index.ts +33 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<nav
|
|
3
|
+
v-if="items.length > 1"
|
|
4
|
+
class="doc-outline"
|
|
5
|
+
aria-labelledby="doc-outline-title"
|
|
6
|
+
>
|
|
7
|
+
<p id="doc-outline-title" class="doc-outline__title">On this page</p>
|
|
8
|
+
<div class="doc-outline__track">
|
|
9
|
+
<span
|
|
10
|
+
class="doc-outline__marker"
|
|
11
|
+
:style="markerStyle"
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
/>
|
|
14
|
+
<ul ref="list" class="doc-outline__list">
|
|
15
|
+
<li v-for="item in items" :key="item.id">
|
|
16
|
+
<a
|
|
17
|
+
:href="`#${item.id}`"
|
|
18
|
+
class="doc-outline__link"
|
|
19
|
+
:class="{ 'is-active': item.id === active }"
|
|
20
|
+
:aria-current="item.id === active ? 'location' : undefined"
|
|
21
|
+
@click="onClick($event, item.id)"
|
|
22
|
+
>
|
|
23
|
+
{{ item.text }}
|
|
24
|
+
</a>
|
|
25
|
+
</li>
|
|
26
|
+
</ul>
|
|
27
|
+
</div>
|
|
28
|
+
</nav>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script setup lang="ts">
|
|
32
|
+
/**
|
|
33
|
+
* "On this page": the page's sections, with an accent bar on a grey track
|
|
34
|
+
* that follows the section being read, as Carbon's does.
|
|
35
|
+
*
|
|
36
|
+
* It reads the headings from the rendered page rather than from VitePress's
|
|
37
|
+
* header data, because the layout hides inactive in-page tabs, and a section
|
|
38
|
+
* the reader cannot see should not be offered as a place to go.
|
|
39
|
+
*/
|
|
40
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
41
|
+
import { useRoute } from 'vitepress'
|
|
42
|
+
|
|
43
|
+
const props = defineProps<{
|
|
44
|
+
/** The element whose `h2`s are listed. */
|
|
45
|
+
root: HTMLElement | null
|
|
46
|
+
/** Anything that changes which headings are visible (an in-page tab). */
|
|
47
|
+
watchKey?: unknown
|
|
48
|
+
}>()
|
|
49
|
+
|
|
50
|
+
interface IItem {
|
|
51
|
+
id: string
|
|
52
|
+
text: string
|
|
53
|
+
el: HTMLElement
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const route = useRoute()
|
|
57
|
+
const items = ref<IItem[]>([])
|
|
58
|
+
const active = ref('')
|
|
59
|
+
const list = ref<HTMLElement | null>(null)
|
|
60
|
+
const marker = ref({ top: 0, height: 0 })
|
|
61
|
+
|
|
62
|
+
function collect() {
|
|
63
|
+
const root = props.root
|
|
64
|
+
if (!root) {
|
|
65
|
+
items.value = []
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
items.value = Array.from(root.querySelectorAll<HTMLElement>('h2[id]'))
|
|
69
|
+
.filter((el) => el.offsetParent !== null)
|
|
70
|
+
.map((el) => ({
|
|
71
|
+
id: el.id,
|
|
72
|
+
// The heading's own text, without VitePress's "#" anchor.
|
|
73
|
+
text: Array.from(el.childNodes)
|
|
74
|
+
.filter(
|
|
75
|
+
(n) =>
|
|
76
|
+
!(
|
|
77
|
+
n instanceof HTMLElement && n.classList.contains('header-anchor')
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
.map((n) => n.textContent)
|
|
81
|
+
.join('')
|
|
82
|
+
.replace(//g, '')
|
|
83
|
+
.trim(),
|
|
84
|
+
el,
|
|
85
|
+
}))
|
|
86
|
+
update()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The sticky chrome above the content, so "in view" means below it. */
|
|
90
|
+
function offset() {
|
|
91
|
+
const bar = document.querySelector<HTMLElement>('.doc-header__bar')
|
|
92
|
+
if (bar) return bar.getBoundingClientRect().bottom + 24
|
|
93
|
+
const nav = document.querySelector<HTMLElement>('.VPNav')
|
|
94
|
+
return (nav?.getBoundingClientRect().bottom ?? 64) + 24
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let frame = 0
|
|
98
|
+
function update() {
|
|
99
|
+
frame = 0
|
|
100
|
+
if (!items.value.length) return
|
|
101
|
+
const line = offset()
|
|
102
|
+
let current = items.value[0].id
|
|
103
|
+
for (const item of items.value) {
|
|
104
|
+
if (item.el.getBoundingClientRect().top <= line) current = item.id
|
|
105
|
+
else break
|
|
106
|
+
}
|
|
107
|
+
// At the very bottom the last sections can never reach the line, so the
|
|
108
|
+
// last one is current once the page cannot scroll any further.
|
|
109
|
+
const atEnd =
|
|
110
|
+
window.innerHeight + window.scrollY >=
|
|
111
|
+
document.documentElement.scrollHeight - 2
|
|
112
|
+
if (atEnd) current = items.value[items.value.length - 1].id
|
|
113
|
+
active.value = current
|
|
114
|
+
nextTick(placeMarker)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function placeMarker() {
|
|
118
|
+
const link = list.value?.querySelector<HTMLElement>('.is-active')
|
|
119
|
+
if (!link) return
|
|
120
|
+
marker.value = { top: link.offsetTop, height: link.offsetHeight }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const markerStyle = computed(() => ({
|
|
124
|
+
transform: `translateY(${marker.value.top}px)`,
|
|
125
|
+
height: `${marker.value.height}px`,
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
function onScroll() {
|
|
129
|
+
if (!frame) frame = requestAnimationFrame(update)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function onClick(event: MouseEvent, id: string) {
|
|
133
|
+
const target = document.getElementById(id)
|
|
134
|
+
if (!target) return
|
|
135
|
+
event.preventDefault()
|
|
136
|
+
const y = target.getBoundingClientRect().top + window.scrollY - offset() + 8
|
|
137
|
+
window.scrollTo({ top: y, behavior: 'smooth' })
|
|
138
|
+
history.replaceState(history.state, '', `#${id}`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
onMounted(() => {
|
|
142
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
143
|
+
window.addEventListener('resize', onScroll, { passive: true })
|
|
144
|
+
nextTick(collect)
|
|
145
|
+
})
|
|
146
|
+
onBeforeUnmount(() => {
|
|
147
|
+
window.removeEventListener('scroll', onScroll)
|
|
148
|
+
window.removeEventListener('resize', onScroll)
|
|
149
|
+
if (frame) cancelAnimationFrame(frame)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
watch(
|
|
153
|
+
() => [route.path, props.root, props.watchKey],
|
|
154
|
+
() => {
|
|
155
|
+
// Demos render client-side and move headings as they mount, so look
|
|
156
|
+
// again once they have settled.
|
|
157
|
+
nextTick(collect)
|
|
158
|
+
setTimeout(collect, 300)
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
</script>
|
|
162
|
+
|
|
163
|
+
<style lang="scss" scoped>
|
|
164
|
+
.doc-outline {
|
|
165
|
+
font-size: 0.875rem;
|
|
166
|
+
|
|
167
|
+
&__title {
|
|
168
|
+
margin: 0 0 calc(var(--nb-base-unit) * 1.5);
|
|
169
|
+
padding-left: calc(var(--nb-base-unit) * 2);
|
|
170
|
+
font-weight: 600;
|
|
171
|
+
color: var(--nb-c-text);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
&__track {
|
|
175
|
+
position: relative;
|
|
176
|
+
|
|
177
|
+
// The grey track the marker runs on.
|
|
178
|
+
&::before {
|
|
179
|
+
content: '';
|
|
180
|
+
position: absolute;
|
|
181
|
+
left: 0;
|
|
182
|
+
top: 0;
|
|
183
|
+
bottom: 0;
|
|
184
|
+
width: 2px;
|
|
185
|
+
background: var(--nb-c-layer-2);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
&__marker {
|
|
190
|
+
position: absolute;
|
|
191
|
+
left: 0;
|
|
192
|
+
top: 0;
|
|
193
|
+
width: 3px;
|
|
194
|
+
background: var(--nb-c-primary);
|
|
195
|
+
transition:
|
|
196
|
+
transform 0.25s cubic-bezier(0.2, 0, 0.38, 0.9),
|
|
197
|
+
height 0.25s cubic-bezier(0.2, 0, 0.38, 0.9);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
&__list {
|
|
201
|
+
list-style: none;
|
|
202
|
+
margin: 0;
|
|
203
|
+
padding: 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
&__link {
|
|
207
|
+
display: block;
|
|
208
|
+
padding: 6px 0 6px calc(var(--nb-base-unit) * 2);
|
|
209
|
+
line-height: 1.4;
|
|
210
|
+
color: var(--nb-c-text-subtle);
|
|
211
|
+
text-decoration: none;
|
|
212
|
+
transition: color 0.15s ease;
|
|
213
|
+
|
|
214
|
+
&:hover {
|
|
215
|
+
color: var(--nb-c-text);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
&.is-active {
|
|
219
|
+
color: var(--nb-c-text);
|
|
220
|
+
font-weight: 600;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
&:focus-visible {
|
|
224
|
+
outline: 2px solid var(--nb-c-primary);
|
|
225
|
+
outline-offset: -2px;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@media (prefers-reduced-motion: reduce) {
|
|
231
|
+
.doc-outline__marker {
|
|
232
|
+
transition: none;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
</style>
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="docs-controls">
|
|
3
|
+
<div
|
|
4
|
+
class="docs-controls__group"
|
|
5
|
+
role="group"
|
|
6
|
+
aria-label="Colour mode"
|
|
7
|
+
:title="`Colour mode: ${resolved}`"
|
|
8
|
+
>
|
|
9
|
+
<button
|
|
10
|
+
v-for="option in modes"
|
|
11
|
+
:key="option.value"
|
|
12
|
+
type="button"
|
|
13
|
+
class="docs-controls__btn"
|
|
14
|
+
:class="{ 'docs-controls__btn--on': theme === option.value }"
|
|
15
|
+
:aria-pressed="theme === option.value"
|
|
16
|
+
@click="setTheme(option.value)"
|
|
17
|
+
>
|
|
18
|
+
<span class="docs-controls__icon" aria-hidden="true">{{
|
|
19
|
+
option.glyph
|
|
20
|
+
}}</span>
|
|
21
|
+
<span class="docs-controls__label">{{ option.label }}</span>
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div
|
|
26
|
+
v-if="theme_.nubisco?.appearanceControl"
|
|
27
|
+
class="docs-controls__group"
|
|
28
|
+
role="group"
|
|
29
|
+
aria-label="Corner appearance"
|
|
30
|
+
>
|
|
31
|
+
<button
|
|
32
|
+
v-for="option in shapes"
|
|
33
|
+
:key="option.value"
|
|
34
|
+
type="button"
|
|
35
|
+
class="docs-controls__btn"
|
|
36
|
+
:class="{ 'docs-controls__btn--on': appearance === option.value }"
|
|
37
|
+
:aria-pressed="appearance === option.value"
|
|
38
|
+
@click="setAppearance(option.value)"
|
|
39
|
+
>
|
|
40
|
+
<span
|
|
41
|
+
class="docs-controls__swatch"
|
|
42
|
+
:class="`docs-controls__swatch--${option.value}`"
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
/>
|
|
45
|
+
<span class="docs-controls__label">{{ option.label }}</span>
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<script setup lang="ts">
|
|
52
|
+
/**
|
|
53
|
+
* The two switches the whole documentation site is driven by.
|
|
54
|
+
*
|
|
55
|
+
* They are the library's own composables, not documentation-only state:
|
|
56
|
+
* `useTheme()` owns the `.dark` class and `useAppearance()` owns
|
|
57
|
+
* `data-nb-appearance`, both on `<html>`. So the site is exercising the same
|
|
58
|
+
* public API a consumer would call, and every example on every page follows
|
|
59
|
+
* along, teleported overlays included.
|
|
60
|
+
*
|
|
61
|
+
* The two are deliberately separate controls rather than one combined menu,
|
|
62
|
+
* because the settings are independent: changing one must not disturb the
|
|
63
|
+
* other, and a single control implying four modes would suggest otherwise.
|
|
64
|
+
*/
|
|
65
|
+
import { useData } from 'vitepress'
|
|
66
|
+
import { useTheme } from '@nubisco/ui/composables/useTheme.composable'
|
|
67
|
+
import { useAppearance } from '@nubisco/ui/composables/useAppearance.composable'
|
|
68
|
+
|
|
69
|
+
// The Square and Round switch belongs to the component library's own docs: it
|
|
70
|
+
// is shown only when the site asks for it (`themeConfig.nubisco.appearanceControl`).
|
|
71
|
+
const { theme: theme_ } = useData()
|
|
72
|
+
|
|
73
|
+
const { theme, resolved, setTheme } = useTheme()
|
|
74
|
+
const { appearance, setAppearance } = useAppearance()
|
|
75
|
+
|
|
76
|
+
const modes = [
|
|
77
|
+
{ value: 'light' as const, label: 'Light', glyph: '☀' },
|
|
78
|
+
{ value: 'dark' as const, label: 'Dark', glyph: '☾' },
|
|
79
|
+
{ value: 'system' as const, label: 'Auto', glyph: '◐' },
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const shapes = [
|
|
83
|
+
{ value: 'square' as const, label: 'Square' },
|
|
84
|
+
{ value: 'rounded' as const, label: 'Round' },
|
|
85
|
+
]
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<style scoped>
|
|
89
|
+
.docs-controls {
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
gap: 10px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.docs-controls__group {
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
gap: 1px;
|
|
99
|
+
padding: 2px;
|
|
100
|
+
background: var(--nb-c-layer-2);
|
|
101
|
+
border-radius: 6px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.docs-controls__btn {
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
gap: 5px;
|
|
108
|
+
height: 26px;
|
|
109
|
+
padding: 0 8px;
|
|
110
|
+
border: 0;
|
|
111
|
+
border-radius: 4px;
|
|
112
|
+
background: transparent;
|
|
113
|
+
color: var(--nb-c-text-subtle);
|
|
114
|
+
font: inherit;
|
|
115
|
+
font-size: 12px;
|
|
116
|
+
line-height: 1;
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
transition:
|
|
119
|
+
background 0.15s,
|
|
120
|
+
color 0.15s;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.docs-controls__btn:hover {
|
|
124
|
+
color: var(--nb-c-text);
|
|
125
|
+
background: var(--nb-c-layer-hover-1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* The header is a layer-1 surface like the page header under it, so the
|
|
129
|
+
pressed switch lifts to the layer above the track it sits in. */
|
|
130
|
+
.docs-controls__btn--on {
|
|
131
|
+
color: var(--nb-c-text);
|
|
132
|
+
background: var(--nb-c-layer-1);
|
|
133
|
+
box-shadow: 0 0 0 1px var(--nb-c-layer-border-1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.docs-controls__btn:focus-visible {
|
|
137
|
+
outline: 2px solid var(--nb-c-primary);
|
|
138
|
+
outline-offset: 1px;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.docs-controls__icon {
|
|
142
|
+
font-size: 13px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* The shape control shows the thing it does rather than naming it twice. */
|
|
146
|
+
.docs-controls__swatch {
|
|
147
|
+
width: 11px;
|
|
148
|
+
height: 11px;
|
|
149
|
+
border: 1.5px solid currentColor;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.docs-controls__swatch--rounded {
|
|
153
|
+
border-radius: 4px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* The nav bar is tight on a laptop and very tight on a tablet; the words go
|
|
157
|
+
first, the controls stay. */
|
|
158
|
+
@media (max-width: 1100px) {
|
|
159
|
+
.docs-controls__label {
|
|
160
|
+
display: none;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.docs-controls__btn {
|
|
164
|
+
padding: 0 6px;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@media (max-width: 768px) {
|
|
169
|
+
.docs-controls {
|
|
170
|
+
display: none;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
</style>
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="docs-sidenav">
|
|
3
|
+
<div class="docs-sidenav__find">
|
|
4
|
+
<NbTextInput
|
|
5
|
+
v-model="query"
|
|
6
|
+
size="sm"
|
|
7
|
+
type="search"
|
|
8
|
+
placeholder="Find"
|
|
9
|
+
aria-label="Find in navigation"
|
|
10
|
+
autocomplete="off"
|
|
11
|
+
>
|
|
12
|
+
<template #leading>
|
|
13
|
+
<NbIcon
|
|
14
|
+
:name="iconSearch"
|
|
15
|
+
:size="16"
|
|
16
|
+
class="docs-sidenav__find-icon"
|
|
17
|
+
aria-hidden="true"
|
|
18
|
+
/>
|
|
19
|
+
</template>
|
|
20
|
+
</NbTextInput>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<p v-if="query && !groups.length" class="docs-sidenav__empty">
|
|
24
|
+
Nothing matches "{{ query }}".
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<section
|
|
28
|
+
v-for="group in groups"
|
|
29
|
+
:key="group.text"
|
|
30
|
+
class="docs-sidenav__group"
|
|
31
|
+
>
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
class="docs-sidenav__heading"
|
|
35
|
+
:aria-expanded="isOpen(group)"
|
|
36
|
+
@click="toggle(group)"
|
|
37
|
+
>
|
|
38
|
+
<span>{{ group.text }}</span>
|
|
39
|
+
<NbIcon
|
|
40
|
+
:name="iconCaret"
|
|
41
|
+
:size="14"
|
|
42
|
+
class="docs-sidenav__caret"
|
|
43
|
+
:class="{ 'is-open': isOpen(group) }"
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
/>
|
|
46
|
+
</button>
|
|
47
|
+
<ul v-show="isOpen(group)" class="docs-sidenav__list">
|
|
48
|
+
<template v-for="item in group.items" :key="item.text">
|
|
49
|
+
<li v-if="item.link">
|
|
50
|
+
<a
|
|
51
|
+
:href="href(item.link)"
|
|
52
|
+
class="docs-sidenav__link"
|
|
53
|
+
:class="{ 'is-active': isActive(item.link) }"
|
|
54
|
+
:aria-current="isActive(item.link) ? 'page' : undefined"
|
|
55
|
+
>
|
|
56
|
+
{{ item.text }}
|
|
57
|
+
</a>
|
|
58
|
+
</li>
|
|
59
|
+
<li v-else class="docs-sidenav__sub">
|
|
60
|
+
<span class="docs-sidenav__sub-title">{{ item.text }}</span>
|
|
61
|
+
<ul class="docs-sidenav__list">
|
|
62
|
+
<li v-for="child in item.items" :key="child.text">
|
|
63
|
+
<a
|
|
64
|
+
:href="href(child.link)"
|
|
65
|
+
class="docs-sidenav__link docs-sidenav__link--nested"
|
|
66
|
+
:class="{ 'is-active': isActive(child.link) }"
|
|
67
|
+
:aria-current="isActive(child.link) ? 'page' : undefined"
|
|
68
|
+
>
|
|
69
|
+
{{ child.text }}
|
|
70
|
+
</a>
|
|
71
|
+
</li>
|
|
72
|
+
</ul>
|
|
73
|
+
</li>
|
|
74
|
+
</template>
|
|
75
|
+
</ul>
|
|
76
|
+
</section>
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|
|
79
|
+
|
|
80
|
+
<script setup lang="ts">
|
|
81
|
+
/**
|
|
82
|
+
* The left navigation, in place of VitePress's own sidebar list.
|
|
83
|
+
*
|
|
84
|
+
* It reads the same `themeConfig.sidebar`, so the footer's previous and next
|
|
85
|
+
* links and the mobile drawer keep working from one source. What it adds is
|
|
86
|
+
* Carbon's shape: collapsible top-level groups that open on the section you
|
|
87
|
+
* are in, a "Find" box that filters every group at once, and one active entry
|
|
88
|
+
* for every tab of a split component page, which VitePress's exact-path match
|
|
89
|
+
* cannot give.
|
|
90
|
+
*/
|
|
91
|
+
import { computed, ref, watch } from 'vue'
|
|
92
|
+
import { NbIcon, NbTextInput } from '@nubisco/ui'
|
|
93
|
+
import * as iconSearch from '@nubisco/ui/icons/magnifying-glass'
|
|
94
|
+
import * as iconCaret from '@nubisco/ui/icons/caret-down'
|
|
95
|
+
import { useData, useRoute, withBase } from 'vitepress'
|
|
96
|
+
|
|
97
|
+
interface IItem {
|
|
98
|
+
text: string
|
|
99
|
+
link?: string
|
|
100
|
+
items?: IItem[]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { theme, site } = useData()
|
|
104
|
+
const route = useRoute()
|
|
105
|
+
const query = ref('')
|
|
106
|
+
|
|
107
|
+
const all = computed<IItem[]>(() => {
|
|
108
|
+
const sidebar = theme.value.sidebar
|
|
109
|
+
if (Array.isArray(sidebar)) return sidebar
|
|
110
|
+
return sidebar?.['/'] ?? []
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
/** The path without base, extension or trailing index. */
|
|
114
|
+
function normal(path: string) {
|
|
115
|
+
return path
|
|
116
|
+
.replace(new RegExp(`^${site.value.base}`), '/')
|
|
117
|
+
.replace(/\.html$/, '')
|
|
118
|
+
.replace(/\/index$/, '/')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A link to a tab route stands for the whole page: `/components/x/usage` is
|
|
123
|
+
* active on `/components/x/code` too.
|
|
124
|
+
*/
|
|
125
|
+
function owner(link: string) {
|
|
126
|
+
return /\/(usage|style|code|accessibility)$/.test(link)
|
|
127
|
+
? link.replace(/\/[^/]+$/, '')
|
|
128
|
+
: link
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const current = computed(() => normal(route.path))
|
|
132
|
+
|
|
133
|
+
function isActive(link?: string) {
|
|
134
|
+
if (!link) return false
|
|
135
|
+
const own = owner(link)
|
|
136
|
+
return own === link
|
|
137
|
+
? current.value === link
|
|
138
|
+
: current.value === own || current.value.startsWith(own + '/')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function contains(item: IItem): boolean {
|
|
142
|
+
return isActive(item.link) || (item.items ?? []).some(contains)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function href(link?: string) {
|
|
146
|
+
if (!link) return undefined
|
|
147
|
+
return withBase(link + (site.value.cleanUrls ? '' : '.html'))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const groups = computed<IItem[]>(() => {
|
|
151
|
+
const q = query.value.trim().toLowerCase()
|
|
152
|
+
if (!q) return all.value
|
|
153
|
+
const match = (i: IItem) => i.text.toLowerCase().includes(q)
|
|
154
|
+
return all.value
|
|
155
|
+
.map((group) => {
|
|
156
|
+
if (match(group)) return group
|
|
157
|
+
const items = (group.items ?? [])
|
|
158
|
+
.map((item) => {
|
|
159
|
+
if (match(item)) return item
|
|
160
|
+
const kids = (item.items ?? []).filter(match)
|
|
161
|
+
return kids.length ? { ...item, items: kids } : null
|
|
162
|
+
})
|
|
163
|
+
.filter(Boolean) as IItem[]
|
|
164
|
+
return items.length ? { ...group, items } : null
|
|
165
|
+
})
|
|
166
|
+
.filter(Boolean) as IItem[]
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// Open state: the group holding the current page opens by itself; the rest
|
|
170
|
+
// remember what the reader did with them.
|
|
171
|
+
const toggled = ref<Record<string, boolean>>({})
|
|
172
|
+
|
|
173
|
+
watch(
|
|
174
|
+
() => route.path,
|
|
175
|
+
() => {
|
|
176
|
+
for (const group of all.value)
|
|
177
|
+
if (contains(group)) toggled.value[group.text] = true
|
|
178
|
+
},
|
|
179
|
+
{ immediate: true },
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
function isOpen(group: IItem) {
|
|
183
|
+
if (query.value.trim()) return true
|
|
184
|
+
return toggled.value[group.text] ?? false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function toggle(group: IItem) {
|
|
188
|
+
toggled.value[group.text] = !isOpen(group)
|
|
189
|
+
}
|
|
190
|
+
</script>
|
|
191
|
+
|
|
192
|
+
<style lang="scss" scoped>
|
|
193
|
+
.docs-sidenav {
|
|
194
|
+
padding-bottom: calc(var(--nb-base-unit) * 4);
|
|
195
|
+
|
|
196
|
+
&__find {
|
|
197
|
+
position: sticky;
|
|
198
|
+
top: 0;
|
|
199
|
+
z-index: 1;
|
|
200
|
+
margin: 0 calc(var(--nb-base-unit) * -1) calc(var(--nb-base-unit) * 1.5);
|
|
201
|
+
padding: calc(var(--nb-base-unit) * 2) var(--nb-base-unit)
|
|
202
|
+
var(--nb-base-unit);
|
|
203
|
+
background: var(--vp-sidebar-bg-color);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
&__find-icon {
|
|
207
|
+
margin-left: var(--nb-base-unit);
|
|
208
|
+
color: var(--nb-c-text-subtle);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
&__empty {
|
|
212
|
+
margin: var(--nb-base-unit) 0;
|
|
213
|
+
font-size: 0.875rem;
|
|
214
|
+
color: var(--nb-c-text-subtle);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
&__group + &__group {
|
|
218
|
+
border-top: 1px solid var(--nb-c-layer-2);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
&__heading {
|
|
222
|
+
appearance: none;
|
|
223
|
+
display: flex;
|
|
224
|
+
align-items: center;
|
|
225
|
+
justify-content: space-between;
|
|
226
|
+
width: 100%;
|
|
227
|
+
padding: calc(var(--nb-base-unit) * 1.5) 0;
|
|
228
|
+
border: 0;
|
|
229
|
+
background: transparent;
|
|
230
|
+
font: inherit;
|
|
231
|
+
font-size: 0.875rem;
|
|
232
|
+
font-weight: 600;
|
|
233
|
+
color: var(--nb-c-text);
|
|
234
|
+
text-align: left;
|
|
235
|
+
cursor: pointer;
|
|
236
|
+
|
|
237
|
+
&:focus-visible {
|
|
238
|
+
outline: 2px solid var(--nb-c-primary);
|
|
239
|
+
outline-offset: 2px;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
&__caret {
|
|
244
|
+
color: var(--nb-c-text-subtle);
|
|
245
|
+
transform: rotate(-90deg);
|
|
246
|
+
transition: transform 0.15s ease;
|
|
247
|
+
|
|
248
|
+
&.is-open {
|
|
249
|
+
transform: none;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
&__list {
|
|
254
|
+
list-style: none;
|
|
255
|
+
margin: 0 0 var(--nb-base-unit);
|
|
256
|
+
padding: 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
&__sub-title {
|
|
260
|
+
display: block;
|
|
261
|
+
padding: var(--nb-base-unit) 0 calc(var(--nb-base-unit) * 0.5)
|
|
262
|
+
calc(var(--nb-base-unit) * 1.5);
|
|
263
|
+
font-size: 0.8125rem;
|
|
264
|
+
font-weight: 600;
|
|
265
|
+
color: var(--nb-c-text-subtle);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
&__link {
|
|
269
|
+
position: relative;
|
|
270
|
+
display: block;
|
|
271
|
+
padding: 6px var(--nb-base-unit) 6px calc(var(--nb-base-unit) * 1.5);
|
|
272
|
+
font-size: 0.875rem;
|
|
273
|
+
line-height: 1.4;
|
|
274
|
+
color: var(--nb-c-text-subtle);
|
|
275
|
+
text-decoration: none;
|
|
276
|
+
transition:
|
|
277
|
+
color 0.15s ease,
|
|
278
|
+
background 0.15s ease;
|
|
279
|
+
|
|
280
|
+
&--nested {
|
|
281
|
+
padding-left: calc(var(--nb-base-unit) * 3);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
&:hover {
|
|
285
|
+
color: var(--nb-c-text);
|
|
286
|
+
background: var(--nb-c-layer-hover-1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Carbon marks the current page with a selected row and an edge; the edge
|
|
290
|
+
// is the accent here.
|
|
291
|
+
&.is-active {
|
|
292
|
+
color: var(--nb-c-text);
|
|
293
|
+
font-weight: 600;
|
|
294
|
+
background: var(--nb-c-layer-2);
|
|
295
|
+
|
|
296
|
+
&::before {
|
|
297
|
+
content: '';
|
|
298
|
+
position: absolute;
|
|
299
|
+
left: 0;
|
|
300
|
+
top: 0;
|
|
301
|
+
bottom: 0;
|
|
302
|
+
width: 3px;
|
|
303
|
+
background: var(--nb-c-primary);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
&:focus-visible {
|
|
308
|
+
outline: 2px solid var(--nb-c-primary);
|
|
309
|
+
outline-offset: -2px;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
</style>
|