@maple-design/cli-vue 0.0.0-stage → 1.1.1

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.
Files changed (228) hide show
  1. package/README.md +305 -2
  2. package/README.zh-CN.md +305 -0
  3. package/bin/cli.mjs +5 -0
  4. package/client.d.ts +1 -0
  5. package/client.js +1 -0
  6. package/lib/client/appType.d.ts +4 -0
  7. package/lib/client/appType.js +7 -0
  8. package/lib/client/index.d.ts +29 -0
  9. package/lib/client/index.js +170 -0
  10. package/lib/client/language.d.ts +11 -0
  11. package/lib/client/language.js +52 -0
  12. package/lib/client/theme-state.d.ts +3 -0
  13. package/lib/client/theme-state.js +6 -0
  14. package/lib/node/bin.d.ts +2 -0
  15. package/lib/node/bin.js +154 -0
  16. package/lib/node/commands/build.d.ts +1 -0
  17. package/lib/node/commands/build.js +15 -0
  18. package/lib/node/commands/checklist.d.ts +6 -0
  19. package/lib/node/commands/checklist.js +59 -0
  20. package/lib/node/commands/compile.d.ts +3 -0
  21. package/lib/node/commands/compile.js +41 -0
  22. package/lib/node/commands/create.d.ts +8 -0
  23. package/lib/node/commands/create.js +91 -0
  24. package/lib/node/commands/dev.d.ts +6 -0
  25. package/lib/node/commands/dev.js +58 -0
  26. package/lib/node/commands/extension.d.ts +3 -0
  27. package/lib/node/commands/extension.js +5 -0
  28. package/lib/node/commands/gen.d.ts +24 -0
  29. package/lib/node/commands/gen.js +91 -0
  30. package/lib/node/commands/icons.d.ts +4 -0
  31. package/lib/node/commands/icons.js +15 -0
  32. package/lib/node/commands/preview.d.ts +4 -0
  33. package/lib/node/commands/preview.js +18 -0
  34. package/lib/node/commands/styleVars.d.ts +1 -0
  35. package/lib/node/commands/styleVars.js +6 -0
  36. package/lib/node/commands/test.d.ts +7 -0
  37. package/lib/node/commands/test.js +16 -0
  38. package/lib/node/commands/vite.d.ts +2 -0
  39. package/lib/node/commands/vite.js +14 -0
  40. package/lib/node/compiler/compileModule.d.ts +4 -0
  41. package/lib/node/compiler/compileModule.js +78 -0
  42. package/lib/node/compiler/compileSFC.d.ts +6 -0
  43. package/lib/node/compiler/compileSFC.js +119 -0
  44. package/lib/node/compiler/compileScript.d.ts +27 -0
  45. package/lib/node/compiler/compileScript.js +221 -0
  46. package/lib/node/compiler/compileSiteEntry.d.ts +19 -0
  47. package/lib/node/compiler/compileSiteEntry.js +164 -0
  48. package/lib/node/compiler/compileStyle.d.ts +14 -0
  49. package/lib/node/compiler/compileStyle.js +55 -0
  50. package/lib/node/compiler/compileStyleVars.d.ts +3 -0
  51. package/lib/node/compiler/compileStyleVars.js +46 -0
  52. package/lib/node/compiler/compileTemplateHighlight.d.ts +18 -0
  53. package/lib/node/compiler/compileTemplateHighlight.js +133 -0
  54. package/lib/node/compiler/compileTypes.d.ts +2 -0
  55. package/lib/node/compiler/compileTypes.js +58 -0
  56. package/lib/node/compiler/generateLibraryEntry.d.ts +3 -0
  57. package/lib/node/compiler/generateLibraryEntry.js +152 -0
  58. package/lib/node/compiler/siteRoutes.d.ts +4 -0
  59. package/lib/node/compiler/siteRoutes.js +9 -0
  60. package/lib/node/config/maple.config.d.ts +215 -0
  61. package/lib/node/config/maple.config.js +32 -0
  62. package/lib/node/config/maple.default.config.d.ts +2 -0
  63. package/lib/node/config/maple.default.config.js +386 -0
  64. package/lib/node/config/site-resolver.d.ts +25 -0
  65. package/lib/node/config/site-resolver.js +138 -0
  66. package/lib/node/config/vite.config.d.ts +28 -0
  67. package/lib/node/config/vite.config.js +209 -0
  68. package/lib/node/config/vitest.config.d.ts +2 -0
  69. package/lib/node/config/vitest.config.js +54 -0
  70. package/lib/node/config/vitest.setup.d.ts +1 -0
  71. package/lib/node/config/vitest.setup.js +8 -0
  72. package/lib/node/index.d.ts +13 -0
  73. package/lib/node/index.js +13 -0
  74. package/lib/node/release/index.d.ts +20 -0
  75. package/lib/node/release/index.js +147 -0
  76. package/lib/node/shared/constant.d.ts +47 -0
  77. package/lib/node/shared/constant.js +53 -0
  78. package/lib/node/shared/fsUtils.d.ts +18 -0
  79. package/lib/node/shared/fsUtils.js +57 -0
  80. package/lib/node/shared/logger.d.ts +8 -0
  81. package/lib/node/shared/logger.js +18 -0
  82. package/lib/node/shared/serial-task.d.ts +2 -0
  83. package/lib/node/shared/serial-task.js +9 -0
  84. package/package.json +111 -3
  85. package/site/documentation-sections.spec.ts +45 -0
  86. package/site/documentation-sections.ts +48 -0
  87. package/site/index.html +89 -0
  88. package/site/mobile/App.vue +437 -0
  89. package/site/mobile/AppHome.vue +154 -0
  90. package/site/mobile/main.ts +99 -0
  91. package/site/mobile/route-sync.spec.ts +34 -0
  92. package/site/mobile/route-sync.ts +10 -0
  93. package/site/mobile.html +70 -0
  94. package/site/module.d.ts +5 -0
  95. package/site/pc/App.vue +44 -0
  96. package/site/pc/Layout.vue +641 -0
  97. package/site/pc/components/AppAd.vue +117 -0
  98. package/site/pc/components/AppHeader.vue +459 -0
  99. package/site/pc/components/AppMobile.spec.ts +204 -0
  100. package/site/pc/components/AppMobile.vue +201 -0
  101. package/site/pc/components/AppOutline.spec.ts +89 -0
  102. package/site/pc/components/AppOutline.vue +161 -0
  103. package/site/pc/components/AppSidebar.vue +144 -0
  104. package/site/pc/components/code-example/CodeExample.spec.ts +36 -0
  105. package/site/pc/components/code-example/CodeExample.vue +164 -0
  106. package/site/pc/components/code-example/codeExample.less +88 -0
  107. package/site/pc/components/code-example/index.ts +10 -0
  108. package/site/pc/components/demo-preview/DemoPreview.vue +92 -0
  109. package/site/pc/components/demo-preview/index.ts +8 -0
  110. package/site/pc/components/mobile-preview-url.ts +17 -0
  111. package/site/pc/components/preview-section.spec.ts +24 -0
  112. package/site/pc/components/preview-section.ts +23 -0
  113. package/site/pc/main.ts +108 -0
  114. package/site/pc/pages/index/index.vue +421 -0
  115. package/site/pc/pages/index/locale/en-US.ts +1 -0
  116. package/site/pc/pages/index/locale/zh-CN.ts +1 -0
  117. package/site/pc/responsive-layout.spec.ts +40 -0
  118. package/site/pc/route-state.spec.ts +34 -0
  119. package/site/pc/route-state.ts +25 -0
  120. package/site/pc/scroll-hash.spec.ts +49 -0
  121. package/site/pc/scroll-hash.ts +25 -0
  122. package/site/route-recovery.spec.ts +51 -0
  123. package/site/route-recovery.ts +38 -0
  124. package/site/tsconfig.json +16 -0
  125. package/site/types.ts +6 -0
  126. package/site/utils.ts +45 -0
  127. package/template/create/[ComponentName].tsx.ejs +16 -0
  128. package/template/create/[ComponentName].vue.ejs +21 -0
  129. package/template/create/[componentName].d.ts.ejs +13 -0
  130. package/template/create/__tests__/index.spec.js.ejs +10 -0
  131. package/template/create/docs/en-US.md +0 -0
  132. package/template/create/docs/zh-CN.md +0 -0
  133. package/template/create/example/index.vue.ejs +17 -0
  134. package/template/create/example/locale/_index.ts +17 -0
  135. package/template/create/example/locale/en-US.ts +3 -0
  136. package/template/create/example/locale/index.ts +24 -0
  137. package/template/create/example/locale/zh-CN.ts +3 -0
  138. package/template/create/index.ts.ejs +12 -0
  139. package/template/create/props.ts +1 -0
  140. package/template/create/style/[componentName].less.ejs +5 -0
  141. package/template/create/style/index.less.ejs +1 -0
  142. package/template/create/style/index.ts.ejs +1 -0
  143. package/template/generators/base/.browserslistrc +2 -0
  144. package/template/generators/base/.prettierignore +14 -0
  145. package/template/generators/base/README.md +90 -0
  146. package/template/generators/base/_gitignore +19 -0
  147. package/template/generators/base/eslint.config.js +15 -0
  148. package/template/generators/base/package.json +97 -0
  149. package/template/generators/base/prettier.config.js +3 -0
  150. package/template/generators/base/public/logo.svg +30 -0
  151. package/template/generators/base/shims/shims-md.d.ts +4 -0
  152. package/template/generators/base/shims/shims-vue.d.ts +6 -0
  153. package/template/generators/base/tsconfig.declaration.json +21 -0
  154. package/template/generators/base/tsconfig.json +28 -0
  155. package/template/generators/base/ui-build.config.mjs +16 -0
  156. package/template/generators/config/default/base/docs/home.zh-CN.md +3 -0
  157. package/template/generators/config/default/base/maple.config.mjs +49 -0
  158. package/template/generators/config/default/sfc/src/button/Button.vue +34 -0
  159. package/template/generators/config/default/sfc/src/button/__tests__/index.spec.js +8 -0
  160. package/template/generators/config/default/sfc/src/button/docs/zh-CN.md +39 -0
  161. package/template/generators/config/default/sfc/src/button/example/BasicUse.vue +7 -0
  162. package/template/generators/config/default/sfc/src/button/example/ModifyColor.vue +7 -0
  163. package/template/generators/config/default/sfc/src/button/example/index.vue +12 -0
  164. package/template/generators/config/default/sfc/src/button/index.ts +8 -0
  165. package/template/generators/config/default/sfc/src/button/style/button.less +14 -0
  166. package/template/generators/config/default/sfc/src/button/style/index.less +1 -0
  167. package/template/generators/config/default/sfc/src/button/style/index.ts +1 -0
  168. package/template/generators/config/default/sfc/src/utils/components.ts +20 -0
  169. package/template/generators/config/default/tsx/src/button/Button.tsx +30 -0
  170. package/template/generators/config/default/tsx/src/button/__tests__/index.spec.js +8 -0
  171. package/template/generators/config/default/tsx/src/button/docs/zh-CN.md +39 -0
  172. package/template/generators/config/default/tsx/src/button/example/BasicUse.vue +7 -0
  173. package/template/generators/config/default/tsx/src/button/example/ModifyColor.vue +7 -0
  174. package/template/generators/config/default/tsx/src/button/example/index.vue +12 -0
  175. package/template/generators/config/default/tsx/src/button/index.ts +8 -0
  176. package/template/generators/config/default/tsx/src/button/style/button.less +14 -0
  177. package/template/generators/config/default/tsx/src/button/style/index.less +1 -0
  178. package/template/generators/config/default/tsx/src/button/style/index.ts +1 -0
  179. package/template/generators/config/default/tsx/src/utils/components.ts +20 -0
  180. package/template/generators/config/i18n/base/docs/home.en-US.md +3 -0
  181. package/template/generators/config/i18n/base/docs/home.zh-CN.md +3 -0
  182. package/template/generators/config/i18n/base/maple.config.mjs +51 -0
  183. package/template/generators/config/i18n/sfc/src/button/Button.vue +38 -0
  184. package/template/generators/config/i18n/sfc/src/button/__tests__/index.spec.js +8 -0
  185. package/template/generators/config/i18n/sfc/src/button/docs/en-US.md +39 -0
  186. package/template/generators/config/i18n/sfc/src/button/docs/zh-CN.md +39 -0
  187. package/template/generators/config/i18n/sfc/src/button/example/BasicUse.vue +11 -0
  188. package/template/generators/config/i18n/sfc/src/button/example/ModifyColor.vue +11 -0
  189. package/template/generators/config/i18n/sfc/src/button/example/index.vue +15 -0
  190. package/template/generators/config/i18n/sfc/src/button/example/locale/en-US.ts +5 -0
  191. package/template/generators/config/i18n/sfc/src/button/example/locale/index.ts +23 -0
  192. package/template/generators/config/i18n/sfc/src/button/example/locale/zh-CN.ts +5 -0
  193. package/template/generators/config/i18n/sfc/src/button/index.ts +8 -0
  194. package/template/generators/config/i18n/sfc/src/button/style/button.less +14 -0
  195. package/template/generators/config/i18n/sfc/src/button/style/index.less +1 -0
  196. package/template/generators/config/i18n/sfc/src/button/style/index.ts +1 -0
  197. package/template/generators/config/i18n/sfc/src/locale/__tests__/index.spec.js +52 -0
  198. package/template/generators/config/i18n/sfc/src/locale/docs/en-US.md +26 -0
  199. package/template/generators/config/i18n/sfc/src/locale/docs/zh-CN.md +25 -0
  200. package/template/generators/config/i18n/sfc/src/locale/en-US.d.ts +5 -0
  201. package/template/generators/config/i18n/sfc/src/locale/en-US.ts +7 -0
  202. package/template/generators/config/i18n/sfc/src/locale/index.ts +82 -0
  203. package/template/generators/config/i18n/sfc/src/locale/zh-CN.d.ts +5 -0
  204. package/template/generators/config/i18n/sfc/src/locale/zh-CN.ts +7 -0
  205. package/template/generators/config/i18n/sfc/src/utils/components.ts +20 -0
  206. package/template/generators/config/i18n/tsx/src/button/Button.tsx +35 -0
  207. package/template/generators/config/i18n/tsx/src/button/__tests__/index.spec.js +8 -0
  208. package/template/generators/config/i18n/tsx/src/button/docs/en-US.md +39 -0
  209. package/template/generators/config/i18n/tsx/src/button/docs/zh-CN.md +39 -0
  210. package/template/generators/config/i18n/tsx/src/button/example/BasicUse.vue +11 -0
  211. package/template/generators/config/i18n/tsx/src/button/example/ModifyColor.vue +11 -0
  212. package/template/generators/config/i18n/tsx/src/button/example/index.vue +15 -0
  213. package/template/generators/config/i18n/tsx/src/button/example/locale/en-US.ts +5 -0
  214. package/template/generators/config/i18n/tsx/src/button/example/locale/index.ts +23 -0
  215. package/template/generators/config/i18n/tsx/src/button/example/locale/zh-CN.ts +5 -0
  216. package/template/generators/config/i18n/tsx/src/button/index.ts +8 -0
  217. package/template/generators/config/i18n/tsx/src/button/style/button.less +14 -0
  218. package/template/generators/config/i18n/tsx/src/button/style/index.less +1 -0
  219. package/template/generators/config/i18n/tsx/src/button/style/index.ts +1 -0
  220. package/template/generators/config/i18n/tsx/src/locale/__tests__/index.spec.js +52 -0
  221. package/template/generators/config/i18n/tsx/src/locale/docs/en-US.md +26 -0
  222. package/template/generators/config/i18n/tsx/src/locale/docs/zh-CN.md +25 -0
  223. package/template/generators/config/i18n/tsx/src/locale/en-US.d.ts +5 -0
  224. package/template/generators/config/i18n/tsx/src/locale/en-US.ts +7 -0
  225. package/template/generators/config/i18n/tsx/src/locale/index.ts +82 -0
  226. package/template/generators/config/i18n/tsx/src/locale/zh-CN.d.ts +5 -0
  227. package/template/generators/config/i18n/tsx/src/locale/zh-CN.ts +7 -0
  228. 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>