@maple-design/cli-vue 0.0.0-stage → 1.1.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/README.md +305 -2
- package/README.zh-CN.md +305 -0
- package/bin/cli.mjs +5 -0
- package/client.d.ts +1 -0
- package/client.js +1 -0
- package/lib/client/appType.d.ts +4 -0
- package/lib/client/appType.js +7 -0
- package/lib/client/index.d.ts +29 -0
- package/lib/client/index.js +170 -0
- package/lib/client/language.d.ts +11 -0
- package/lib/client/language.js +52 -0
- package/lib/client/theme-state.d.ts +3 -0
- package/lib/client/theme-state.js +6 -0
- package/lib/node/bin.d.ts +2 -0
- package/lib/node/bin.js +154 -0
- package/lib/node/commands/build.d.ts +1 -0
- package/lib/node/commands/build.js +15 -0
- package/lib/node/commands/checklist.d.ts +6 -0
- package/lib/node/commands/checklist.js +59 -0
- package/lib/node/commands/compile.d.ts +3 -0
- package/lib/node/commands/compile.js +41 -0
- package/lib/node/commands/create.d.ts +8 -0
- package/lib/node/commands/create.js +91 -0
- package/lib/node/commands/dev.d.ts +6 -0
- package/lib/node/commands/dev.js +58 -0
- package/lib/node/commands/extension.d.ts +3 -0
- package/lib/node/commands/extension.js +5 -0
- package/lib/node/commands/gen.d.ts +24 -0
- package/lib/node/commands/gen.js +91 -0
- package/lib/node/commands/icons.d.ts +4 -0
- package/lib/node/commands/icons.js +15 -0
- package/lib/node/commands/preview.d.ts +4 -0
- package/lib/node/commands/preview.js +18 -0
- package/lib/node/commands/styleVars.d.ts +1 -0
- package/lib/node/commands/styleVars.js +6 -0
- package/lib/node/commands/test.d.ts +7 -0
- package/lib/node/commands/test.js +16 -0
- package/lib/node/commands/vite.d.ts +2 -0
- package/lib/node/commands/vite.js +14 -0
- package/lib/node/compiler/compileModule.d.ts +4 -0
- package/lib/node/compiler/compileModule.js +78 -0
- package/lib/node/compiler/compileSFC.d.ts +6 -0
- package/lib/node/compiler/compileSFC.js +119 -0
- package/lib/node/compiler/compileScript.d.ts +27 -0
- package/lib/node/compiler/compileScript.js +221 -0
- package/lib/node/compiler/compileSiteEntry.d.ts +19 -0
- package/lib/node/compiler/compileSiteEntry.js +164 -0
- package/lib/node/compiler/compileStyle.d.ts +14 -0
- package/lib/node/compiler/compileStyle.js +55 -0
- package/lib/node/compiler/compileStyleVars.d.ts +3 -0
- package/lib/node/compiler/compileStyleVars.js +46 -0
- package/lib/node/compiler/compileTemplateHighlight.d.ts +18 -0
- package/lib/node/compiler/compileTemplateHighlight.js +133 -0
- package/lib/node/compiler/compileTypes.d.ts +2 -0
- package/lib/node/compiler/compileTypes.js +58 -0
- package/lib/node/compiler/generateLibraryEntry.d.ts +3 -0
- package/lib/node/compiler/generateLibraryEntry.js +152 -0
- package/lib/node/compiler/siteRoutes.d.ts +4 -0
- package/lib/node/compiler/siteRoutes.js +9 -0
- package/lib/node/config/maple.config.d.ts +215 -0
- package/lib/node/config/maple.config.js +32 -0
- package/lib/node/config/maple.default.config.d.ts +2 -0
- package/lib/node/config/maple.default.config.js +386 -0
- package/lib/node/config/site-resolver.d.ts +25 -0
- package/lib/node/config/site-resolver.js +138 -0
- package/lib/node/config/vite.config.d.ts +28 -0
- package/lib/node/config/vite.config.js +209 -0
- package/lib/node/config/vitest.config.d.ts +2 -0
- package/lib/node/config/vitest.config.js +54 -0
- package/lib/node/config/vitest.setup.d.ts +1 -0
- package/lib/node/config/vitest.setup.js +8 -0
- package/lib/node/index.d.ts +13 -0
- package/lib/node/index.js +13 -0
- package/lib/node/release/index.d.ts +20 -0
- package/lib/node/release/index.js +147 -0
- package/lib/node/shared/constant.d.ts +47 -0
- package/lib/node/shared/constant.js +53 -0
- package/lib/node/shared/fsUtils.d.ts +18 -0
- package/lib/node/shared/fsUtils.js +57 -0
- package/lib/node/shared/logger.d.ts +8 -0
- package/lib/node/shared/logger.js +18 -0
- package/lib/node/shared/serial-task.d.ts +2 -0
- package/lib/node/shared/serial-task.js +9 -0
- package/package.json +111 -3
- package/site/documentation-sections.spec.ts +45 -0
- package/site/documentation-sections.ts +48 -0
- package/site/index.html +89 -0
- package/site/mobile/App.vue +437 -0
- package/site/mobile/AppHome.vue +154 -0
- package/site/mobile/main.ts +99 -0
- package/site/mobile/route-sync.spec.ts +34 -0
- package/site/mobile/route-sync.ts +10 -0
- package/site/mobile.html +70 -0
- package/site/module.d.ts +5 -0
- package/site/pc/App.vue +44 -0
- package/site/pc/Layout.vue +641 -0
- package/site/pc/components/AppAd.vue +117 -0
- package/site/pc/components/AppHeader.vue +459 -0
- package/site/pc/components/AppMobile.spec.ts +204 -0
- package/site/pc/components/AppMobile.vue +201 -0
- package/site/pc/components/AppOutline.spec.ts +89 -0
- package/site/pc/components/AppOutline.vue +161 -0
- package/site/pc/components/AppSidebar.vue +144 -0
- package/site/pc/components/code-example/CodeExample.spec.ts +36 -0
- package/site/pc/components/code-example/CodeExample.vue +164 -0
- package/site/pc/components/code-example/codeExample.less +88 -0
- package/site/pc/components/code-example/index.ts +10 -0
- package/site/pc/components/demo-preview/DemoPreview.vue +92 -0
- package/site/pc/components/demo-preview/index.ts +8 -0
- package/site/pc/components/mobile-preview-url.ts +17 -0
- package/site/pc/components/preview-section.spec.ts +24 -0
- package/site/pc/components/preview-section.ts +23 -0
- package/site/pc/main.ts +108 -0
- package/site/pc/pages/index/index.vue +421 -0
- package/site/pc/pages/index/locale/en-US.ts +1 -0
- package/site/pc/pages/index/locale/zh-CN.ts +1 -0
- package/site/pc/responsive-layout.spec.ts +40 -0
- package/site/pc/route-state.spec.ts +34 -0
- package/site/pc/route-state.ts +25 -0
- package/site/pc/scroll-hash.spec.ts +49 -0
- package/site/pc/scroll-hash.ts +25 -0
- package/site/route-recovery.spec.ts +51 -0
- package/site/route-recovery.ts +38 -0
- package/site/tsconfig.json +16 -0
- package/site/types.ts +6 -0
- package/site/utils.ts +45 -0
- package/template/create/[ComponentName].tsx.ejs +16 -0
- package/template/create/[ComponentName].vue.ejs +21 -0
- package/template/create/[componentName].d.ts.ejs +13 -0
- package/template/create/__tests__/index.spec.js.ejs +10 -0
- package/template/create/docs/en-US.md +0 -0
- package/template/create/docs/zh-CN.md +0 -0
- package/template/create/example/index.vue.ejs +17 -0
- package/template/create/example/locale/_index.ts +17 -0
- package/template/create/example/locale/en-US.ts +3 -0
- package/template/create/example/locale/index.ts +24 -0
- package/template/create/example/locale/zh-CN.ts +3 -0
- package/template/create/index.ts.ejs +12 -0
- package/template/create/props.ts +1 -0
- package/template/create/style/[componentName].less.ejs +5 -0
- package/template/create/style/index.less.ejs +1 -0
- package/template/create/style/index.ts.ejs +1 -0
- package/template/generators/base/.browserslistrc +2 -0
- package/template/generators/base/.prettierignore +14 -0
- package/template/generators/base/README.md +90 -0
- package/template/generators/base/_gitignore +19 -0
- package/template/generators/base/eslint.config.js +15 -0
- package/template/generators/base/package.json +97 -0
- package/template/generators/base/prettier.config.js +3 -0
- package/template/generators/base/public/logo.svg +30 -0
- package/template/generators/base/shims/shims-md.d.ts +4 -0
- package/template/generators/base/shims/shims-vue.d.ts +6 -0
- package/template/generators/base/tsconfig.declaration.json +21 -0
- package/template/generators/base/tsconfig.json +28 -0
- package/template/generators/base/ui-build.config.mjs +16 -0
- package/template/generators/config/default/base/docs/home.zh-CN.md +3 -0
- package/template/generators/config/default/base/maple.config.mjs +49 -0
- package/template/generators/config/default/sfc/src/button/Button.vue +34 -0
- package/template/generators/config/default/sfc/src/button/__tests__/index.spec.js +8 -0
- package/template/generators/config/default/sfc/src/button/docs/zh-CN.md +39 -0
- package/template/generators/config/default/sfc/src/button/example/BasicUse.vue +7 -0
- package/template/generators/config/default/sfc/src/button/example/ModifyColor.vue +7 -0
- package/template/generators/config/default/sfc/src/button/example/index.vue +12 -0
- package/template/generators/config/default/sfc/src/button/index.ts +8 -0
- package/template/generators/config/default/sfc/src/button/style/button.less +14 -0
- package/template/generators/config/default/sfc/src/button/style/index.less +1 -0
- package/template/generators/config/default/sfc/src/button/style/index.ts +1 -0
- package/template/generators/config/default/sfc/src/utils/components.ts +20 -0
- package/template/generators/config/default/tsx/src/button/Button.tsx +30 -0
- package/template/generators/config/default/tsx/src/button/__tests__/index.spec.js +8 -0
- package/template/generators/config/default/tsx/src/button/docs/zh-CN.md +39 -0
- package/template/generators/config/default/tsx/src/button/example/BasicUse.vue +7 -0
- package/template/generators/config/default/tsx/src/button/example/ModifyColor.vue +7 -0
- package/template/generators/config/default/tsx/src/button/example/index.vue +12 -0
- package/template/generators/config/default/tsx/src/button/index.ts +8 -0
- package/template/generators/config/default/tsx/src/button/style/button.less +14 -0
- package/template/generators/config/default/tsx/src/button/style/index.less +1 -0
- package/template/generators/config/default/tsx/src/button/style/index.ts +1 -0
- package/template/generators/config/default/tsx/src/utils/components.ts +20 -0
- package/template/generators/config/i18n/base/docs/home.en-US.md +3 -0
- package/template/generators/config/i18n/base/docs/home.zh-CN.md +3 -0
- package/template/generators/config/i18n/base/maple.config.mjs +51 -0
- package/template/generators/config/i18n/sfc/src/button/Button.vue +38 -0
- package/template/generators/config/i18n/sfc/src/button/__tests__/index.spec.js +8 -0
- package/template/generators/config/i18n/sfc/src/button/docs/en-US.md +39 -0
- package/template/generators/config/i18n/sfc/src/button/docs/zh-CN.md +39 -0
- package/template/generators/config/i18n/sfc/src/button/example/BasicUse.vue +11 -0
- package/template/generators/config/i18n/sfc/src/button/example/ModifyColor.vue +11 -0
- package/template/generators/config/i18n/sfc/src/button/example/index.vue +15 -0
- package/template/generators/config/i18n/sfc/src/button/example/locale/en-US.ts +5 -0
- package/template/generators/config/i18n/sfc/src/button/example/locale/index.ts +23 -0
- package/template/generators/config/i18n/sfc/src/button/example/locale/zh-CN.ts +5 -0
- package/template/generators/config/i18n/sfc/src/button/index.ts +8 -0
- package/template/generators/config/i18n/sfc/src/button/style/button.less +14 -0
- package/template/generators/config/i18n/sfc/src/button/style/index.less +1 -0
- package/template/generators/config/i18n/sfc/src/button/style/index.ts +1 -0
- package/template/generators/config/i18n/sfc/src/locale/__tests__/index.spec.js +52 -0
- package/template/generators/config/i18n/sfc/src/locale/docs/en-US.md +26 -0
- package/template/generators/config/i18n/sfc/src/locale/docs/zh-CN.md +25 -0
- package/template/generators/config/i18n/sfc/src/locale/en-US.d.ts +5 -0
- package/template/generators/config/i18n/sfc/src/locale/en-US.ts +7 -0
- package/template/generators/config/i18n/sfc/src/locale/index.ts +82 -0
- package/template/generators/config/i18n/sfc/src/locale/zh-CN.d.ts +5 -0
- package/template/generators/config/i18n/sfc/src/locale/zh-CN.ts +7 -0
- package/template/generators/config/i18n/sfc/src/utils/components.ts +20 -0
- package/template/generators/config/i18n/tsx/src/button/Button.tsx +35 -0
- package/template/generators/config/i18n/tsx/src/button/__tests__/index.spec.js +8 -0
- package/template/generators/config/i18n/tsx/src/button/docs/en-US.md +39 -0
- package/template/generators/config/i18n/tsx/src/button/docs/zh-CN.md +39 -0
- package/template/generators/config/i18n/tsx/src/button/example/BasicUse.vue +11 -0
- package/template/generators/config/i18n/tsx/src/button/example/ModifyColor.vue +11 -0
- package/template/generators/config/i18n/tsx/src/button/example/index.vue +15 -0
- package/template/generators/config/i18n/tsx/src/button/example/locale/en-US.ts +5 -0
- package/template/generators/config/i18n/tsx/src/button/example/locale/index.ts +23 -0
- package/template/generators/config/i18n/tsx/src/button/example/locale/zh-CN.ts +5 -0
- package/template/generators/config/i18n/tsx/src/button/index.ts +8 -0
- package/template/generators/config/i18n/tsx/src/button/style/button.less +14 -0
- package/template/generators/config/i18n/tsx/src/button/style/index.less +1 -0
- package/template/generators/config/i18n/tsx/src/button/style/index.ts +1 -0
- package/template/generators/config/i18n/tsx/src/locale/__tests__/index.spec.js +52 -0
- package/template/generators/config/i18n/tsx/src/locale/docs/en-US.md +26 -0
- package/template/generators/config/i18n/tsx/src/locale/docs/zh-CN.md +25 -0
- package/template/generators/config/i18n/tsx/src/locale/en-US.d.ts +5 -0
- package/template/generators/config/i18n/tsx/src/locale/en-US.ts +7 -0
- package/template/generators/config/i18n/tsx/src/locale/index.ts +82 -0
- package/template/generators/config/i18n/tsx/src/locale/zh-CN.d.ts +5 -0
- package/template/generators/config/i18n/tsx/src/locale/zh-CN.ts +7 -0
- package/template/generators/config/i18n/tsx/src/utils/components.ts +20 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { flushPromises, mount } from '@vue/test-utils'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import AppMobile from './AppMobile.vue'
|
|
4
|
+
|
|
5
|
+
vi.mock('@maple-design/cli-vue/client', () => ({ getMobileIndex: () => '/mobile.html' }))
|
|
6
|
+
|
|
7
|
+
beforeEach(() => localStorage.clear())
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
localStorage.clear()
|
|
10
|
+
vi.restoreAllMocks()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('PC site mobile preview', () => {
|
|
14
|
+
it('loads the selected component demo in the iframe', async () => {
|
|
15
|
+
const wrapper = mount(AppMobile, {
|
|
16
|
+
props: { componentName: 'button', language: 'zh-CN', replace: 'button', hash: '' },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
expect(wrapper.find('iframe').attributes('src')).toBe(
|
|
20
|
+
'/mobile.html#/button?language=zh-CN&platform=pc&replace=button',
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
await wrapper.setProps({ componentName: 'input', replace: 'input' })
|
|
24
|
+
expect(wrapper.find('iframe').attributes('src')).toBe(
|
|
25
|
+
'/mobile.html#/input?language=zh-CN&platform=pc&replace=input',
|
|
26
|
+
)
|
|
27
|
+
expect(new URL(wrapper.find('iframe').attributes('src')!, 'http://localhost:8080/guide/why/').pathname).toBe(
|
|
28
|
+
'/mobile.html',
|
|
29
|
+
)
|
|
30
|
+
await wrapper.setProps({
|
|
31
|
+
componentName: 'zh-CN/practiceDetail',
|
|
32
|
+
query: { project: 'P-1007', state: '500', language: 'en-US' },
|
|
33
|
+
})
|
|
34
|
+
const url = wrapper.find('iframe').attributes('src')!
|
|
35
|
+
expect(url).toContain('project=P-1007')
|
|
36
|
+
expect(url).toContain('state=500')
|
|
37
|
+
expect(url).toContain('language=zh-CN')
|
|
38
|
+
expect(url).not.toContain('language=en-US')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('switches to the document outline without remounting the mobile preview', async () => {
|
|
43
|
+
const root = document.createElement('main')
|
|
44
|
+
root.innerHTML =
|
|
45
|
+
'<article class="maple-site-doc"><h2>案例</h2><h3 id="basic"><a>#</a>基础用法</h3><div class="loca-doc-preview__example"><h3>Demo content</h3></div></article>'
|
|
46
|
+
document.body.append(root)
|
|
47
|
+
const wrapper = mount(AppMobile, {
|
|
48
|
+
attachTo: document.body,
|
|
49
|
+
props: { docRoot: root, pageKey: '/button', language: 'zh-CN' },
|
|
50
|
+
})
|
|
51
|
+
const iframe = wrapper.get('iframe').element
|
|
52
|
+
const buttons = wrapper.findAll('.maple-site-mobile-toolbar button')
|
|
53
|
+
await buttons[1].trigger('click')
|
|
54
|
+
expect(buttons[1].attributes('aria-pressed')).toBe('true')
|
|
55
|
+
expect(wrapper.get('.maple-site-mobile-content').isVisible()).toBe(false)
|
|
56
|
+
const links = wrapper.findAll('.maple-site-outline__link')
|
|
57
|
+
expect(links.map((link) => link.text())).toEqual(['案例', '基础用法'])
|
|
58
|
+
await links[1].trigger('click')
|
|
59
|
+
expect(wrapper.emitted('navigate')).toEqual([['basic']])
|
|
60
|
+
await buttons[0].trigger('click')
|
|
61
|
+
expect(wrapper.get('iframe').element).toBe(iframe)
|
|
62
|
+
expect(wrapper.get('.maple-site-mobile-content').isVisible()).toBe(true)
|
|
63
|
+
|
|
64
|
+
root.innerHTML = '<article class="maple-site-doc"><h3 id="async">Async section</h3></article>'
|
|
65
|
+
await vi.waitFor(() =>
|
|
66
|
+
expect(wrapper.findAll('.maple-site-outline__link').map((link) => link.text())).toEqual(['Async section']),
|
|
67
|
+
)
|
|
68
|
+
await wrapper.setProps({ language: 'en-US', pageKey: '/input' })
|
|
69
|
+
expect(buttons[1].text()).toBe('On this page')
|
|
70
|
+
wrapper.unmount()
|
|
71
|
+
root.remove()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it.each(['preview', 'outline'] as const)(
|
|
75
|
+
'remembers %s mode across component changes and page reloads',
|
|
76
|
+
async (mode) => {
|
|
77
|
+
const wrapper = mount(AppMobile, { props: { componentName: 'button' } })
|
|
78
|
+
// Select the opposite mode first to verify saving both directions.
|
|
79
|
+
const index = mode === 'preview' ? 0 : 1
|
|
80
|
+
await wrapper.findAll('.maple-site-mobile-toolbar button')[1 - index].trigger('click')
|
|
81
|
+
await wrapper.findAll('.maple-site-mobile-toolbar button')[index].trigger('click')
|
|
82
|
+
await wrapper.setProps({ componentName: 'input', pageKey: '/input' })
|
|
83
|
+
expect(wrapper.findAll('.maple-site-mobile-toolbar button')[index].attributes('aria-pressed')).toBe('true')
|
|
84
|
+
expect(localStorage.getItem('MAPLE_UI_DOC_PANEL_MODE')).toBe(mode)
|
|
85
|
+
wrapper.unmount()
|
|
86
|
+
|
|
87
|
+
const reloaded = mount(AppMobile, { props: { componentName: 'select' } })
|
|
88
|
+
expect(reloaded.findAll('.maple-site-mobile-toolbar button')[index].attributes('aria-pressed')).toBe('true')
|
|
89
|
+
reloaded.unmount()
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
it('still switches when browser storage is unavailable', async () => {
|
|
94
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
95
|
+
throw new Error('Storage blocked')
|
|
96
|
+
})
|
|
97
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
98
|
+
throw new Error('Storage blocked')
|
|
99
|
+
})
|
|
100
|
+
const wrapper = mount(AppMobile)
|
|
101
|
+
const buttons = wrapper.findAll('.maple-site-mobile-toolbar button')
|
|
102
|
+
expect(buttons[0].attributes('aria-pressed')).toBe('true')
|
|
103
|
+
await buttons[1].trigger('click')
|
|
104
|
+
expect(buttons[1].attributes('aria-pressed')).toBe('true')
|
|
105
|
+
wrapper.unmount()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('follows the active heading after the parent reveals the outline with v-show', async () => {
|
|
109
|
+
const root = document.createElement('main')
|
|
110
|
+
root.innerHTML = '<article class="maple-site-doc"><h3 id="current">Current section</h3></article>'
|
|
111
|
+
document.body.append(root)
|
|
112
|
+
vi.spyOn(root.querySelector('h3')!, 'getBoundingClientRect').mockReturnValue({ top: 100 } as DOMRect)
|
|
113
|
+
const wrapper = mount(AppMobile, { props: { docRoot: root } })
|
|
114
|
+
const nav = wrapper.get('.maple-site-outline').element as HTMLElement
|
|
115
|
+
const link = wrapper.get('.maple-site-outline__link').element
|
|
116
|
+
const scrollTo = vi.fn()
|
|
117
|
+
nav.scrollTo = scrollTo
|
|
118
|
+
vi.spyOn(nav, 'getBoundingClientRect').mockImplementation(
|
|
119
|
+
() => ({ top: 100, bottom: 300, height: nav.style.display === 'none' ? 0 : 200 }) as DOMRect,
|
|
120
|
+
)
|
|
121
|
+
vi.spyOn(link, 'getBoundingClientRect').mockReturnValue({ top: 350, bottom: 380 } as DOMRect)
|
|
122
|
+
await flushPromises()
|
|
123
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
124
|
+
await wrapper.findAll('.maple-site-mobile-toolbar button')[1].trigger('click')
|
|
125
|
+
await flushPromises()
|
|
126
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 80, behavior: 'smooth' })
|
|
127
|
+
wrapper.unmount()
|
|
128
|
+
root.remove()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('scrolls the existing iframe only when switching back to preview, even when the URL hash has not caught up', async () => {
|
|
132
|
+
const root = document.createElement('main')
|
|
133
|
+
root.innerHTML =
|
|
134
|
+
'<article class="maple-site-doc"><div class="card"><h3>First</h3><div data-mobile-demo="first"></div></div><div class="card"><h3>Second</h3><div data-mobile-demo="second"></div></div></article>'
|
|
135
|
+
let secondTop = 400
|
|
136
|
+
vi.spyOn(root.querySelectorAll('h3')[0], 'getBoundingClientRect').mockReturnValue({ top: 100 } as DOMRect)
|
|
137
|
+
vi.spyOn(root.querySelectorAll('h3')[1], 'getBoundingClientRect').mockImplementation(
|
|
138
|
+
() => ({ top: secondTop }) as DOMRect,
|
|
139
|
+
)
|
|
140
|
+
const wrapper = mount(AppMobile, { attachTo: document.body, props: { docRoot: root, hash: 'first' } })
|
|
141
|
+
const frame = wrapper.get('iframe').element as HTMLIFrameElement
|
|
142
|
+
const frameDoc = document.implementation.createHTMLDocument()
|
|
143
|
+
const scrollTo = vi.fn()
|
|
144
|
+
Object.defineProperty(frame, 'contentDocument', { value: frameDoc })
|
|
145
|
+
Object.defineProperty(frame, 'contentWindow', { value: { scrollY: 0, scrollTo } })
|
|
146
|
+
frameDoc.body.innerHTML =
|
|
147
|
+
'<header class="mobile-page-header"></header><section data-demo="first"></section><section data-demo="second"></section>'
|
|
148
|
+
vi.spyOn(frameDoc.querySelector('header')!, 'getBoundingClientRect').mockReturnValue({ height: 48 } as DOMRect)
|
|
149
|
+
vi.spyOn(frameDoc.querySelector('[data-demo="second"]')!, 'getBoundingClientRect').mockReturnValue({
|
|
150
|
+
top: 600,
|
|
151
|
+
} as DOMRect)
|
|
152
|
+
const buttons = wrapper.findAll('.maple-site-mobile-toolbar button')
|
|
153
|
+
await buttons[1].trigger('click')
|
|
154
|
+
secondTop = 100
|
|
155
|
+
document.dispatchEvent(new Event('scroll'))
|
|
156
|
+
await flushPromises()
|
|
157
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
158
|
+
await buttons[0].trigger('click')
|
|
159
|
+
await flushPromises()
|
|
160
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 540, behavior: 'smooth' })
|
|
161
|
+
expect(wrapper.get('iframe').element).toBe(frame)
|
|
162
|
+
expect(wrapper.get('iframe').attributes('src')).toContain('#first')
|
|
163
|
+
scrollTo.mockClear()
|
|
164
|
+
document.dispatchEvent(new Event('scroll'))
|
|
165
|
+
await flushPromises()
|
|
166
|
+
await buttons[0].trigger('click')
|
|
167
|
+
await flushPromises()
|
|
168
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
169
|
+
wrapper.unmount()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('waits for a lazily mounted demo and cancels the switch request when leaving preview', async () => {
|
|
173
|
+
const root = document.createElement('main')
|
|
174
|
+
root.innerHTML =
|
|
175
|
+
'<article class="maple-site-doc"><div class="card"><h3>Late demo</h3><div data-mobile-demo="late"></div></div></article>'
|
|
176
|
+
vi.spyOn(root.querySelector('h3')!, 'getBoundingClientRect').mockReturnValue({ top: 100 } as DOMRect)
|
|
177
|
+
const wrapper = mount(AppMobile, { props: { docRoot: root } })
|
|
178
|
+
const frame = wrapper.get('iframe').element as HTMLIFrameElement
|
|
179
|
+
const doc = document.implementation.createHTMLDocument()
|
|
180
|
+
const scrollTo = vi.fn()
|
|
181
|
+
Object.defineProperty(frame, 'contentDocument', { value: doc })
|
|
182
|
+
Object.defineProperty(frame, 'contentWindow', { value: { scrollY: 0, scrollTo } })
|
|
183
|
+
const buttons = wrapper.findAll('.maple-site-mobile-toolbar button')
|
|
184
|
+
await buttons[1].trigger('click')
|
|
185
|
+
await buttons[0].trigger('click')
|
|
186
|
+
await flushPromises()
|
|
187
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
188
|
+
const demo = doc.createElement('section')
|
|
189
|
+
demo.dataset.demo = 'late'
|
|
190
|
+
vi.spyOn(demo, 'getBoundingClientRect').mockReturnValue({ top: 500 } as DOMRect)
|
|
191
|
+
doc.body.append(demo)
|
|
192
|
+
await flushPromises()
|
|
193
|
+
expect(scrollTo).toHaveBeenCalledTimes(1)
|
|
194
|
+
demo.remove()
|
|
195
|
+
scrollTo.mockClear()
|
|
196
|
+
await buttons[1].trigger('click')
|
|
197
|
+
await buttons[0].trigger('click')
|
|
198
|
+
await flushPromises()
|
|
199
|
+
await buttons[1].trigger('click')
|
|
200
|
+
doc.body.append(demo)
|
|
201
|
+
await flushPromises()
|
|
202
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
203
|
+
wrapper.unmount()
|
|
204
|
+
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="maple-site-mobile">
|
|
3
|
+
<div class="maple-site-mobile-toolbar" role="group" :aria-label="language === 'en-US' ? 'Right panel' : '右侧面板'">
|
|
4
|
+
<button type="button" :aria-pressed="showPreview" @click="showMobilePreview">
|
|
5
|
+
{{ language === 'en-US' ? 'Mobile preview' : '移动预览' }}
|
|
6
|
+
</button>
|
|
7
|
+
<button type="button" :aria-pressed="!showPreview" @click="mode = 'outline'">
|
|
8
|
+
{{ language === 'en-US' ? 'On this page' : '本页目录' }}
|
|
9
|
+
</button>
|
|
10
|
+
</div>
|
|
11
|
+
<div v-show="showPreview" class="maple-site-mobile-content">
|
|
12
|
+
<iframe
|
|
13
|
+
id="mobile"
|
|
14
|
+
ref="previewFrame"
|
|
15
|
+
@load="followPendingDemo"
|
|
16
|
+
:title="language === 'en-US' ? 'Mobile preview' : '移动端预览'"
|
|
17
|
+
:src="
|
|
18
|
+
mobilePreviewUrl(getMobileIndex(), componentName || '', language || 'zh-CN', replace || '', hash, query ?? {})
|
|
19
|
+
"
|
|
20
|
+
></iframe>
|
|
21
|
+
</div>
|
|
22
|
+
<app-outline
|
|
23
|
+
v-show="!showPreview"
|
|
24
|
+
:root="docRoot"
|
|
25
|
+
:visible="!showPreview"
|
|
26
|
+
:page-key="pageKey"
|
|
27
|
+
:language="language"
|
|
28
|
+
@navigate="emit('navigate', $event)"
|
|
29
|
+
@active-change="emit('active-change', $event)"
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<script lang="ts" setup>
|
|
35
|
+
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
36
|
+
import { getMobileIndex } from '@maple-design/cli-vue/client'
|
|
37
|
+
import AppOutline from './AppOutline.vue'
|
|
38
|
+
import { mobilePreviewUrl } from './mobile-preview-url'
|
|
39
|
+
import { currentMobileDemo } from './preview-section'
|
|
40
|
+
|
|
41
|
+
const props = defineProps<{
|
|
42
|
+
query?: Record<string, unknown>
|
|
43
|
+
componentName?: string
|
|
44
|
+
language?: string
|
|
45
|
+
replace?: string
|
|
46
|
+
hash?: string
|
|
47
|
+
docRoot?: HTMLElement | null
|
|
48
|
+
pageKey?: string
|
|
49
|
+
}>()
|
|
50
|
+
const emit = defineEmits<{ navigate: [id: string]; 'active-change': [id: string] }>()
|
|
51
|
+
const previewFrame = ref<HTMLIFrameElement | null>(null)
|
|
52
|
+
let pendingDemo: string | undefined
|
|
53
|
+
let demoObserver: MutationObserver | undefined
|
|
54
|
+
|
|
55
|
+
function cancelPendingDemo() {
|
|
56
|
+
pendingDemo = undefined
|
|
57
|
+
demoObserver?.disconnect()
|
|
58
|
+
demoObserver = undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function followPendingDemo() {
|
|
62
|
+
demoObserver?.disconnect()
|
|
63
|
+
if (!pendingDemo || !showPreview.value) return
|
|
64
|
+
const frame = previewFrame.value
|
|
65
|
+
const doc = frame?.contentDocument
|
|
66
|
+
const view = frame?.contentWindow
|
|
67
|
+
if (!doc || !view) return
|
|
68
|
+
const target = Array.from(doc.querySelectorAll<HTMLElement>('[data-demo]')).find(
|
|
69
|
+
(element) => element.dataset.demo === pendingDemo,
|
|
70
|
+
)
|
|
71
|
+
if (target) {
|
|
72
|
+
const headerHeight = doc.querySelector('.mobile-page-header')?.getBoundingClientRect().height ?? 0
|
|
73
|
+
view.scrollTo({
|
|
74
|
+
top: Math.max(0, view.scrollY + target.getBoundingClientRect().top - headerHeight - 12),
|
|
75
|
+
behavior: 'smooth',
|
|
76
|
+
})
|
|
77
|
+
cancelPendingDemo()
|
|
78
|
+
} else {
|
|
79
|
+
// The iframe can finish loading before its async Vue route has mounted.
|
|
80
|
+
demoObserver = new MutationObserver(followPendingDemo)
|
|
81
|
+
demoObserver.observe(doc, { childList: true, subtree: true })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function showMobilePreview() {
|
|
86
|
+
if (showPreview.value) return
|
|
87
|
+
const demoId = currentMobileDemo(props.docRoot)
|
|
88
|
+
mode.value = 'preview'
|
|
89
|
+
await nextTick()
|
|
90
|
+
if (!showPreview.value || !demoId) return
|
|
91
|
+
pendingDemo = decodeURIComponent(demoId)
|
|
92
|
+
followPendingDemo()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
watch(() => [props.pageKey, props.componentName, props.language], cancelPendingDemo)
|
|
96
|
+
onBeforeUnmount(cancelPendingDemo)
|
|
97
|
+
|
|
98
|
+
const modeStorageKey = 'MAPLE_UI_DOC_PANEL_MODE'
|
|
99
|
+
function savedMode(): 'preview' | 'outline' {
|
|
100
|
+
try {
|
|
101
|
+
return localStorage.getItem(modeStorageKey) === 'outline' ? 'outline' : 'preview'
|
|
102
|
+
} catch {
|
|
103
|
+
return 'preview'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const mode = ref(savedMode())
|
|
107
|
+
watch(
|
|
108
|
+
mode,
|
|
109
|
+
(value) => {
|
|
110
|
+
if (value === 'outline') cancelPendingDemo()
|
|
111
|
+
try {
|
|
112
|
+
localStorage.setItem(modeStorageKey, value)
|
|
113
|
+
} catch {
|
|
114
|
+
// Keep switching available when browser storage is disabled.
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
{ flush: 'sync' },
|
|
118
|
+
)
|
|
119
|
+
const showPreview = computed(() => mode.value === 'preview')
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<style lang="less">
|
|
123
|
+
.maple-site-mobile {
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
position: sticky;
|
|
127
|
+
flex: 0 1 clamp(300px, 27vw, 375px);
|
|
128
|
+
min-width: 0;
|
|
129
|
+
top: 80px;
|
|
130
|
+
height: calc(100vh - 100px);
|
|
131
|
+
margin-right: clamp(12px, 2.5vw, 38px);
|
|
132
|
+
overflow: visible;
|
|
133
|
+
margin-top: 20px;
|
|
134
|
+
|
|
135
|
+
&-toolbar {
|
|
136
|
+
display: flex;
|
|
137
|
+
flex-shrink: 0;
|
|
138
|
+
gap: 20px;
|
|
139
|
+
padding: 8px 16px;
|
|
140
|
+
|
|
141
|
+
button {
|
|
142
|
+
min-height: 36px;
|
|
143
|
+
padding: 6px 0;
|
|
144
|
+
border: 0;
|
|
145
|
+
background: transparent;
|
|
146
|
+
color: var(--site-config-color-sub-text);
|
|
147
|
+
font: inherit;
|
|
148
|
+
font-size: 13px;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
|
|
151
|
+
&:hover {
|
|
152
|
+
color: var(--site-config-color-text);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
&[aria-pressed='true'] {
|
|
156
|
+
color: var(--site-config-color-text);
|
|
157
|
+
font-weight: 600;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
&:focus-visible {
|
|
161
|
+
outline: 2px solid var(--site-config-color-text);
|
|
162
|
+
outline-offset: -2px;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
&-content,
|
|
168
|
+
.maple-site-outline {
|
|
169
|
+
flex: 1;
|
|
170
|
+
min-height: 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
&-content {
|
|
174
|
+
width: 100%;
|
|
175
|
+
margin-top: 8px;
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
border-radius: 2px;
|
|
178
|
+
background: var(--site-config-color-bar);
|
|
179
|
+
box-shadow: 0 4px 16px var(--site-config-color-shadow);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
&-image {
|
|
183
|
+
width: 100%;
|
|
184
|
+
pointer-events: none;
|
|
185
|
+
height: 100%;
|
|
186
|
+
top: 0;
|
|
187
|
+
|
|
188
|
+
img {
|
|
189
|
+
width: 100%;
|
|
190
|
+
height: 100%;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
iframe {
|
|
195
|
+
display: block;
|
|
196
|
+
width: 100%;
|
|
197
|
+
height: 100%;
|
|
198
|
+
border: none;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
</style>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
3
|
+
import AppOutline from './AppOutline.vue'
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.useRealTimers()
|
|
7
|
+
vi.restoreAllMocks()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('debounces document scroll, ignores nested scrollers, and cancels pending work on page change', async () => {
|
|
11
|
+
vi.useFakeTimers()
|
|
12
|
+
const root = document.createElement('main')
|
|
13
|
+
root.innerHTML =
|
|
14
|
+
'<article class="maple-site-doc"><h3 id="first">First</h3><h3 id="second">Second</h3><div class="demo-scroller"></div></article>'
|
|
15
|
+
document.body.append(root)
|
|
16
|
+
let top = 300
|
|
17
|
+
vi.spyOn(root.querySelector('#first')!, 'getBoundingClientRect').mockImplementation(
|
|
18
|
+
() => ({ top: top - 200 }) as DOMRect,
|
|
19
|
+
)
|
|
20
|
+
vi.spyOn(root.querySelector('#second')!, 'getBoundingClientRect').mockImplementation(() => ({ top }) as DOMRect)
|
|
21
|
+
const onActiveChange = vi.fn()
|
|
22
|
+
const wrapper = mount(AppOutline, { props: { root, pageKey: '/button' }, attrs: { onActiveChange } })
|
|
23
|
+
expect(wrapper.emitted('active-change')).toBeUndefined()
|
|
24
|
+
top = 100
|
|
25
|
+
document.dispatchEvent(new Event('scroll'))
|
|
26
|
+
document.dispatchEvent(new Event('scroll'))
|
|
27
|
+
await vi.advanceTimersByTimeAsync(149)
|
|
28
|
+
expect(wrapper.emitted('active-change')).toBeUndefined()
|
|
29
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
30
|
+
expect(wrapper.emitted('active-change')).toEqual([['second']])
|
|
31
|
+
expect(wrapper.get('[aria-current="location"]').text()).toBe('Second')
|
|
32
|
+
|
|
33
|
+
root.querySelector('.demo-scroller')!.dispatchEvent(new Event('scroll'))
|
|
34
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
35
|
+
expect(wrapper.emitted('active-change')).toHaveLength(1)
|
|
36
|
+
|
|
37
|
+
top = 300
|
|
38
|
+
document.dispatchEvent(new Event('scroll'))
|
|
39
|
+
await wrapper.setProps({ pageKey: '/input' })
|
|
40
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
41
|
+
expect(wrapper.emitted('active-change')).toHaveLength(1)
|
|
42
|
+
// Back above the first heading clears the section hash.
|
|
43
|
+
top = 500
|
|
44
|
+
document.dispatchEvent(new Event('scroll'))
|
|
45
|
+
await vi.advanceTimersByTimeAsync(150)
|
|
46
|
+
expect(wrapper.emitted('active-change')?.at(-1)).toEqual([''])
|
|
47
|
+
document.dispatchEvent(new Event('scroll'))
|
|
48
|
+
wrapper.unmount()
|
|
49
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
50
|
+
expect(onActiveChange).toHaveBeenCalledTimes(2)
|
|
51
|
+
root.remove()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('keeps the active link visible without moving the page, and catches up when the outline is shown', async () => {
|
|
55
|
+
const root = document.createElement('main')
|
|
56
|
+
root.innerHTML = '<article class="maple-site-doc"><h3 id="section">Section</h3></article>'
|
|
57
|
+
document.body.append(root)
|
|
58
|
+
vi.spyOn(root.querySelector('h3')!, 'getBoundingClientRect').mockReturnValue({ top: 100 } as DOMRect)
|
|
59
|
+
const wrapper = mount(AppOutline, { props: { root, visible: false } })
|
|
60
|
+
const container = wrapper.get('nav').element as HTMLElement
|
|
61
|
+
const link = wrapper.get('a').element
|
|
62
|
+
const scrollTo = vi.fn()
|
|
63
|
+
container.scrollTo = scrollTo
|
|
64
|
+
container.scrollTop = 50
|
|
65
|
+
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 300, height: 200 } as DOMRect)
|
|
66
|
+
const itemBounds = vi.spyOn(link, 'getBoundingClientRect').mockReturnValue({ top: 310, bottom: 340 } as DOMRect)
|
|
67
|
+
|
|
68
|
+
await wrapper.setProps({ visible: true })
|
|
69
|
+
await wrapper.vm.$nextTick()
|
|
70
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 90, behavior: 'smooth' })
|
|
71
|
+
scrollTo.mockClear()
|
|
72
|
+
itemBounds.mockReturnValue({ top: 80, bottom: 110 } as DOMRect)
|
|
73
|
+
document.dispatchEvent(new Event('scroll'))
|
|
74
|
+
await wrapper.vm.$nextTick()
|
|
75
|
+
expect(scrollTo).toHaveBeenLastCalledWith({ top: 30, behavior: 'smooth' })
|
|
76
|
+
|
|
77
|
+
scrollTo.mockClear()
|
|
78
|
+
itemBounds.mockReturnValue({ top: 150, bottom: 180 } as DOMRect)
|
|
79
|
+
document.dispatchEvent(new Event('scroll'))
|
|
80
|
+
await wrapper.vm.$nextTick()
|
|
81
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
82
|
+
await wrapper.setProps({ visible: false })
|
|
83
|
+
itemBounds.mockReturnValue({ top: 310, bottom: 340 } as DOMRect)
|
|
84
|
+
document.dispatchEvent(new Event('scroll'))
|
|
85
|
+
await wrapper.vm.$nextTick()
|
|
86
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
87
|
+
wrapper.unmount()
|
|
88
|
+
root.remove()
|
|
89
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(
|
|
5
|
+
defineProps<{ root?: HTMLElement | null; language?: string; pageKey?: string; visible?: boolean }>(),
|
|
6
|
+
{ visible: true },
|
|
7
|
+
)
|
|
8
|
+
const emit = defineEmits<{ navigate: [id: string]; 'active-change': [id: string] }>()
|
|
9
|
+
const headings = ref<{ id: string; title: string; level: number; element: HTMLElement }[]>([])
|
|
10
|
+
const activeId = ref('')
|
|
11
|
+
const outline = ref<HTMLElement | null>(null)
|
|
12
|
+
let observer: MutationObserver | undefined
|
|
13
|
+
let scrollTimer: ReturnType<typeof setTimeout> | undefined
|
|
14
|
+
|
|
15
|
+
function updateActive() {
|
|
16
|
+
let active: (typeof headings.value)[number] | undefined
|
|
17
|
+
for (const heading of headings.value) {
|
|
18
|
+
if (heading.element.getBoundingClientRect().top > 120) break
|
|
19
|
+
active = heading
|
|
20
|
+
}
|
|
21
|
+
activeId.value = active?.id ?? headings.value[0]?.id ?? ''
|
|
22
|
+
return active?.id ?? ''
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function followActive() {
|
|
26
|
+
const container = outline.value
|
|
27
|
+
if (!props.visible || !container) return
|
|
28
|
+
const link = container.querySelector<HTMLElement>('[aria-current="location"]')
|
|
29
|
+
if (!link) return
|
|
30
|
+
const bounds = container.getBoundingClientRect()
|
|
31
|
+
if (!bounds.height) return
|
|
32
|
+
const item = link.getBoundingClientRect()
|
|
33
|
+
if (item.top < bounds.top) {
|
|
34
|
+
container.scrollTo({ top: container.scrollTop + item.top - bounds.top, behavior: 'smooth' })
|
|
35
|
+
} else if (item.bottom > bounds.bottom) {
|
|
36
|
+
container.scrollTo({ top: container.scrollTop + item.bottom - bounds.bottom, behavior: 'smooth' })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
watch([activeId, () => props.visible, () => props.pageKey], () => nextTick(followActive), { flush: 'post' })
|
|
41
|
+
|
|
42
|
+
function onScroll(event: Event) {
|
|
43
|
+
// Ignore independent demo, sidebar and outline scrolling.
|
|
44
|
+
if (event.target !== document && event.target !== props.root) return
|
|
45
|
+
updateActive()
|
|
46
|
+
nextTick(followActive)
|
|
47
|
+
clearTimeout(scrollTimer)
|
|
48
|
+
scrollTimer = setTimeout(() => emit('active-change', updateActive()), 150)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function collect() {
|
|
52
|
+
const elements = props.root?.querySelectorAll<HTMLElement>('.maple-site-doc h2, .maple-site-doc h3') ?? []
|
|
53
|
+
headings.value = Array.from(elements)
|
|
54
|
+
.filter((element) => !element.closest('.loca-doc-preview__example, details'))
|
|
55
|
+
.map((element, index) => {
|
|
56
|
+
if (!element.id) {
|
|
57
|
+
let id = `maple-doc-section-${index}`
|
|
58
|
+
while (document.getElementById(id)) id += '-section'
|
|
59
|
+
element.id = id
|
|
60
|
+
}
|
|
61
|
+
const label = element.cloneNode(true) as HTMLElement
|
|
62
|
+
label.querySelectorAll('a').forEach((link) => {
|
|
63
|
+
if (link.textContent?.trim() === '#') link.remove()
|
|
64
|
+
})
|
|
65
|
+
return {
|
|
66
|
+
id: element.id,
|
|
67
|
+
title: label.textContent?.trim() ?? '',
|
|
68
|
+
level: Number(element.tagName.slice(1)),
|
|
69
|
+
element,
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
.filter((heading) => heading.title)
|
|
73
|
+
updateActive()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
watch(
|
|
77
|
+
() => [props.root, props.pageKey],
|
|
78
|
+
() => {
|
|
79
|
+
observer?.disconnect()
|
|
80
|
+
clearTimeout(scrollTimer)
|
|
81
|
+
collect()
|
|
82
|
+
if (props.root) {
|
|
83
|
+
observer = new MutationObserver(collect)
|
|
84
|
+
observer.observe(props.root, { childList: true, subtree: true, characterData: true })
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{ immediate: true, flush: 'post' },
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// Capture also covers a document container that scrolls independently of the window.
|
|
91
|
+
document.addEventListener('scroll', onScroll, { capture: true, passive: true })
|
|
92
|
+
onBeforeUnmount(() => {
|
|
93
|
+
observer?.disconnect()
|
|
94
|
+
clearTimeout(scrollTimer)
|
|
95
|
+
document.removeEventListener('scroll', onScroll, true)
|
|
96
|
+
})
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<template>
|
|
100
|
+
<nav ref="outline" class="maple-site-outline" :aria-label="language === 'en-US' ? 'On this page' : '本页目录'">
|
|
101
|
+
<a
|
|
102
|
+
v-for="heading in headings"
|
|
103
|
+
:key="heading.id"
|
|
104
|
+
:href="`#${heading.id}`"
|
|
105
|
+
class="maple-site-outline__link"
|
|
106
|
+
:class="{ 'maple-site-outline__link--nested': heading.level === 3 }"
|
|
107
|
+
:aria-current="activeId === heading.id ? 'location' : undefined"
|
|
108
|
+
@click.prevent="emit('navigate', heading.id)"
|
|
109
|
+
>{{ heading.title }}</a
|
|
110
|
+
>
|
|
111
|
+
<p v-if="!headings.length" class="maple-site-outline__empty">
|
|
112
|
+
{{ language === 'en-US' ? 'No headings on this page' : '本页暂无目录' }}
|
|
113
|
+
</p>
|
|
114
|
+
</nav>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<style lang="less">
|
|
118
|
+
.maple-site-outline {
|
|
119
|
+
padding: 16px;
|
|
120
|
+
overflow-y: auto;
|
|
121
|
+
overscroll-behavior: contain;
|
|
122
|
+
scrollbar-width: thin;
|
|
123
|
+
scrollbar-color: var(--site-config-color-border) transparent;
|
|
124
|
+
|
|
125
|
+
&__link {
|
|
126
|
+
display: block;
|
|
127
|
+
padding: 3px 12px;
|
|
128
|
+
border-left: 2px solid var(--site-config-color-border);
|
|
129
|
+
color: var(--site-config-color-sub-text);
|
|
130
|
+
font-size: 14px;
|
|
131
|
+
line-height: 1.7rem;
|
|
132
|
+
text-decoration: none;
|
|
133
|
+
overflow-wrap: anywhere;
|
|
134
|
+
|
|
135
|
+
&--nested {
|
|
136
|
+
padding-left: 20px;
|
|
137
|
+
font-size: 13px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
&:hover {
|
|
141
|
+
color: var(--mp-sys-color-content-brand, var(--site-config-color-text));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
&[aria-current='location'] {
|
|
145
|
+
color: var(--mp-sys-color-content-brand, var(--site-config-color-text));
|
|
146
|
+
border-left-color: currentColor;
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
&:focus-visible {
|
|
151
|
+
outline: 2px solid var(--site-config-color-text);
|
|
152
|
+
outline-offset: -2px;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
&__empty {
|
|
157
|
+
color: var(--site-config-color-sub-text);
|
|
158
|
+
font-size: 14px;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
</style>
|