@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,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>