apptify 0.5.1 → 0.5.4

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 (67) hide show
  1. package/README.md +3 -0
  2. package/dist/apptify.css +1 -1
  3. package/dist/apptify.js +2298 -2140
  4. package/dist/components/AppleCard.vue.d.ts +76 -0
  5. package/dist/components/button.d.ts +3 -3
  6. package/dist/components/content.d.ts +0 -90
  7. package/dist/components/foundation.d.ts +201 -129
  8. package/dist/components/link.d.ts +31 -0
  9. package/dist/components/overlays.d.ts +8 -2
  10. package/dist/core/context.d.ts +11 -0
  11. package/dist/core/overlay-surface.d.ts +6 -0
  12. package/dist/core/popup-motion.d.ts +5 -5
  13. package/dist/core/popup-placement.d.ts +3 -2
  14. package/dist/index.d.ts +144 -79
  15. package/docs/COMPONENTS.md +23 -9
  16. package/docs/INPUT-WORKSHOP.md +59 -0
  17. package/docs/PLAYGROUND-TEMPLATE.md +9 -9
  18. package/package.json +5 -1
  19. package/templates/playground/README.md +4 -4
  20. package/templates/playground/index.html +1 -1
  21. package/templates/playground/package.json +6 -2
  22. package/templates/playground/source-snapshot.json +138 -53
  23. package/templates/playground/{playground → src}/App.vue +5 -4
  24. package/templates/playground/{playground → src}/ComponentDemo.vue +8 -9
  25. package/templates/playground/{playground → src}/catalog.ts +2 -3
  26. package/templates/playground/src/components/ComponentIndex.vue +43 -0
  27. package/templates/playground/src/components/DirectoryDialog.vue +20 -0
  28. package/templates/playground/src/components/PageFooter.vue +7 -0
  29. package/templates/playground/{playground → src}/components/UserMenu.vue +11 -11
  30. package/templates/playground/src/editor/AppleInputExample.vue +78 -0
  31. package/templates/playground/src/editor/AppleInputPreview.vue +82 -0
  32. package/templates/playground/src/editor/ComponentDocumentLink.vue +19 -0
  33. package/templates/playground/src/editor/ComponentWorkshop.vue +81 -0
  34. package/templates/playground/src/editor/ConfigurableEditor.vue +35 -0
  35. package/templates/playground/src/editor/LiveVueEditor.vue +133 -0
  36. package/templates/playground/src/editor/apple-input-source.ts +2 -0
  37. package/templates/playground/src/editor/apple-input.ts +48 -0
  38. package/templates/playground/src/editor/contract.ts +14 -0
  39. package/templates/playground/src/editor/documents.ts +20 -0
  40. package/templates/playground/src/editor/input-playground.ts +59 -0
  41. package/templates/playground/src/editor/runtime.ts +5 -0
  42. package/templates/playground/src/editor/sections.ts +10 -0
  43. package/templates/playground/{playground → src}/style.css +40 -39
  44. package/templates/playground/{playground → src}/views/[...all].vue +2 -2
  45. package/templates/playground/src/views/component-docs/[component].vue +162 -0
  46. package/templates/playground/src/views/components.vue +148 -0
  47. package/templates/playground/src/views/index.vue +73 -0
  48. package/templates/playground/src/views/settings.vue +225 -0
  49. package/templates/playground/tooling/playground-editor.mjs +114 -0
  50. package/templates/playground/tooling/prepare-input-source.mjs +38 -0
  51. package/templates/playground/tsconfig.json +2 -2
  52. package/templates/playground/vite.config.ts +3 -1
  53. package/tooling/playground-editor.mjs +114 -0
  54. package/tooling/prepare-input-source.mjs +38 -0
  55. package/tooling/prepare-playground.mjs +27 -14
  56. package/tooling/test-create-apptify.mjs +15 -5
  57. package/tooling/test-playground-package.mjs +13 -7
  58. package/templates/playground/playground/components/ComponentIndex.vue +0 -62
  59. package/templates/playground/playground/components/PageFooter.vue +0 -3
  60. package/templates/playground/playground/views/components.vue +0 -126
  61. package/templates/playground/playground/views/index.vue +0 -75
  62. package/templates/playground/playground/views/settings.vue +0 -98
  63. /package/templates/playground/{playground → src}/components/not-found-paper-scene.vue +0 -0
  64. /package/templates/playground/{playground → src}/components/not-found-paper-story.ts +0 -0
  65. /package/templates/playground/{playground → src}/env.d.ts +0 -0
  66. /package/templates/playground/{playground → src}/main.ts +0 -0
  67. /package/templates/playground/{playground → src}/router/index.ts +0 -0
@@ -0,0 +1,59 @@
1
+ import { icons } from 'lucide-vue-next'
2
+ import exampleSource from './AppleInputExample.vue?raw'
3
+
4
+ export function createInputConfig() {
5
+ return {
6
+ type: 'text', label: '姓名', placeholder: '怎么称呼你', hint: '用于在应用中显示你的名称。', error: '',
7
+ clearable: true, disabled: false, loading: false, required: false,
8
+ prefixMode: 'none', prefix: '', prefixIcon: 'Mail', suffixMode: 'none', suffix: '', suffixIcon: 'Search', motion: 'inherit' as 'inherit' | 'auto' | 'full' | 'reduced' | 'none',
9
+ name: 'name', ariaLabel: '', maxlength: '', autocomplete: 'off', min: '', max: '', step: '',
10
+ }
11
+ }
12
+ export type InputConfig = ReturnType<typeof createInputConfig>
13
+ export function buildInputProps(config: InputConfig) {
14
+ return {
15
+ type: config.type, label: config.label, placeholder: config.placeholder, hint: config.hint, error: config.error,
16
+ clearable: config.clearable, disabled: config.disabled, loading: config.loading, required: config.required,
17
+ motion: config.motion, name: config.name || undefined,
18
+ 'aria-label': config.ariaLabel || (!config.label ? '演示输入框' : undefined),
19
+ maxlength: config.maxlength !== '' && Number.isInteger(Number(config.maxlength)) && Number(config.maxlength) >= 0 ? Number(config.maxlength) : undefined,
20
+ autocomplete: config.autocomplete,
21
+ min: config.type === 'number' && config.min !== '' ? config.min : undefined,
22
+ max: config.type === 'number' && config.max !== '' ? config.max : undefined,
23
+ step: config.type === 'number' && Number(config.step) > 0 ? config.step : undefined,
24
+ }
25
+ }
26
+ const iconNames = new Map(Object.keys(icons).map(name => [name.toLowerCase(), name]))
27
+ export function resolveIconName(code: string): string | undefined {
28
+ return iconNames.get(code.trim().replace(/-/g, '').toLowerCase())
29
+ }
30
+ export function iconCodeError(code: string): string {
31
+ return resolveIconName(code) ? '' : '请输入有效图标代号,例如 Mail、Search、ArrowRight。'
32
+ }
33
+ // This Vue source is both the editable file and the compiled preview.
34
+ const literal = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')
35
+ const attribute = (value: string) => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
36
+ const text = (value: string) => attribute(value).replace(/{/g, '&#123;').replace(/}/g, '&#125;')
37
+ export function generateInputCode(config: InputConfig, value: string | number) {
38
+ const attrs = Object.entries(buildInputProps(config)).flatMap(([name, next]) => {
39
+ if (next === undefined || next === '' || next === false) return []
40
+ return [typeof next === 'boolean' ? ` ${name}` : typeof next === 'number' ? ` :${name}="${next}"` : ` ${name}="${attribute(next)}"`]
41
+ }).join('\n')
42
+ const prefixIcon = config.prefixMode === 'icon' ? resolveIconName(config.prefixIcon) : undefined
43
+ const suffixIcon = config.suffixMode === 'icon' ? resolveIconName(config.suffixIcon) : undefined
44
+ const usedIcons = [...new Set([prefixIcon, suffixIcon].filter((name): name is string => !!name))]
45
+ const affix = (slot: string, mode: string, value: string, icon?: string) => {
46
+ if (mode === 'icon' && icon) return `<template #${slot}><${icon} :size="18" aria-hidden="true" /></template>`
47
+ return mode === 'text' ? `<template #${slot}><span>${text(value)}</span></template>` : ''
48
+ }
49
+ const prefix = affix('prefix', config.prefixMode, config.prefix, prefixIcon)
50
+ const suffix = affix('suffix', config.suffixMode, config.suffix, suffixIcon)
51
+ return exampleSource
52
+ .replace("import { Mail } from 'lucide-vue-next'", () => usedIcons.length ? `import { ${usedIcons.join(', ')} } from 'lucide-vue-next'` : '')
53
+ .replace(/, Mail(?= })/, () => usedIcons.length ? ', ' + usedIcons.join(', ') : '')
54
+ .replace("value: 'Apptify'", () => `value: ${literal(value)}`)
55
+ .replace(/(v-model="value"\n)[\s\S]*?(\n\s+@update:model-value)/, (_, start, end) => `${start}${attrs}${end}`)
56
+ .replace('<!-- prefix -->', () => prefix)
57
+ .replace('<!-- suffix -->', () => suffix)
58
+ .replace(':disabled="false"', () => config.disabled || config.loading ? 'disabled' : '')
59
+ }
@@ -0,0 +1,5 @@
1
+ import 'apptify/style.css'
2
+ // Template packaging adapts this import to the public apptify package.
3
+ export * from 'apptify'
4
+
5
+ export * from 'lucide-vue-next'
@@ -0,0 +1,10 @@
1
+ import type { ComponentDocument } from './contract'
2
+ /** One section map drives both document content and its API navigation tree. */
3
+ export function documentSections(doc: ComponentDocument) {
4
+ return [
5
+ { id: 'props', kicker: 'PROPS', title: '属性', entries: doc.props },
6
+ { id: 'events', kicker: 'EVENTS', title: '事件', entries: doc.events },
7
+ { id: 'slots', kicker: 'SLOTS', title: '插槽', entries: doc.slots },
8
+ { id: 'methods', kicker: 'METHODS', title: '公开方法', entries: doc.methods },
9
+ ].filter(section => section.entries.length)
10
+ }
@@ -5,6 +5,14 @@ button { font: inherit; }
5
5
  #app > .apple-provider { min-height: 100dvh; }
6
6
  .apple-auto-size__inner { position: relative; }
7
7
  .route-page { display: contents; }
8
+ .site-footer { width: 100%; margin-inline: auto; margin-top: 16px; padding-inline: 48px; background: var(--apple-surface-alt); }
9
+ .route-page:has(.components-page) + .site-footer > .page-footer { margin-inline: auto; }
10
+ @media (max-width: 1100px) {
11
+ .site-footer { padding-inline: 28px; }
12
+ }
13
+ @media (max-width: 640px) {
14
+ .site-footer { padding-inline: 18px; }
15
+ }
8
16
  .brand { display: inline-flex; align-items: center; gap: 10px; color: var(--apple-text); text-decoration: none; font-weight: 650; font-size: 20px; }
9
17
  .brand-symbol { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; background: var(--apple-text); color: var(--apple-surface); font-weight: 600; font-size: 24px; line-height: 1; padding-bottom: 4px; font-family: Georgia,serif; }
10
18
  .user-trigger { width: 38px; height: 38px; border: 1px solid var(--apple-border); border-radius: 50%; display: grid; place-items: center; background: var(--apple-surface); color: var(--apple-text); transition: background var(--apple-fast); }
@@ -47,9 +55,10 @@ button { font: inherit; }
47
55
  .more-components { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 38px 0; }
48
56
  .more-components h2 { margin: 0 0 8px; font-size: 19px; font-weight: 600; }
49
57
  .more-components span { font-size: 12px; color: var(--apple-secondary); }
50
- .page-footer { padding: 24px 0; border-top: 1px solid var(--apple-border); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--apple-secondary); margin-top: 16px; }
58
+ .page-footer { padding: 24px 0; border: 0; font-size: 12px; color: var(--apple-secondary); place-items: center; text-align: center }
51
59
  .specimen-grid { display: grid; grid-template-columns: 1.04fr 1fr 1fr; gap: 18px; }
52
- .specimen { min-width: 0; padding: 21px 24px 22px; border-radius: 8px; background: var(--apple-surface); box-shadow: 0 2px 10px rgb(0 0 0 / .025); position: relative; display: flex; flex-direction: column; }
60
+ .specimen { min-width: 0; }
61
+ .specimen > .apple-card__body { height: 100%; display: flex; flex-direction: column; padding: 9px 12px 10px; }
53
62
  .specimen-caption { display: flex; align-items: center; justify-content: space-between; color: var(--apple-secondary); font-size: 9px; margin-bottom: 22px; }
54
63
  .specimen h2 { font-size: 21px; font-weight: 600; line-height: 1.3; margin: 0 0 25px; }
55
64
  .button-specimens { display: flex; align-items: center; flex-direction: column; gap: 15px; padding: 9px 0 22px; }
@@ -62,10 +71,10 @@ button { font: inherit; }
62
71
  .preference-demo .apple-field__label { display: none; }
63
72
  .preference-demo .apple-slider .apple-field__label { display: block; }
64
73
  .setting-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid color-mix(in srgb,var(--apple-border) 55%,transparent); padding: 3px 0 13px; }
65
- .setting-line > div:first-child { min-width: 0; }
74
+ .setting-line > div:first-child { flex: 1; min-width: 0; }
66
75
  .setting-line strong { display: block; font-size: 12px; font-weight: 500; }
67
76
  .setting-line small { display: block; font-size: 10px; color: var(--apple-secondary); margin-top: 4px; }
68
- .setting-line .apple-field { flex-shrink: 0; }
77
+ .setting-line .apple-field { flex-shrink: 0; width: auto; }
69
78
  .check-row { padding-top: 5px; }
70
79
  .check-row .apple-choice__label { font-size: 11px; }
71
80
  .form-specimen .apple-form { display: flex; flex-direction: column; gap: 13px; }
@@ -80,33 +89,29 @@ button { font: inherit; }
80
89
  .feedback-actions { margin-top: auto; display: flex; flex-wrap: wrap; gap: 3px; padding-top: 25px; }
81
90
  .feedback-actions .apple-button { font-size: 11px; }
82
91
 
83
- .specimen > .apple-search { margin-bottom: 12px; }
84
- .sidebar { width: 232px; position: fixed; top: 64px; bottom: 0; left: 0; z-index: 40; padding: 30px 14px 24px; border-right: 1px solid color-mix(in srgb,var(--apple-border) 55%,transparent); background: var(--apple-surface); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
85
- .sidebar-heading { display: flex; flex-direction: column; gap: 7px; padding: 0 14px; margin-bottom: 24px; }
86
- .sidebar-heading strong { font-size: 13px; font-weight: 600; }
87
- .sidebar-heading > span { font-size: 10px; color: var(--apple-secondary); }
88
- .component-index a { text-decoration: none; color: var(--apple-secondary); font-size: 12px; }
89
- .index-group { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 6px; }
90
- .index-group span { flex: 1; }
91
- .index-group small { font-size: 10px; font-variant-numeric: tabular-nums; color: var(--apple-secondary); }
92
- .index-group.active { color: var(--apple-text); font-weight: 600; }
93
- .index-items { margin: 3px 0 12px 21px; padding-left: 12px; border-left: 1px solid var(--apple-border); }
94
- .index-items a { display: block; border-radius: 5px; padding: 7px 10px; }
95
- .component-index a:hover:where(:not([data-apple-touch] *)) { color: var(--apple-text); background: var(--apple-hover); }
96
- .index-items a.active { background: var(--apple-selected); color: var(--apple-text); font-weight: 500; }
97
- .components-page { margin-left: 232px; width: calc(100% - 232px); max-width: none; padding: 42px 48px 0; }
98
- .components-page > * { max-width: 1000px; margin-inline: auto; }
92
+ .specimen > .apple-card__body > .apple-search { margin-bottom: 12px; }
93
+ .directory-layout { display: grid; grid-template-columns: 224px minmax(0, 1fr); gap: 32px; align-items: start; }
94
+ .directory-index { position: sticky; top: 100px; max-height: calc(100dvh - 120px); overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; padding-top: 16px; font-size: 13px; min-width: 0; }
95
+ .directory-index > span { color: var(--apple-secondary); font-size: 12px; display: block; margin: 0 0 12px 8px; }
96
+ .directory-index .apple-tree__row { min-height: 34px; }
97
+ .directory-index .apple-tree__row > span:last-child { min-width: 0; overflow-wrap: anywhere; }
98
+ .components-page { padding: 40px 40px 0; }
99
+ .components-content { min-width: 0; }
100
+ @media (max-width: 900px) {
101
+ .directory-layout { grid-template-columns: minmax(0, 1fr); gap: 20px; }
102
+ .directory-index { display: none; }
103
+ }
99
104
  .feed-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--apple-border); }
100
105
  .feed-count { font-size: 12px; font-weight: 500; }
101
106
  .feed-count small { margin-left: 7px; color: var(--apple-secondary); font-size: 10px; font-variant-numeric: tabular-nums; }
102
107
  .preview-controls { display: flex; gap: 4px; }
103
- .mobile-index-toggle { display: none; }
104
108
  .feed-group { padding-bottom: 16px; }
105
109
  .feed-group-heading { display: flex; align-items: baseline; gap: 12px; padding: 32px 0 20px; }
106
110
  .feed-group-heading > span { color: var(--apple-secondary); font-size: 11px; font-variant-numeric: tabular-nums; }
107
111
  .feed-group-heading h2 { margin: 0; font-size: 20px; font-weight: 600; }
108
112
  .feed-group-heading small { margin-left: auto; font-size: 11px; color: var(--apple-secondary); }
109
- .component-card { min-width: 0; background: var(--apple-surface); border: 1px solid color-mix(in srgb,var(--apple-border) 40%,transparent); border-radius: 10px; margin-bottom: 20px; }
113
+ .component-card { min-width: 0; margin-bottom: 20px; }
114
+ .component-card > .apple-card__body { padding: 0; }
110
115
  .component-card-heading { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 12px; padding: 24px 28px 0; }
111
116
  .component-card-heading h3 { font-size: 16px; font-weight: 600; margin: 0; }
112
117
  .component-card-heading h3 a { display: inline-flex; align-items: center; gap: 8px; color: inherit; text-decoration: none; }
@@ -119,7 +124,6 @@ button { font: inherit; }
119
124
  .detail-preview .component-demo--compact { max-width: 420px; }
120
125
  .detail-preview .component-demo--media { max-width: 560px; }
121
126
  .detail-preview.mobile-preview { max-width: 390px; margin-inline: auto; }
122
- .component-card > .component-source { border-radius: 0 0 9px 9px; }
123
127
  .component-source { border-top: 1px solid color-mix(in srgb,var(--apple-border) 45%,transparent); }
124
128
  .code-view { padding: 4px 28px 20px; min-width: 0; }
125
129
  .code-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--apple-secondary); font-size: 12px; }
@@ -132,9 +136,10 @@ pre { padding: 18px; overflow: auto; background: var(--apple-surface-alt); borde
132
136
  .component-demo .apple-form__fields { gap: 20px; }
133
137
  .component-demo .apple-form__fields > .apple-button { justify-self: start; }
134
138
  .component-demo .apple-grid { align-items: start; }
135
- .demo-layout-tile { display: flex; flex-direction: column; justify-content: center; gap: 12px; min-height: 112px; padding: 20px; border: 1px solid var(--apple-border); border-radius: 10px; background: var(--apple-surface-alt); }
136
- .demo-layout-tile > span { color: var(--apple-secondary); font-size: 12px; font-variant-numeric: tabular-nums; }
137
- .demo-layout-tile > strong { font-size: 16px; font-weight: 550; }
139
+ .demo-layout-tile { min-height: 112px; }
140
+ .demo-layout-tile > .apple-card__body { display: flex; flex-direction: column; justify-content: center; gap: 12px; padding: 8px; }
141
+ .demo-layout-tile > .apple-card__body > span { color: var(--apple-secondary); font-size: 12px; font-variant-numeric: tabular-nums; }
142
+ .demo-layout-tile > .apple-card__body > strong { font-size: 16px; font-weight: 550; }
138
143
  .component-demo > .apple-stack > .apple-provider { border-radius: 10px; }
139
144
  .component-demo[data-component="AppleSpinner"] .apple-spinner { align-self: flex-start; }
140
145
  .component-demo[data-component="AppleDivider"] .apple-divider:not(.apple-divider--vertical) { width: 100%; }
@@ -150,10 +155,10 @@ pre { padding: 18px; overflow: auto; background: var(--apple-surface-alt); borde
150
155
  .demo-copy { margin: 0; padding: 8px 0; color: var(--apple-secondary); }
151
156
  .demo-slide { display: block; width: 100%; aspect-ratio: 16/10; object-fit: cover; }
152
157
 
153
- .demo-floating-preview { padding: 24px; border-radius: 8px; }
158
+ .demo-floating-preview > .apple-card__body { padding: 12px; }
154
159
  .demo-floating-preview .apple-floating-group { position: relative; inset: auto; flex-direction: row; justify-content: center; padding: 12px 0; z-index: auto; }
155
- .settings-page { max-width: 980px; }
156
- .settings-section { background: var(--apple-surface); border: 1px solid color-mix(in srgb,var(--apple-border) 40%,transparent); border-radius: 12px; padding: 28px; margin-bottom: 22px; }
160
+ .settings-section { margin-bottom: 22px; }
161
+ .settings-section > .apple-card__body { padding: 16px; }
157
162
  .settings-section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 26px; }
158
163
  .settings-section-heading > svg { margin-top: 3px; color: var(--apple-secondary); }
159
164
  .settings-section h2 { font-size: 19px; font-weight: 600; margin: 0; }
@@ -167,20 +172,16 @@ pre { padding: 18px; overflow: auto; background: var(--apple-surface-alt); borde
167
172
  .theme-swatch i:last-child { width: 35%; }
168
173
  .theme-label { display: flex; align-items: center; justify-content: space-between; gap: 4px; width: 100%; min-height: 20px; font-size: 12px; padding: 0 2px 3px; }
169
174
  .theme-label svg { color: var(--apple-accent); flex-shrink: 0; }
170
- .settings-section > .apple-segmented { max-width: 440px; }
175
+ .settings-section > .apple-card__body > .apple-segmented { max-width: 440px; }
171
176
  .settings-note { color: var(--apple-secondary); font-size: 12px; line-height: 1.8; min-height: 22px; margin: 14px 0 24px; }
172
- .motion-preview { background: var(--apple-surface-alt); border-radius: 8px; padding: 14px 20px 22px; }
177
+ .motion-preview > .apple-card__body { padding: 2px 8px 10px; }
173
178
  .motion-preview-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 12px; color: var(--apple-secondary); margin-bottom: 14px; }
174
179
  .motion-preview-content p { margin: 18px 0 0; font-size: 13px; color: var(--apple-secondary); line-height: 1.8; }
175
180
  .settings-reset { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--apple-secondary); font-size: 12px; margin: 26px 0 48px; }
176
181
  .not-found-page { max-width: 740px; }
177
- @media (min-width: 1600px) { .main-content.home-page { max-width: 1420px; } .specimen-grid { gap: 24px; } .specimen { padding: 26px 30px; } }
178
- @media (max-width: 1100px) { .main-content { padding-inline: 28px; } .components-page { padding-inline: 32px; } .sidebar { width: 210px; } .components-page { margin-left: 210px; width: calc(100% - 210px); } .specimen-grid { grid-template-columns: 1fr 1fr; } }
182
+ @media (min-width: 1600px) { .main-content.home-page { max-width: 1420px; } .specimen-grid { gap: 24px; } .specimen > .apple-card__body { padding: 14px 18px; } }
183
+ @media (max-width: 1100px) { .main-content { padding-inline: 28px; } .components-page { padding-inline: 32px; } .specimen-grid { grid-template-columns: 1fr 1fr; } }
179
184
  @media (max-width: 800px) {
180
- .sidebar { display: none; }
181
- .components-page { margin-left: 0; width: 100%; }
182
- .mobile-index-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 0; border: 0; background: transparent; color: var(--apple-text); font-size: 12px; }
183
- .feed-count { display: none; }
184
185
  .theme-options { gap: 8px; }
185
186
  }
186
187
  @media (max-width: 640px) {
@@ -198,7 +199,7 @@ pre { padding: 18px; overflow: auto; background: var(--apple-surface-alt); borde
198
199
  .mobile-break { display: block; }
199
200
  .section-heading { align-items: flex-start; flex-direction: column; gap: 7px; padding-top: 22px; }
200
201
  .specimen-grid { grid-template-columns: 1fr; gap: 16px; }
201
- .specimen { padding: 24px; }
202
+ .specimen > .apple-card__body { padding: 12px; }
202
203
  .specimen h2 { font-size: 23px; }
203
204
  .specimen-caption { font-size: 10px; }
204
205
  .button-specimens > .apple-button { min-height: 44px; }
@@ -218,7 +219,7 @@ pre { padding: 18px; overflow: auto; background: var(--apple-surface-alt); borde
218
219
  .detail-preview { padding: 28px 16px; }
219
220
  .code-view { padding-inline: 16px; }
220
221
  pre { padding: 14px; font-size: 11px; }
221
- .settings-section { padding: 22px 18px; }
222
+ .settings-section > .apple-card__body { padding: 10px 6px; }
222
223
  .theme-options { grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; }
223
224
  .theme-swatch { height: 68px; padding: 12px; }
224
225
  .theme-label { font-size: 11px; }
@@ -12,7 +12,7 @@
12
12
  <router-link v-slot="{ href, navigate }" :to="homePath" custom>
13
13
  <apple-button :href="href" @click="navigate">返回首页</apple-button>
14
14
  </router-link>
15
- <apple-button variant="ghost" @click="goBack"><ArrowLeft :size="16" aria-hidden="true" /> 返回上一页</apple-button>
15
+ <apple-link as="button" @click="goBack"><ArrowLeft :size="16" aria-hidden="true" /> 返回上一页</apple-link>
16
16
  </div>
17
17
  </apple-card>
18
18
  <NotFoundPaperScene class="paper-404-illustration" />
@@ -45,7 +45,7 @@ function goBack() {
45
45
  .paper-404-heading h1 { font-size: clamp(26px, 2.7vw, 35px); line-height: 1.35; letter-spacing: -.035em; font-weight: 600; margin: 0 0 17px; }
46
46
  .paper-404-copy { font-size: 13px; line-height: 1.95; color: var(--apple-secondary); margin: 0; text-wrap: pretty; }
47
47
  .paper-404-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 27px; }
48
- .paper-404-actions .apple-button--ghost { font-size: 12px; padding-inline: 8px; color: var(--apple-secondary); }
48
+ .paper-404-actions .apple-link { font-size: 12px; padding-inline: 8px; color: var(--apple-secondary); }
49
49
  .paper-404-illustration { min-width: 0; }
50
50
  .paper-404-footnote { grid-column: 1 / -1; text-align: center; font-size: 10px; letter-spacing: .09em; color: var(--apple-secondary); margin: 48px 0 0; }
51
51
  @media (max-width: 880px) {
@@ -0,0 +1,162 @@
1
+ <template>
2
+ <apple-container style="padding-top: 12px; padding-bottom: 24px">
3
+ <template v-if="registration">
4
+ <apple-breadcrumbs :items="breadcrumbs" label="组件文档路径" @click="navigateBreadcrumb" />
5
+ <header class="documentation-heading"><div><span class="page-kicker">{{ registration.group }} / COMPONENT API</span><h1>{{ registration.label }}<span>{{ registration.name }}</span></h1><p>{{ registration.description }}</p></div></header>
6
+ <div class="documentation-layout directory-layout">
7
+ <aside ref="documentationIndex" class="documentation-index directory-index" aria-label="API 导航"><span>本页接口</span><apple-tree v-model="selectedApi" v-model:expanded="expandedSections" :items="apiTree" label="组件 API 树" @select="navigateApi" /></aside>
8
+ <div ref="documentationContent" class="documentation-content">
9
+ <section class="component-overview" aria-label="组件演示"><component :is="registration.preview" v-if="registration.preview" /></section>
10
+ <ComponentWorkshop v-if="doc" :doc="doc" />
11
+ <apple-alert v-else-if="failed" tone="danger" title="文档加载失败"><apple-button variant="secondary" @click="loadDocument">重试</apple-button></apple-alert>
12
+ <apple-spinner v-else label="正在加载组件 API" />
13
+ </div>
14
+ </div>
15
+ </template>
16
+ <apple-empty v-else title="组件文档尚未开放" description="当前阶段仅开放 AppleInput 样板。"><router-link to="/components" custom v-slot="{ href, navigate }"><apple-link :href="href" @click="navigate">返回组件目录</apple-link></router-link></apple-empty>
17
+ </apple-container>
18
+ <DirectoryDialog v-if="registration" v-model="mobileIndex" title="本页接口">
19
+ <apple-tree v-model="selectedApi" v-model:expanded="expandedSections" :items="apiTree" label="组件 API 树" @select="navigateApi" />
20
+ </DirectoryDialog>
21
+ </template>
22
+ <script setup lang="ts">
23
+ import { computed, shallowRef, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
24
+ import { useRoute, useRouter } from 'vue-router'
25
+ import type { AppleItem, AppleTreeItem, AppleValue } from 'apptify'
26
+ import { componentDocuments } from '../../editor/documents'
27
+ import type { ComponentDocument } from '../../editor/contract'
28
+ import { documentSections } from '../../editor/sections'
29
+ import ComponentWorkshop from '../../editor/ComponentWorkshop.vue'
30
+ import DirectoryDialog from '../../components/DirectoryDialog.vue'
31
+ const route = useRoute()
32
+ const router = useRouter()
33
+ const breadcrumbs = computed(() => [
34
+ { label: '组件', value: 'components', href: '/components' },
35
+ { label: registration.value?.group || '组件', value: 'group', href: `/components#${String(route.params.component)}` },
36
+ { label: registration.value?.label || '', value: 'current' },
37
+ ])
38
+ function navigateBreadcrumb(item: AppleItem, event: MouseEvent) {
39
+ if (!item.href || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
40
+ event.preventDefault()
41
+ void router.push(item.href)
42
+ }
43
+ const registration = computed(() => componentDocuments[String(route.params.component)])
44
+ const doc = shallowRef<ComponentDocument>()
45
+ const failed = ref(false)
46
+ let loadVersion = 0
47
+ async function loadDocument() {
48
+ const version = ++loadVersion
49
+ doc.value = undefined; failed.value = false
50
+ const current = registration.value
51
+ if (!current) return
52
+ try { const result = await current.load(); if (version === loadVersion) doc.value = result.default }
53
+ catch { if (version === loadVersion) failed.value = true }
54
+ }
55
+ watch(registration, loadDocument, { immediate: true })
56
+ onBeforeUnmount(() => { loadVersion++ })
57
+ const documentationIndex = ref<HTMLElement>()
58
+ const documentationContent = ref<HTMLElement>()
59
+ const mobileIndex = ref(false)
60
+ const selectedApi = ref<AppleValue>('props')
61
+ const expandedSections = ref<AppleValue[]>(['props', 'events', 'slots', 'methods'])
62
+ const apiTree = computed<AppleTreeItem[]>(() => doc.value ? [
63
+ ...documentSections(doc.value).map(section => ({
64
+ value: section.id, label: section.title,
65
+ children: section.entries.map(entry => ({ value: entry.id, label: entry.name })),
66
+ })),
67
+ ...(doc.value.notes.length ? [{ value: 'accessibility', label: '交互与无障碍' }] : []),
68
+ ] : [])
69
+ watch(() => [route.hash, doc.value] as const, () => {
70
+ const id = route.hash.slice(1)
71
+ const group = apiTree.value.find(item => item.children?.some(child => child.value === id))
72
+ if (!group && !apiTree.value.some(item => item.value === id)) return
73
+ selectApi(id)
74
+ }, { immediate: true })
75
+ function selectApi(id: AppleValue) {
76
+ selectedApi.value = id
77
+ }
78
+
79
+ let scrollFrame = 0
80
+ let contentObserver: ResizeObserver | undefined
81
+ let indexObserver: ResizeObserver | undefined
82
+ function syncScrollSelection() {
83
+ scrollFrame = 0
84
+ const content = documentationContent.value
85
+ if (!content) return
86
+ const targets = apiTree.value.flatMap(section => [section, ...(section.children || [])])
87
+ .map(item => ({ id: item.value, element: document.getElementById(String(item.value)) }))
88
+ .filter(target => target.element && content.contains(target.element))
89
+ if (!targets.length) return
90
+ let active = targets[0].id
91
+ for (const target of targets) {
92
+ const offset = Math.max(100, parseFloat(getComputedStyle(target.element!).scrollMarginTop) || 0)
93
+ if (target.element!.getBoundingClientRect().top > offset + 1) break
94
+ active = target.id
95
+ }
96
+ // The final section may be too short to reach the reading line above the footer.
97
+ if (window.scrollY > 0 && window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2) {
98
+ active = targets[targets.length - 1].id
99
+ }
100
+ selectApi(active)
101
+ }
102
+ function queueScrollSelection() {
103
+ if (!scrollFrame) scrollFrame = requestAnimationFrame(syncScrollSelection)
104
+ }
105
+ function revealSelection() {
106
+ const index = documentationIndex.value
107
+ const row = index?.querySelector<HTMLElement>('.apple-tree__row.is-selected')
108
+ if (!index || !row) return
109
+ const bounds = index.getBoundingClientRect(), selected = row.getBoundingClientRect()
110
+ if (selected.top < bounds.top) index.scrollTop += selected.top - bounds.top
111
+ else if (selected.bottom > bounds.bottom) index.scrollTop += selected.bottom - bounds.bottom
112
+ }
113
+ watch([selectedApi, expandedSections], async () => {
114
+ await nextTick()
115
+ revealSelection()
116
+ }, { deep: true })
117
+ watch(doc, async () => {
118
+ await nextTick()
119
+ contentObserver?.disconnect()
120
+ if (documentationContent.value) contentObserver?.observe(documentationContent.value)
121
+ queueScrollSelection()
122
+ }, { flush: 'post' })
123
+ onMounted(() => {
124
+ window.addEventListener('scroll', queueScrollSelection, { passive: true })
125
+ window.addEventListener('resize', queueScrollSelection)
126
+ contentObserver = new ResizeObserver(queueScrollSelection)
127
+ if (documentationContent.value) contentObserver.observe(documentationContent.value)
128
+ indexObserver = new ResizeObserver(revealSelection)
129
+ const tree = documentationIndex.value?.querySelector<HTMLElement>('.apple-tree-size')
130
+ if (tree) indexObserver.observe(tree)
131
+ queueScrollSelection()
132
+ })
133
+ onBeforeUnmount(() => {
134
+ window.removeEventListener('scroll', queueScrollSelection)
135
+ window.removeEventListener('resize', queueScrollSelection)
136
+ contentObserver?.disconnect()
137
+ indexObserver?.disconnect()
138
+ cancelAnimationFrame(scrollFrame)
139
+ })
140
+
141
+ async function navigateApi(item: AppleTreeItem) {
142
+ if (item.children?.length) return
143
+ mobileIndex.value = false
144
+ const id = String(item.value)
145
+ await nextTick()
146
+ await router.replace({ hash: `#${id}` })
147
+ await nextTick()
148
+ document.getElementById(id)?.scrollIntoView({ block: 'start' })
149
+ }
150
+
151
+ </script>
152
+ <style scoped>
153
+ .documentation-page { max-width: 1440px; margin: 0 auto; padding: 40px; }
154
+ .documentation-page > :deep(.apple-link) { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }
155
+ .documentation-heading { margin: 12px 0 36px 0; }
156
+ .documentation-heading h1 { margin: 12px 0; font-size: clamp(32px, 4vw, 48px); letter-spacing: -.04em; }
157
+ .documentation-heading h1 span { display: inline-block; margin-left: 18px; color: var(--apple-secondary); font-weight: 400; font-size: 24px; letter-spacing: -.02em; }
158
+ .documentation-heading p { color: var(--apple-secondary); font-size: 15px; line-height: 1.7; }
159
+ .documentation-content { min-width: 0; }
160
+ .component-overview { margin-bottom: 32px; }
161
+ @media (max-width: 720px) { .documentation-page { padding: 40px; } .documentation-heading { margin: 24px 0; } .documentation-heading h1 span { display: block; margin: 8px 0 0; font-size: 20px; } }
162
+ </style>
@@ -0,0 +1,148 @@
1
+ <template>
2
+ <main>
3
+ <apple-container class="main-content components-page">
4
+ <header class="page-heading"><div><div class="page-kicker">COMPONENTS</div><h1>组件<span>。</span></h1><p>{{ catalog.length }} 个组件,完整呈现。向下浏览,直接体验。</p></div></header>
5
+ <div class="directory-layout">
6
+ <aside class="directory-index sidebar" aria-label="组件目录">
7
+ <header class="directory-header">
8
+ <span>组件目录</span>
9
+ <div class="directory-search"><apple-search v-model="directorySearch" label="搜索组件目录" placeholder="名称、标签或描述" /></div>
10
+ </header>
11
+ <div class="directory-tree"><ComponentIndex v-model:expanded="expandedGroups" :active-group="activeGroup" :active-id="activeId" :search="directorySearch" /></div>
12
+ </aside>
13
+ <div class="components-content">
14
+ <div class="feed-toolbar">
15
+ <span class="feed-count">全部组件 <small>{{ catalog.length }}</small></span>
16
+ <div class="preview-controls" role="group" aria-label="预览尺寸">
17
+ <apple-link as="button" icon-only :icon="Monitor" :class="{ active: previewMode === 'desktop' }" :aria-pressed="previewMode === 'desktop'" label="桌面预览" @click="previewMode = 'desktop'" />
18
+ <apple-link icon-only :icon="Smartphone" :class="{ active: previewMode === 'mobile' }" :aria-pressed="previewMode === 'mobile'" label="手机预览" @click="previewMode = 'mobile'" />
19
+ </div>
20
+ </div>
21
+ <div ref="feed" class="component-feed">
22
+ <section v-for="(section, index) in sections" :id="section.id" :key="section.id" class="feed-group feed-anchor" :data-group="section.id" :aria-labelledby="`${section.id}-heading`">
23
+ <header class="feed-group-heading"><span>0{{ index + 1 }}</span><h2 :id="`${section.id}-heading`">{{ section.label }}</h2><small>{{ section.items.length }} 个组件</small></header>
24
+ <div class="feed-cards">
25
+ <apple-card v-for="item in section.items" :id="componentId(item.name)" :key="item.name" class="component-card feed-anchor" :data-group="section.id" :aria-labelledby="`${componentId(item.name)}-heading`">
26
+ <header class="component-card-heading"><div><h3 :id="`${componentId(item.name)}-heading`"><router-link :to="`#${componentId(item.name)}`">{{ item.label }}<Hash :size="14" /></router-link></h3><p>{{ item.description }}</p></div><code>{{ componentId(item.name) }}</code></header>
27
+ <section class="detail-preview" :class="{ 'mobile-preview': previewMode === 'mobile' }" :aria-label="`${item.label}预览`"><ComponentDemo :name="item.name" @navigate="navigateGroup" /></section>
28
+ <ComponentDocumentLink v-if="documentPaths[item.name]" :href="documentPaths[item.name]" label="代码与 API" />
29
+ <apple-accordion v-else class="component-source" :items="sourceSections">
30
+ <template #item>
31
+ <div class="code-view"><div class="code-toolbar"><span>使用示例</span><apple-link @click="copy(item.code)">复制代码</apple-link></div><pre><code>{{ item.code }}</code></pre><h4>接口</h4><p class="api-line">{{ item.api }}</p></div>
32
+ </template>
33
+ </apple-accordion>
34
+ </apple-card>
35
+ </div>
36
+ </section>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </apple-container>
41
+ </main>
42
+ <DirectoryDialog v-model="mobileIndex" title="组件目录">
43
+ <div class="directory-search"><apple-search v-model="directorySearch" label="搜索组件目录" placeholder="名称、标签或描述" /></div>
44
+ <ComponentIndex v-model:expanded="expandedGroups" :active-group="activeGroup" :active-id="activeId" :search="directorySearch" @navigate="mobileIndex = false" />
45
+ </DirectoryDialog>
46
+ </template>
47
+
48
+ <script setup lang="ts">
49
+ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
50
+ import { useRoute, useRouter } from 'vue-router'
51
+ import { Hash, Monitor, Smartphone } from 'lucide-vue-next'
52
+ import { useApple } from 'apptify'
53
+ import { catalog, sections, componentId } from '../catalog'
54
+ import ComponentDemo from '../ComponentDemo.vue'
55
+ import ComponentIndex from '../components/ComponentIndex.vue'
56
+ import DirectoryDialog from '../components/DirectoryDialog.vue'
57
+ import ComponentDocumentLink from '../editor/ComponentDocumentLink.vue'
58
+ import { componentDocuments, componentDocumentPath } from '../editor/documents'
59
+
60
+ const documentPaths = Object.fromEntries(Object.entries(componentDocuments).map(([slug, document]) => [document.name, componentDocumentPath(slug)]))
61
+ const route = useRoute()
62
+ const router = useRouter()
63
+ const apple = useApple()
64
+ const feed = ref<HTMLElement>()
65
+ const activeGroup = ref(sections[0].id)
66
+ const activeId = ref('')
67
+ const previewMode = ref('desktop')
68
+ const mobileIndex = ref(false)
69
+ const directorySearch = ref('')
70
+ const expandedGroups = ref(sections.map(section => section.id))
71
+ const sourceSections = [{ label: '代码与 API', value: 'source' }]
72
+ let anchors: HTMLElement[] = []
73
+ let frame = 0
74
+ let observer: ResizeObserver | undefined
75
+
76
+ function updatePosition() {
77
+ frame = 0
78
+ let current: HTMLElement | undefined
79
+ const positioned = anchors.map(anchor => ({ anchor, top: anchor.getBoundingClientRect().top })).sort((a, b) => a.top - b.top)
80
+ for (const { anchor, top } of positioned) {
81
+ if (top > 128) break
82
+ if (!current || Math.abs(anchor.getBoundingClientRect().top - current.getBoundingClientRect().top) > 1) current = anchor
83
+ else if (anchor.id === route.hash.slice(1) || (current.id !== route.hash.slice(1) && anchor.id === activeId.value)) current = anchor
84
+ }
85
+ activeId.value = current?.id ?? ''
86
+ activeGroup.value = current?.dataset.group ?? sections[0].id
87
+ }
88
+ function schedulePosition() {
89
+ if (!frame) frame = requestAnimationFrame(updatePosition)
90
+ }
91
+ function navigateGroup(label: string) {
92
+ const section = sections.find(section => section.label === label)
93
+ void router.push(section ? `/components#${section.id}` : '/components')
94
+ }
95
+ async function copy(value: string) {
96
+ try { await navigator.clipboard.writeText(value); apple.notify('已复制', { tone: 'success' }) }
97
+ catch { apple.notify('无法访问剪贴板,请手动选择代码复制', { tone: 'warning' }) }
98
+ }
99
+ watch(() => route.hash, () => { mobileIndex.value = false; schedulePosition() })
100
+ watch(activeId, async () => {
101
+ await nextTick()
102
+ const sidebar = document.querySelector<HTMLElement>('.directory-tree')
103
+ const current = sidebar?.querySelector<HTMLElement>('.apple-tree__row.is-selected')
104
+ if (!sidebar || !current) return
105
+ const item = current.getBoundingClientRect(), bounds = sidebar.getBoundingClientRect()
106
+ if (item.top < bounds.top + 16) sidebar.scrollTop -= bounds.top + 16 - item.top
107
+ else if (item.bottom > bounds.bottom - 16) sidebar.scrollTop += item.bottom - bounds.bottom + 16
108
+ })
109
+ onMounted(() => {
110
+ anchors = Array.from(feed.value!.querySelectorAll<HTMLElement>('.feed-anchor'))
111
+ window.addEventListener('scroll', schedulePosition, { passive: true })
112
+ window.addEventListener('resize', schedulePosition)
113
+ observer = new ResizeObserver(schedulePosition)
114
+ observer.observe(feed.value!)
115
+ schedulePosition()
116
+ })
117
+ onBeforeUnmount(() => {
118
+ window.removeEventListener('scroll', schedulePosition)
119
+ window.removeEventListener('resize', schedulePosition)
120
+ observer?.disconnect()
121
+ cancelAnimationFrame(frame)
122
+ })
123
+ </script>
124
+
125
+ <style scoped>
126
+ .directory-index { display: flex; flex-direction: column; overflow: hidden; }
127
+ .directory-header { flex-shrink: 0; }
128
+ .directory-header > span { color: var(--apple-secondary); font-size: 12px; display: block; margin: 0 0 12px 8px; }
129
+ .directory-tree { min-height: 0; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; }
130
+ @media (max-width: 900px) { .directory-index { display: none; } }
131
+ .directory-search { min-width: 0; margin: 0 4px 16px; }
132
+ .directory-search :deep(.apple-field__label) { font-size: 11px; }
133
+ .directory-search :deep(input) { min-width: 0; font-size: 12px; }
134
+ .components-page > * { max-width: none; }
135
+ .feed-cards { column-width: 400px; column-gap: 20px; }
136
+ .feed-group-heading { padding-bottom: 20px; }
137
+ .component-card { display: inline-block; vertical-align: top; width: 100%; break-inside: avoid; margin-bottom: 20px; padding: 0; }
138
+ .component-card :deep(> .apple-card__body) { padding: 0; border-radius: inherit; }
139
+ .component-card :deep(> .apple-card__body > .documentation-entry),
140
+ .component-card :deep(> .apple-card__body > .component-source) { border-radius: 0 0 18px 18px; overflow: hidden; }
141
+ .component-source :deep(.apple-accordion__item) { border: 0; }
142
+ .component-source :deep(.apple-accordion__item > h3) { font-size: 11px; font-weight: 400; }
143
+ .component-source :deep(.apple-accordion__item > h3 > button) { min-height: 44px; gap: 8px; padding: 13px 28px; color: var(--apple-secondary); }
144
+ .component-source :deep(.apple-accordion__content) { padding: 0; }
145
+ @media (max-width: 800px) {
146
+ .component-source :deep(.apple-accordion__item > h3 > button) { padding-inline: 18px; }
147
+ }
148
+ </style>