apptify 0.5.1 → 0.6.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 +19 -66
- package/dist/apptify.css +1 -1
- package/dist/apptify.js +2298 -2140
- package/dist/components/AppleCard.vue.d.ts +76 -0
- package/dist/components/button.d.ts +3 -3
- package/dist/components/content.d.ts +0 -90
- package/dist/components/foundation.d.ts +201 -129
- package/dist/components/link.d.ts +31 -0
- package/dist/components/overlays.d.ts +8 -2
- package/dist/core/context.d.ts +11 -0
- package/dist/core/overlay-surface.d.ts +6 -0
- package/dist/core/popup-motion.d.ts +5 -5
- package/dist/core/popup-placement.d.ts +3 -2
- package/dist/index.d.ts +144 -79
- package/docs/COMPONENTS.md +23 -9
- package/docs/INPUT-WORKSHOP.md +59 -0
- package/docs/PLAYGROUND-TEMPLATE.md +9 -9
- package/package.json +5 -1
- package/templates/playground/README.md +4 -4
- package/templates/playground/index.html +1 -1
- package/templates/playground/package.json +6 -2
- package/templates/playground/source-snapshot.json +138 -53
- package/templates/playground/{playground → src}/App.vue +5 -4
- package/templates/playground/{playground → src}/ComponentDemo.vue +9 -10
- package/templates/playground/{playground → src}/catalog.ts +3 -4
- package/templates/playground/src/components/ComponentIndex.vue +43 -0
- package/templates/playground/src/components/DirectoryDialog.vue +20 -0
- package/templates/playground/src/components/PageFooter.vue +7 -0
- package/templates/playground/{playground → src}/components/UserMenu.vue +11 -11
- package/templates/playground/src/editor/AppleInputExample.vue +78 -0
- package/templates/playground/src/editor/AppleInputPreview.vue +82 -0
- package/templates/playground/src/editor/ComponentDocumentLink.vue +19 -0
- package/templates/playground/src/editor/ComponentWorkshop.vue +81 -0
- package/templates/playground/src/editor/ConfigurableEditor.vue +35 -0
- package/templates/playground/src/editor/LiveVueEditor.vue +133 -0
- package/templates/playground/src/editor/apple-input-source.ts +2 -0
- package/templates/playground/src/editor/apple-input.ts +48 -0
- package/templates/playground/src/editor/contract.ts +14 -0
- package/templates/playground/src/editor/documents.ts +20 -0
- package/templates/playground/src/editor/input-playground.ts +59 -0
- package/templates/playground/src/editor/runtime.ts +5 -0
- package/templates/playground/src/editor/sections.ts +10 -0
- package/templates/playground/{playground → src}/style.css +40 -39
- package/templates/playground/{playground → src}/views/[...all].vue +2 -2
- package/templates/playground/src/views/component-docs/[component].vue +162 -0
- package/templates/playground/src/views/components.vue +148 -0
- package/templates/playground/src/views/index.vue +73 -0
- package/templates/playground/src/views/settings.vue +224 -0
- package/templates/playground/tooling/playground-editor.mjs +114 -0
- package/templates/playground/tooling/prepare-input-source.mjs +38 -0
- package/templates/playground/tsconfig.json +2 -2
- package/templates/playground/vite.config.ts +3 -1
- package/tooling/playground-editor.mjs +114 -0
- package/tooling/prepare-input-source.mjs +38 -0
- package/tooling/prepare-playground.mjs +27 -14
- package/tooling/test-create-apptify.mjs +15 -5
- package/tooling/test-playground-package.mjs +25 -11
- package/templates/playground/playground/components/ComponentIndex.vue +0 -62
- package/templates/playground/playground/components/PageFooter.vue +0 -3
- package/templates/playground/playground/views/components.vue +0 -126
- package/templates/playground/playground/views/index.vue +0 -75
- package/templates/playground/playground/views/settings.vue +0 -98
- /package/templates/playground/{playground → src}/components/not-found-paper-scene.vue +0 -0
- /package/templates/playground/{playground → src}/components/not-found-paper-story.ts +0 -0
- /package/templates/playground/{playground → src}/env.d.ts +0 -0
- /package/templates/playground/{playground → src}/main.ts +0 -0
- /package/templates/playground/{playground → src}/router/index.ts +0 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<apple-popover v-model="opened" label="用户菜单" align="end" :width="280" panel-class="user-popup">
|
|
3
3
|
<template #activator>
|
|
4
|
-
<apple-button class="user-trigger user-account-trigger"
|
|
4
|
+
<apple-link as="button" class="user-trigger user-account-trigger" icon-only label="打开用户菜单" title="用户菜单"><UserRound :size="20" /></apple-link>
|
|
5
5
|
</template>
|
|
6
6
|
<div class="user-summary">
|
|
7
7
|
<apple-avatar name="林初" :size="48" />
|
|
8
8
|
<div><strong>林初 <small>演示账户</small></strong><span>lin.chu@example.com</span></div>
|
|
9
9
|
</div>
|
|
10
10
|
<div class="user-menu-list">
|
|
11
|
-
<apple-
|
|
11
|
+
<apple-link as="button" @click="showProfile"><UserRound :size="17" /><span>个人资料</span><ArrowUpRight :size="14" aria-hidden="true" /></apple-link>
|
|
12
12
|
<router-link to="/settings" @click="opened = false"><Settings2 :size="17" /><span>偏好设置</span><ArrowUpRight :size="14" aria-hidden="true" /></router-link>
|
|
13
13
|
</div>
|
|
14
14
|
<div class="user-menu-list user-menu-examples">
|
|
15
|
-
<apple-
|
|
16
|
-
<apple-
|
|
15
|
+
<apple-link as="button" @click="openAuth('login')"><LogIn :size="17" /><span>登录 <small>Login</small></span><em>示例</em></apple-link>
|
|
16
|
+
<apple-link as="button" @click="openAuth('register')"><UserPlus :size="17" /><span>注册 <small>Register</small></span><em>示例</em></apple-link>
|
|
17
17
|
</div>
|
|
18
18
|
</apple-popover>
|
|
19
19
|
<apple-dialog v-model="authOpen" class="user-auth-dialog" :width="560" :title="authMode === 'login' ? '登录示例' : '注册示例'" :show-footer="false" @after-close="password = ''">
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
<apple-input v-model="password" label="密码" type="password" :autocomplete="authMode === 'login' ? 'current-password' : 'new-password'" required>
|
|
35
35
|
<template #prefix><LockKeyhole :size="18" aria-hidden="true" /></template>
|
|
36
36
|
</apple-input>
|
|
37
|
-
<apple-button v-if="authMode === 'login'" class="user-auth-forgot"
|
|
37
|
+
<apple-link as="button" v-if="authMode === 'login'" class="user-auth-forgot" @click="showAuthEntry('forgot')">忘记密码?</apple-link>
|
|
38
38
|
</div>
|
|
39
39
|
<p class="user-auth-note">这是表单交互示例,尚未接入账户服务。填写内容不会提交到服务器。</p>
|
|
40
40
|
<div class="user-auth-actions">
|
|
41
|
-
<apple-button class="user-auth-submit" type="submit"
|
|
42
|
-
<apple-button class="user-auth-email" variant="outline"
|
|
41
|
+
<apple-button class="user-auth-submit" type="submit">{{ authMode === 'login' ? '体验登录' : '体验注册' }}</apple-button>
|
|
42
|
+
<apple-button class="user-auth-email" variant="outline" @click="showAuthEntry('email')">邮箱验证码登录</apple-button>
|
|
43
43
|
</div>
|
|
44
44
|
</apple-form>
|
|
45
45
|
<div class="user-auth-alternatives">
|
|
@@ -99,10 +99,10 @@ function showProfile() {
|
|
|
99
99
|
|
|
100
100
|
<style>
|
|
101
101
|
.user-popup.apple-popover { border-radius: 24px; background: var(--apple-surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
102
|
-
.user-popup .user-menu-list > .apple-
|
|
103
|
-
.user-popup .user-menu-list > .apple-
|
|
104
|
-
.user-popup .user-menu-list > .apple-
|
|
105
|
-
.user-popup .user-menu-list > .apple-
|
|
102
|
+
.user-popup .user-menu-list > .apple-link > .apple-link__content { width: 100%; justify-content: flex-start; gap: 11px; }
|
|
103
|
+
.user-popup .user-menu-list > .apple-link { min-height: 44px; font-weight: 400; line-height: inherit; }
|
|
104
|
+
.user-popup .user-menu-list > .apple-link:hover:where(:not([data-apple-touch] *)):not(:disabled) { background: var(--apple-hover); }
|
|
105
|
+
.user-popup .user-menu-list > .apple-link:active:not(:disabled) { background: var(--apple-pressed); }
|
|
106
106
|
.user-trigger.user-account-trigger { width: 44px; height: 44px; border: 0; background: transparent; }
|
|
107
107
|
.user-trigger.user-account-trigger:focus-visible { outline: 3px solid var(--apple-accent); outline-offset: 2px; }
|
|
108
108
|
.user-auth-dialog.apple-modal { border-radius: 24px; background: var(--apple-surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { AppleInput, AppleTextarea, AppleLink } from 'apptify'
|
|
3
|
+
import { Mail } from 'lucide-vue-next'
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
components: { AppleInput, AppleTextarea, AppleLink, Mail },
|
|
7
|
+
data() {
|
|
8
|
+
return { value: 'Apptify', events: [], eventId: 0 }
|
|
9
|
+
},
|
|
10
|
+
computed: {
|
|
11
|
+
eventLog() {
|
|
12
|
+
return this.events.map(event => `${event.id}. ${event.name} ${event.value === undefined ? '无参数' : JSON.stringify(event.value)}`).join('\n')
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
watch: {
|
|
16
|
+
eventLog() {
|
|
17
|
+
this.$nextTick(() => {
|
|
18
|
+
const textarea = this.$refs.logInput?.$el.querySelector('textarea')
|
|
19
|
+
if (textarea) textarea.scrollTop = textarea.scrollHeight
|
|
20
|
+
})
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
methods: {
|
|
24
|
+
record(name, value) {
|
|
25
|
+
this.events.push({ id: ++this.eventId, name, value })
|
|
26
|
+
if (this.events.length > 12) this.events.shift()
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<div class="example">
|
|
34
|
+
<section class="input-section" aria-label="输入框">
|
|
35
|
+
<form @submit.prevent="record('submit', value)">
|
|
36
|
+
<AppleInput
|
|
37
|
+
ref="input"
|
|
38
|
+
v-model="value"
|
|
39
|
+
label="姓名"
|
|
40
|
+
placeholder="怎么称呼你"
|
|
41
|
+
hint="用于在应用中显示你的名称。"
|
|
42
|
+
clearable
|
|
43
|
+
@update:model-value="record('update:modelValue', $event)"
|
|
44
|
+
@change="record('change', $event)"
|
|
45
|
+
@clear="record('clear')"
|
|
46
|
+
@focus="record('focus')"
|
|
47
|
+
@blur="record('blur')"
|
|
48
|
+
>
|
|
49
|
+
<!-- prefix -->
|
|
50
|
+
<!-- suffix -->
|
|
51
|
+
</AppleInput>
|
|
52
|
+
<div class="actions">
|
|
53
|
+
<AppleLink as="button" :disabled="false" @click="$refs.input.focus()">focus()</AppleLink>
|
|
54
|
+
<AppleLink as="button" @click="value = 0">赋值为 0</AppleLink>
|
|
55
|
+
<AppleLink as="button" type="submit">表单校验</AppleLink>
|
|
56
|
+
</div>
|
|
57
|
+
</form>
|
|
58
|
+
<output>v-model = {{ JSON.stringify(value) }} · {{ typeof value }}</output>
|
|
59
|
+
</section>
|
|
60
|
+
<section class="events-section" aria-label="事件记录">
|
|
61
|
+
<div class="event-heading"><h3>事件记录</h3><AppleLink as="button" @click="events = []">清除记录</AppleLink></div>
|
|
62
|
+
<AppleTextarea ref="logInput" :model-value="eventLog" readonly :rows="6" :resize="false" class="event-log" aria-label="事件记录内容" placeholder="等待操作…" />
|
|
63
|
+
</section>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<style scoped>
|
|
68
|
+
.example { display: grid; gap: 28px; max-width: 560px; margin-inline: auto; padding: 28px 24px; }
|
|
69
|
+
.input-section { display: grid; gap: 16px; min-width: 0; }
|
|
70
|
+
form { display: grid; gap: 16px; min-width: 0; }
|
|
71
|
+
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 24px; }
|
|
72
|
+
output { color: var(--apple-secondary); font-size: 12px; line-height: 1.6; font-family: ui-monospace, SFMono-Regular, monospace; overflow-wrap: anywhere; }
|
|
73
|
+
.events-section { display: grid; gap: 12px; min-width: 0; border-top: 1px solid var(--apple-border); padding-top: 20px; }
|
|
74
|
+
.event-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
75
|
+
.event-heading h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
|
76
|
+
.example :deep(.event-log) { height: 240px; max-height: 240px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, monospace; }
|
|
77
|
+
@media (max-width: 480px) { .example { padding: 24px 16px; } }
|
|
78
|
+
</style>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="input-playground" data-testid="input-playground">
|
|
3
|
+
<LiveVueEditor :files="files" :baseline-files="baselineFiles" reset-label="恢复当前配置" active-file="App.vue" preview-surface @restore="reset">
|
|
4
|
+
<template #configuration>
|
|
5
|
+
<section class="configuration" role="region" aria-label="输入框配置">
|
|
6
|
+
<div class="config-heading"><h3>配置输入框</h3><AppleLink as="button" @click="reset">重置</AppleLink></div>
|
|
7
|
+
<div class="preset-segments"><AppleSegmentedControl :model-value="preset" label="快速场景" :items="presets" @update:model-value="applyPreset(String($event))" /></div>
|
|
8
|
+
<div class="preset-select"><AppleSelect :model-value="preset" label="快速场景" :items="presets" @update:model-value="applyPreset(String($event))" /></div>
|
|
9
|
+
<AppleSegmentedControl v-model="config.type" label="输入类型" :items="types" />
|
|
10
|
+
<div class="config-grid">
|
|
11
|
+
<AppleInput v-model="config.label" label="标签" hint="label" />
|
|
12
|
+
<AppleInput v-model="config.placeholder" label="占位文字" hint="placeholder" />
|
|
13
|
+
<AppleInput v-model="config.hint" label="帮助文字" hint="hint" />
|
|
14
|
+
<AppleInput v-model="config.error" label="错误信息" hint="error" placeholder="留空恢复正常" />
|
|
15
|
+
</div>
|
|
16
|
+
<div class="switch-grid">
|
|
17
|
+
<AppleSwitch v-for="toggle in toggles" :key="toggle.key" v-model="config[toggle.key]" :label="toggle.label" :hint="toggle.key" />
|
|
18
|
+
</div>
|
|
19
|
+
<AppleAccordion class="advanced-config" :items="[{ label: '插槽、原生属性与动效', value: 'advanced' }]">
|
|
20
|
+
<template #item>
|
|
21
|
+
<div class="config-grid">
|
|
22
|
+
<AppleSegmentedControl v-model="config.prefixMode" label="前缀插槽" hint="prefix" :items="affixModes" />
|
|
23
|
+
<AppleInput v-if="config.prefixMode === 'text'" v-model="config.prefix" label="前缀文字" placeholder="例如 ¥" />
|
|
24
|
+
<AppleInput v-if="config.prefixMode === 'icon'" v-model="config.prefixIcon" label="前缀图标代号" placeholder="Mail" hint="Lucide 代号,例如 Mail、User、Search。" :error="iconCodeError(config.prefixIcon)" />
|
|
25
|
+
<AppleSegmentedControl v-model="config.suffixMode" label="后缀插槽" hint="suffix" :items="affixModes" />
|
|
26
|
+
<AppleInput v-if="config.suffixMode === 'text'" v-model="config.suffix" label="后缀文字" placeholder="例如 kg" />
|
|
27
|
+
<AppleInput v-if="config.suffixMode === 'icon'" v-model="config.suffixIcon" label="后缀图标代号" placeholder="Search" hint="Lucide 代号,例如 Search、Check、ArrowRight。" :error="iconCodeError(config.suffixIcon)" />
|
|
28
|
+
<AppleSelect v-model="config.motion" label="动效" hint="motion" :items="motions" />
|
|
29
|
+
<AppleInput v-model="config.name" label="表单字段" hint="name" />
|
|
30
|
+
<AppleInput v-model="config.ariaLabel" label="可访问名称" hint="aria-label" />
|
|
31
|
+
<AppleInput v-model="config.maxlength" label="最大长度" hint="maxlength" type="number" min="0" placeholder="不限制" />
|
|
32
|
+
<AppleSelect v-model="config.autocomplete" label="自动填充" hint="autocomplete" :items="autocompletes" />
|
|
33
|
+
<AppleInput v-model="config.min" label="最小值" hint="min" type="number" :disabled="config.type !== 'number'" />
|
|
34
|
+
<AppleInput v-model="config.max" label="最大值" hint="max" type="number" :disabled="config.type !== 'number'" />
|
|
35
|
+
<AppleInput v-model="config.step" label="步长" hint="step" type="number" min="0" :disabled="config.type !== 'number'" />
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
</AppleAccordion>
|
|
39
|
+
</section>
|
|
40
|
+
</template>
|
|
41
|
+
</LiveVueEditor>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
import { computed, defineAsyncComponent, reactive, ref } from 'vue'
|
|
46
|
+
import { AppleInput, AppleButton, AppleLink, AppleSelect, AppleSegmentedControl, AppleSwitch, AppleAccordion } from 'apptify'
|
|
47
|
+
import { createInputConfig, generateInputCode, iconCodeError } from './input-playground'
|
|
48
|
+
const LiveVueEditor = defineAsyncComponent(() => import('./LiveVueEditor.vue'))
|
|
49
|
+
const baselineFiles = { 'App.vue': generateInputCode(createInputConfig(), 'Apptify') }
|
|
50
|
+
const config = reactive(createInputConfig())
|
|
51
|
+
const value = ref<string | number>('Apptify')
|
|
52
|
+
const preset = ref('basic')
|
|
53
|
+
const options = (values: string[]) => values.map(value => ({ label: value, value }))
|
|
54
|
+
const presets = [{ label: '基础', value: 'basic' }, { label: '密码', value: 'password' }, { label: '邮箱', value: 'email' }, { label: '金额', value: 'amount' }]
|
|
55
|
+
const types = [{ label: '文本', value: 'text' }, { label: '密码', value: 'password' }, { label: '数字', value: 'number' }, { label: '邮箱', value: 'email' }, { label: '搜索', value: 'search' }, { label: '电话', value: 'tel' }, { label: '网址', value: 'url' }]
|
|
56
|
+
const affixModes = [{ label: '无', value: 'none' }, { label: '文字', value: 'text' }, { label: '图标', value: 'icon' }]
|
|
57
|
+
const motions = options(['inherit', 'auto', 'full', 'reduced', 'none'])
|
|
58
|
+
const autocompletes = options(['off', 'on', 'name', 'email', 'username', 'current-password', 'new-password', 'tel', 'url'])
|
|
59
|
+
const toggles = [{ key: 'clearable', label: '允许清空' }, { key: 'disabled', label: '禁用' }, { key: 'loading', label: '加载' }, { key: 'required', label: '必填' }] as const
|
|
60
|
+
function applyPreset(next: string) {
|
|
61
|
+
Object.assign(config, createInputConfig())
|
|
62
|
+
preset.value = next
|
|
63
|
+
value.value = 'Apptify'
|
|
64
|
+
if (next === 'password') { Object.assign(config, { type: 'password', label: '密码', placeholder: '请输入密码', hint: '点击眼睛切换密码可见性。', autocomplete: 'current-password' }); value.value = 'apple123' }
|
|
65
|
+
if (next === 'email') { Object.assign(config, { type: 'email', label: '电子邮箱', placeholder: 'name@example.com', hint: '提交时体验原生邮箱校验。', required: true, autocomplete: 'email', prefixMode: 'icon', prefixIcon: 'Mail' }); value.value = '' }
|
|
66
|
+
if (next === 'amount') { Object.assign(config, { type: 'number', label: '金额', placeholder: '0.00', hint: '数字输入事件仍返回字符串。', prefixMode: 'text', prefix: '¥', suffixMode: 'text', suffix: '元', min: '0', step: '0.01' }); value.value = 128 }
|
|
67
|
+
}
|
|
68
|
+
function reset() { applyPreset('basic') }
|
|
69
|
+
const files = computed(() => ({ 'App.vue': generateInputCode(config, value.value) }))
|
|
70
|
+
</script>
|
|
71
|
+
<style scoped>
|
|
72
|
+
.input-playground { display: grid; gap: 24px; min-width: 0; }
|
|
73
|
+
.preset-select { display: none; }
|
|
74
|
+
.configuration { display: grid; gap: 20px; }
|
|
75
|
+
.config-heading { display: flex; align-items: center; justify-content: space-between; }
|
|
76
|
+
.config-heading h3 { margin: 0; font-size: 16px; }
|
|
77
|
+
.config-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
|
78
|
+
.switch-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
|
79
|
+
.advanced-config :deep(.apple-accordion__item > h3) { font-size: 14px; }
|
|
80
|
+
.advanced-config :deep(.apple-accordion__content) { padding: 8px 12px 20px; }
|
|
81
|
+
@media (max-width: 1100px) { .preset-segments { display: none; } .preset-select { display: block; } .config-grid, .switch-grid { grid-template-columns: minmax(0, 1fr); } }
|
|
82
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<footer class="documentation-entry">
|
|
3
|
+
<router-link :to="href" custom v-slot="{ href: target, navigate }">
|
|
4
|
+
<apple-link :href="target" @click="navigate"><span>{{ label || '代码与 API' }}</span><ArrowUpRight :size="16" :stroke-width="1.5" aria-hidden="true" /></apple-link>
|
|
5
|
+
</router-link>
|
|
6
|
+
</footer>
|
|
7
|
+
</template>
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import { ArrowUpRight } from 'lucide-vue-next'
|
|
10
|
+
defineProps<{ href: string; label?: string }>()
|
|
11
|
+
</script>
|
|
12
|
+
<style scoped>
|
|
13
|
+
.documentation-entry { border-top: 1px solid color-mix(in srgb, var(--apple-border) 45%, transparent); border-radius: 0 0 18px 18px; overflow: hidden; }
|
|
14
|
+
.documentation-entry :deep(.apple-link) { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 44px; gap: 8px; padding: 13px 28px; color: var(--apple-secondary); font-size: 11px; font-weight: 400; line-height: 1.25; transition: color var(--apple-fast), background-color var(--apple-fast); }
|
|
15
|
+
.documentation-entry :deep(.apple-link__content) { width: 100%; justify-content: space-between; }
|
|
16
|
+
.documentation-entry :deep(.apple-link:hover) { color: var(--apple-secondary); background: var(--apple-hover); }
|
|
17
|
+
.documentation-entry :deep(.apple-link:active) { background: var(--apple-pressed); }
|
|
18
|
+
@media (max-width: 800px) { .documentation-entry :deep(.apple-link) { padding-inline: 18px; } }
|
|
19
|
+
</style>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="workshop" data-testid="component-document">
|
|
3
|
+
<section v-for="section in apiSections" :key="section.id" :id="section.id" class="api-section">
|
|
4
|
+
<header class="section-heading"><h2>{{ section.title }}</h2><span>{{ section.entries.length }} 项接口</span></header>
|
|
5
|
+
<div class="api-group">
|
|
6
|
+
<article v-for="entry in section.entries" :key="entry.id" :id="entry.id" class="api-entry" :data-api="entry.id">
|
|
7
|
+
<div class="api-summary">
|
|
8
|
+
<div class="api-signature">
|
|
9
|
+
<h3><code>{{ entry.name }}</code></h3>
|
|
10
|
+
<code class="api-type">{{ entry.type }}</code>
|
|
11
|
+
<div v-if="entry.default !== undefined" class="api-default"><span>默认值</span><code>{{ entry.default }}</code></div>
|
|
12
|
+
</div>
|
|
13
|
+
<p class="api-description">{{ entry.description }}</p>
|
|
14
|
+
</div>
|
|
15
|
+
<apple-accordion v-if="entry.example" class="api-accordion" :model-value="activeApi === entry.id ? entry.id : []" :items="[{ label: '交互示例', value: entry.id }]" @update:model-value="value => toggleExample(entry.id, value)">
|
|
16
|
+
<template #item="{ open }">
|
|
17
|
+
<div v-if="open && activeApi === entry.id" class="api-example">
|
|
18
|
+
<div class="example-introduction"><h4>{{ entry.example.title }}</h4><p>{{ entry.example.description }}</p></div>
|
|
19
|
+
<LiveVueEditor :key="entry.id" :files="apiFiles(entry)" :baseline-files="{ 'App.vue': entry.example.code }" active-file="App.vue" @save="files => saveApi(entry.id, files)" />
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
</apple-accordion>
|
|
23
|
+
</article>
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
<section id="accessibility" class="api-section">
|
|
27
|
+
<header class="section-heading"><h2>交互与无障碍</h2></header>
|
|
28
|
+
<ul class="api-notes"><li v-for="note in doc.notes" :key="note">{{ note }}</li></ul>
|
|
29
|
+
</section>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
<script setup lang="ts">
|
|
33
|
+
import { computed, defineAsyncComponent, ref } from 'vue'
|
|
34
|
+
import type { ApiEntry, ComponentDocument } from './contract'
|
|
35
|
+
import { documentSections } from './sections'
|
|
36
|
+
const props = defineProps<{ doc: ComponentDocument }>()
|
|
37
|
+
// Additional editors are mounted only for examples not covered by the main demo.
|
|
38
|
+
const LiveVueEditor = defineAsyncComponent(() => import('./LiveVueEditor.vue'))
|
|
39
|
+
const activeApi = ref<string>()
|
|
40
|
+
const apiDrafts = new Map<string, Record<string, string>>()
|
|
41
|
+
const apiSections = computed(() => documentSections(props.doc))
|
|
42
|
+
function toggleExample(id: string, value: unknown) { activeApi.value = value === id ? id : undefined }
|
|
43
|
+
function apiFiles(entry: ApiEntry) { return apiDrafts.get(entry.id) || { 'App.vue': entry.example!.code } }
|
|
44
|
+
function saveApi(id: string, files: Record<string, string>) { apiDrafts.set(id, files) }
|
|
45
|
+
</script>
|
|
46
|
+
<style scoped>
|
|
47
|
+
.workshop { min-width: 0; }
|
|
48
|
+
.api-section { scroll-margin-top: 100px; margin-top: 40px; }
|
|
49
|
+
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
|
50
|
+
.section-heading h2 { margin: 0; font-size: 22px; letter-spacing: -.02em; }
|
|
51
|
+
.section-heading > span { color: var(--apple-secondary); font-size: 12px; }
|
|
52
|
+
.api-group { border-top: 1px solid var(--apple-border); }
|
|
53
|
+
.api-entry { scroll-margin-top: 100px; }
|
|
54
|
+
.api-entry + .api-entry { border-top: 1px solid var(--apple-border); }
|
|
55
|
+
.api-summary { display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 1fr); gap: 28px; padding: 24px 0; }
|
|
56
|
+
.api-signature { min-width: 0; }
|
|
57
|
+
.api-signature h3 { margin: 0 0 8px; font-size: 15px; line-height: 1.5; overflow-wrap: anywhere; }
|
|
58
|
+
.api-signature code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
59
|
+
.api-type { display: block; color: var(--apple-secondary); font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; }
|
|
60
|
+
.api-default { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; margin-top: 12px; font-size: 12px; }
|
|
61
|
+
.api-default > span { color: var(--apple-secondary); }
|
|
62
|
+
.api-default > code { padding: 2px 6px; border-radius: 4px; background: var(--apple-surface-alt); overflow-wrap: anywhere; }
|
|
63
|
+
.api-description { margin: 0; font-size: 14px; line-height: 1.85; overflow-wrap: anywhere; }
|
|
64
|
+
.api-accordion { margin: 0; }
|
|
65
|
+
.api-accordion :deep(.apple-accordion__item) { border-bottom: 0; }
|
|
66
|
+
.api-accordion :deep(.apple-accordion__item > h3) { margin: 0; }
|
|
67
|
+
.api-accordion :deep(.apple-accordion__item > h3 > button) { padding: 16px 24px; font-size: 13px; }
|
|
68
|
+
.api-accordion :deep(.apple-accordion__content) { padding: 0 24px 24px; }
|
|
69
|
+
.example-introduction { padding: 16px 0 8px; }
|
|
70
|
+
.example-introduction h4 { margin: 0 0 6px; font-size: 16px; }
|
|
71
|
+
.example-introduction p { margin: 0; color: var(--apple-secondary); font-size: 13px; line-height: 1.7; }
|
|
72
|
+
.api-notes { padding-left: 18px; margin: 0; font-size: 14px; line-height: 1.85; }
|
|
73
|
+
.api-notes li + li { margin-top: 14px; }
|
|
74
|
+
@media (max-width: 1100px) { .api-summary { grid-template-columns: minmax(0, 180px) minmax(0, 1fr); gap: 20px; } }
|
|
75
|
+
@media (max-width: 720px) {
|
|
76
|
+
.api-summary { grid-template-columns: minmax(0, 1fr); padding: 20px 0; gap: 14px; }
|
|
77
|
+
.api-default { margin-top: 8px; }
|
|
78
|
+
.section-heading h2 { font-size: 20px; }
|
|
79
|
+
.api-accordion :deep(.apple-accordion__content) { padding: 0 20px 20px; }
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="configurable-editor">
|
|
3
|
+
<div class="mode-selector"><AppleTabs v-model="mode" :items="[{ label: '低代码', value: 'controls' }, { label: 'App.vue', value: 'code' }]" label="编辑模式" /><slot name="actions" /></div>
|
|
4
|
+
<div v-show="mode === 'controls'" class="controls-pane"><slot /></div>
|
|
5
|
+
<div v-show="mode === 'code'" class="source-pane"><CodeMirror ref="editor" v-bind="editorAttrs" :value="String(editorAttrs.value ?? '')" :filename="String(editorAttrs.filename ?? 'App.vue')" /></div>
|
|
6
|
+
</div>
|
|
7
|
+
</template>
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import { ref, watch, nextTick } from 'vue'
|
|
10
|
+
import CodeMirror from '@vue/repl/codemirror-editor'
|
|
11
|
+
import { AppleTabs } from 'apptify'
|
|
12
|
+
defineProps<{ editorAttrs: Record<string, unknown> }>()
|
|
13
|
+
const mode = ref('controls')
|
|
14
|
+
const editor = ref<InstanceType<typeof CodeMirror>>()
|
|
15
|
+
watch(mode, async mode => {
|
|
16
|
+
await nextTick()
|
|
17
|
+
if (mode === 'code') editor.value?.getEditorIns<'codemirror'>()?.refresh()
|
|
18
|
+
})
|
|
19
|
+
defineExpose({ getEditorIns: () => editor.value?.getEditorIns() })
|
|
20
|
+
</script>
|
|
21
|
+
<style scoped>
|
|
22
|
+
.configurable-editor { height: 100%; display: flex; flex-direction: column; min-height: 0; background: var(--apple-surface); }
|
|
23
|
+
.mode-selector { box-sizing: border-box; flex: 0 0 var(--header-height, 49px); display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 12px; border-bottom: 1px solid var(--apple-border); }
|
|
24
|
+
.mode-selector :deep(.apple-tabs__list) { border-bottom: 0; }
|
|
25
|
+
.mode-selector :deep(.apple-tabs__viewport) { display: none; }
|
|
26
|
+
.controls-pane { flex: 1; min-height: 0; overflow: auto; padding: 20px; }
|
|
27
|
+
/* Vue REPL resets every descendant button, including the library select trigger. */
|
|
28
|
+
.controls-pane :deep(.apple-select) { border: 1px solid var(--apple-border); background: var(--apple-surface); }
|
|
29
|
+
.controls-pane :deep(.apple-select:hover:where(:not([data-apple-touch] *)):not(:disabled)) { border-color: color-mix(in srgb, var(--apple-secondary) 60%, var(--apple-border)); }
|
|
30
|
+
.controls-pane :deep(.apple-select:active:not(:disabled)) { background: color-mix(in srgb, var(--apple-text) 7%, var(--apple-surface)); }
|
|
31
|
+
.controls-pane :deep(.apple-select:focus) { border-color: var(--apple-accent); }
|
|
32
|
+
.controls-pane :deep(.apple-field.has-error .apple-select) { border-color: var(--apple-danger); }
|
|
33
|
+
.controls-pane :deep(.apple-select:disabled) { border-color: var(--apple-surface-alt); background: var(--apple-surface-alt); cursor: not-allowed; }
|
|
34
|
+
.source-pane { flex: 1; min-height: 0; position: relative; }
|
|
35
|
+
</style>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="editorRoot" class="live-editor" :class="{ dark: theme === 'dark', 'has-configuration': !!slots.configuration }">
|
|
3
|
+
<EditorActions v-if="!slots.configuration" />
|
|
4
|
+
<Repl ref="repl" :store="store" :editor="slots.configuration ? configuredEditor : CodeMirror" layout="horizontal" layout-reverse preview-theme :theme="theme" :show-import-map="false" :show-ts-config="false" :show-compile-output="false" :show-ssr-output="false" :auto-resize="false" :clear-console="false" :preview-options="previewOptions" :editor-options="{ showErrorText: slots.configuration ? false : '显示错误', autoSaveText: false }" />
|
|
5
|
+
<p class="editor-status" role="status">{{ copied ? '已复制当前文件' : '实时预览 · Vue 源码 · 自动编译' }}</p>
|
|
6
|
+
</div>
|
|
7
|
+
</template>
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref, useSlots, watch, nextTick } from 'vue'
|
|
10
|
+
import { File, Repl, useStore } from '@vue/repl'
|
|
11
|
+
import CodeMirror from '@vue/repl/codemirror-editor'
|
|
12
|
+
import ConfigurableEditor from './ConfigurableEditor.vue'
|
|
13
|
+
import { AppleLink, AppleTooltip, useApple } from 'apptify'
|
|
14
|
+
import { Copy, RefreshCw, RotateCcw } from 'lucide-vue-next'
|
|
15
|
+
const props = defineProps<{ files: Record<string, string>; activeFile?: string; resetLabel?: string; baselineFiles?: Record<string, string>; previewSurface?: boolean }>()
|
|
16
|
+
const emit = defineEmits<{ close: []; save: [files: Record<string, string>]; restore: [] }>()
|
|
17
|
+
const slots = useSlots()
|
|
18
|
+
const configuredEditor = defineComponent({
|
|
19
|
+
inheritAttrs: false,
|
|
20
|
+
setup(_, { attrs, expose }) {
|
|
21
|
+
const pane = ref<InstanceType<typeof ConfigurableEditor>>()
|
|
22
|
+
expose({ getEditorIns: () => pane.value?.getEditorIns() })
|
|
23
|
+
return () => h(ConfigurableEditor, { ref: pane, editorAttrs: attrs }, { default: slots.configuration, actions: () => h(EditorActions) })
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
const EditorActions = () => h('div', { class: 'editor-actions', role: 'group', 'aria-label': '示例操作' }, [
|
|
27
|
+
{ label: '重新运行', icon: RefreshCw, click: rerun },
|
|
28
|
+
{ label: props.resetLabel || '恢复示例', icon: RotateCcw, click: restore },
|
|
29
|
+
{ label: '复制当前文件', icon: Copy, click: copyCode },
|
|
30
|
+
].map(action => {
|
|
31
|
+
const button = () => h(AppleLink, {
|
|
32
|
+
as: 'button', iconOnly: !!slots.configuration,
|
|
33
|
+
icon: slots.configuration ? action.icon : undefined,
|
|
34
|
+
label: action.label, onClick: action.click,
|
|
35
|
+
}, { default: () => slots.configuration ? null : action.label })
|
|
36
|
+
return slots.configuration ? h(AppleTooltip, { text: action.label }, { default: button }) : button()
|
|
37
|
+
}))
|
|
38
|
+
const apple = useApple()
|
|
39
|
+
const theme = computed(() => apple.theme.value.current.scheme === 'dark' ? 'dark' : 'light')
|
|
40
|
+
const repl = ref<InstanceType<typeof Repl>>()
|
|
41
|
+
const editorRoot = ref<HTMLElement>()
|
|
42
|
+
const copied = ref(false)
|
|
43
|
+
let copyTimer: ReturnType<typeof setTimeout> | undefined
|
|
44
|
+
const url = (name: string) => new URL(`${import.meta.env.BASE_URL}assets/editor/${name}`, location.origin).href
|
|
45
|
+
const importMap = { imports: { vue: url('vue.js'), apptify: url('runtime.js'), 'lucide-vue-next': url('runtime.js') } }
|
|
46
|
+
const normalize = (name: string) => name.startsWith('src/') ? name : `src/${name}`
|
|
47
|
+
const initialFiles: Record<string, File> = Object.fromEntries(Object.entries(props.files)
|
|
48
|
+
.filter(([name]) => name.endsWith('.vue'))
|
|
49
|
+
.map(([name, code]) => [normalize(name), new File(normalize(name), code)]))
|
|
50
|
+
// REPL reads the map synchronously during useStore/init and after setFiles.
|
|
51
|
+
// Seed it before either call, rather than allowing a missing-map error to linger.
|
|
52
|
+
initialFiles['import-map.json'] = new File('import-map.json', JSON.stringify(importMap), true)
|
|
53
|
+
const store = useStore({
|
|
54
|
+
files: ref(initialFiles), mainFile: ref('src/App.vue'),
|
|
55
|
+
template: ref({ welcomeSFC: props.files['App.vue'] || props.files['src/App.vue'], newSFC: '<template></template>' }),
|
|
56
|
+
activeFilename: ref('src/App.vue'),
|
|
57
|
+
builtinImportMap: ref(importMap), vueVersion: ref(null),
|
|
58
|
+
resourceLinks: ref({ esModuleShims: url('shims.js') }),
|
|
59
|
+
})
|
|
60
|
+
let disposed = false
|
|
61
|
+
let applying = Promise.resolve(true)
|
|
62
|
+
const previewOptions = computed(() => ({
|
|
63
|
+
headHTML: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob: ${location.origin}; style-src 'unsafe-inline' blob: ${location.origin}; connect-src blob: ${location.origin}; img-src data: blob:; font-src data:; form-action 'none'; base-uri 'none'"><link rel="stylesheet" href="${url('runtime.css')}"><script>window.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); parent.postMessage({ action: 'apptify-workshop-close' }, '*') } })<\/script><style>body{margin:0;background:var(${props.previewSurface ? '--apple-surface' : '--apple-bg'});color:var(--apple-text);min-height:100vh}html{min-height:100%}</style>`,
|
|
64
|
+
customCode: { importCode: "import { watchEffect } from 'vue'; import { createAppleUI, themeStyle, resolveMotion } from 'apptify'", useCode: `const ui = createAppleUI(${JSON.stringify({ theme: 'preview', themes: { preview: apple.theme.value.current }, persist: false, motion: apple.motion.value.mode })}); app.use(ui); ui.attach(); document.documentElement.classList.add('apple-provider'); const stopPreviewPreferences = watchEffect(() => { for (const [key, value] of Object.entries(themeStyle(ui))) document.documentElement.style.setProperty(key, value); document.documentElement.style.colorScheme = ui.theme.value.current.scheme; document.documentElement.dataset.appleMotion = resolveMotion('inherit', ui.motion.value.mode, ui.motion.value.reduced); }); app.onUnmount(() => { stopPreviewPreferences(); ui.detach(); });` },
|
|
65
|
+
showRuntimeError: true, showRuntimeWarning: true,
|
|
66
|
+
}))
|
|
67
|
+
function apply(files: Record<string, string>) {
|
|
68
|
+
// Serialize restores so rapid clicks cannot install an older compilation last.
|
|
69
|
+
applying = applying.then(async () => {
|
|
70
|
+
if (disposed) return false
|
|
71
|
+
const editable = Object.keys(store.files).filter(name => name.endsWith('.vue'))
|
|
72
|
+
const incoming = Object.keys(files).filter(name => name.endsWith('.vue'))
|
|
73
|
+
if (editable.length === incoming.length && incoming.every(name => store.files[normalize(name)])) {
|
|
74
|
+
// Editing the existing File lets REPL compile once without rebuilding its iframe.
|
|
75
|
+
for (const name of incoming) store.files[normalize(name)].code = files[name]
|
|
76
|
+
} else {
|
|
77
|
+
await store.setFiles({ ...files, 'import-map.json': JSON.stringify(importMap) }, 'App.vue')
|
|
78
|
+
}
|
|
79
|
+
if (!disposed && props.activeFile && store.files[normalize(props.activeFile)]) store.setActive(normalize(props.activeFile))
|
|
80
|
+
return !disposed
|
|
81
|
+
}).catch(error => { if (!disposed) store.errors = [error instanceof Error ? error : String(error)]; return false })
|
|
82
|
+
return applying
|
|
83
|
+
}
|
|
84
|
+
function rerun() {
|
|
85
|
+
if (!repl.value) return
|
|
86
|
+
try { repl.value.reload(); apple.notify('正在重新运行示例', { tone: 'info' }) }
|
|
87
|
+
catch { apple.notify('重新运行失败,请查看编辑器中的错误', { tone: 'danger' }) }
|
|
88
|
+
}
|
|
89
|
+
async function restore() {
|
|
90
|
+
emit('restore')
|
|
91
|
+
await nextTick()
|
|
92
|
+
const restored = await apply(props.baselineFiles || props.files)
|
|
93
|
+
if (!disposed) apple.notify(restored ? (slots.configuration ? '已恢复默认配置和代码' : '已恢复示例代码') : '恢复失败,请查看编辑器中的错误', { tone: restored ? 'success' : 'danger' })
|
|
94
|
+
}
|
|
95
|
+
watch(() => props.files, () => apply(props.files))
|
|
96
|
+
async function copyCode() {
|
|
97
|
+
try { await navigator.clipboard.writeText(store.activeFile.code); apple.notify('已复制当前文件代码', { tone: 'success' }); copied.value = true; clearTimeout(copyTimer); copyTimer = setTimeout(() => copied.value = false, 2000) }
|
|
98
|
+
catch { apple.notify('无法访问剪贴板,请在编辑器中选择代码复制', { tone: 'warning' }) }
|
|
99
|
+
}
|
|
100
|
+
function previewMessage(event: MessageEvent) {
|
|
101
|
+
const frame = editorRoot.value?.querySelector<HTMLIFrameElement>('.vue-repl iframe')
|
|
102
|
+
if (frame && event.source === frame.contentWindow && event.data?.action === 'apptify-workshop-close') emit('close')
|
|
103
|
+
}
|
|
104
|
+
onMounted(() => {
|
|
105
|
+
// init() compiles the active file and skips mainFile in its remaining loop.
|
|
106
|
+
// Start on App.vue so the preview entry is compiled, then reveal the source.
|
|
107
|
+
if (props.activeFile) store.setActive(normalize(props.activeFile))
|
|
108
|
+
window.addEventListener('message', previewMessage)
|
|
109
|
+
})
|
|
110
|
+
onBeforeUnmount(() => { disposed = true; clearTimeout(copyTimer); window.removeEventListener('message', previewMessage); emit('save', store.getFiles()) })
|
|
111
|
+
</script>
|
|
112
|
+
<style scoped>
|
|
113
|
+
.has-configuration :deep(.vue-repl) { --header-height: 49px; height: 780px; }
|
|
114
|
+
.has-configuration :deep(.tab-buttons button) { height: 100%; display: inline-flex; align-items: center; }
|
|
115
|
+
.has-configuration :deep(.split-pane > .right .file-selector) { display: none; }
|
|
116
|
+
.has-configuration :deep(.split-pane > .right .editor-container) { height: 100%; }
|
|
117
|
+
.editor-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
|
118
|
+
.has-configuration .editor-actions { flex-wrap: nowrap; gap: 2px; margin: 0; }
|
|
119
|
+
.editor-status { margin: 8px 0; color: var(--apple-secondary); font-size: 12px; }
|
|
120
|
+
:deep(.vue-repl) { height: 580px; border: 1px solid var(--apple-separator); border-radius: 12px; overflow: hidden; }
|
|
121
|
+
|
|
122
|
+
@media (max-width: 720px) {
|
|
123
|
+
:deep(.vue-repl) { height: 740px; }
|
|
124
|
+
.has-configuration :deep(.vue-repl) { height: 1200px; }
|
|
125
|
+
.live-editor:not(.has-configuration) .editor-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
|
|
126
|
+
.editor-actions :deep(button) { padding-inline: 8px; font-size: 12px; min-height: 36px; }
|
|
127
|
+
:deep(.split-pane) { display: flex !important; flex-direction: column !important; }
|
|
128
|
+
:deep(.split-pane > .left), :deep(.split-pane > .right) { position: relative !important; inset: auto !important; z-index: 0 !important; pointer-events: auto !important; display: block !important; height: 50% !important; width: 100% !important; }
|
|
129
|
+
:deep(.split-pane > .left) { height: 55% !important; }
|
|
130
|
+
:deep(.split-pane > .right) { height: 45% !important; }
|
|
131
|
+
:deep(.split-pane .toggler) { display: none !important; }
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Generated by tooling/prepare-input-source.mjs; do not edit the snapshot by hand.
|
|
2
|
+
export default "<!-- AppleInput 同源实现:src/components/forms.ts + src/styles/forms.css。\n只移除 TypeScript 类型并转接浏览器公开依赖,保留 Options API/render 实现。 -->\n<script>\nimport { defineComponent, h, useId } from 'vue';\nimport { ripple } from 'apptify';\nimport { Eye, EyeOff, LoaderCircle, X } from 'lucide-vue-next';\nconst fieldProps = {\n label: { type: String, default: '' },\n hint: { type: String, default: '' },\n error: { type: String, default: '' },\n disabled: Boolean,\n loading: Boolean,\n required: Boolean,\n motion: { type: String, default: 'inherit' },\n};\nfunction fieldId(vm) { return String(vm.$attrs.id || vm.inputId); }\nfunction controlAttrs(vm, hasLabel = !!vm.label) {\n return {\n ...vm.$attrs,\n id: fieldId(vm),\n disabled: vm.disabled || vm.loading,\n required: vm.required,\n 'aria-label': vm.$attrs['aria-label'] || vm.label || undefined,\n 'aria-labelledby': vm.$attrs['aria-labelledby'] || (!vm.$attrs['aria-label'] && hasLabel ? `${fieldId(vm)}-label` : undefined),\n 'aria-invalid': vm.error ? true : undefined,\n 'aria-busy': vm.loading || undefined,\n 'aria-describedby': [vm.$attrs['aria-describedby'], (vm.error || vm.hint) ? `${fieldId(vm)}-message` : ''].filter(Boolean).join(' ') || undefined,\n };\n}\nfunction field(vm, control, classes = '', error = vm.error) {\n return h('div', {\n class: ['apple-field', classes, { 'is-disabled': vm.disabled, 'has-error': !!error }],\n 'data-apple-motion': vm.motion,\n }, [\n vm.label ? h('span', { class: 'apple-field__label', id: `${fieldId(vm)}-label` }, [vm.label, vm.required ? h('span', { 'aria-hidden': true, class: 'apple-field__required' }, ' *') : null]) : null,\n control,\n error || vm.hint ? h('p', { id: `${fieldId(vm)}-message`, class: ['apple-field__message', { 'is-error': !!error }], role: error ? 'alert' : undefined }, error || vm.hint) : null,\n ]);\n}\nfunction spinner() { return h(LoaderCircle, { size: 18, class: 'apple-control__spinner', 'aria-hidden': true }); }\nfunction stringValue(event) { return event.target.value; }\nexport const AppleInput = defineComponent({\n name: 'AppleInput', inheritAttrs: false,\n props: { ...fieldProps, modelValue: { type: [String, Number], default: '' }, type: { type: String, default: 'text' }, placeholder: String, clearable: Boolean },\n emits: ['update:modelValue', 'change', 'clear'],\n data() { return { inputId: useId(), passwordVisible: false }; },\n methods: { focus() { this.$refs.input?.focus(); } },\n render() {\n const inactive = this.disabled || this.loading;\n return field(this, h('div', { class: 'apple-input-wrap' }, [\n this.$slots.prefix ? h('span', { class: 'apple-input__affix' }, this.$slots.prefix()) : null,\n h('input', { ...controlAttrs(this), ref: 'input', class: ['apple-control', this.$attrs.class], type: this.type === 'password' && this.passwordVisible ? 'text' : this.type, placeholder: this.placeholder, value: this.modelValue,\n onInput: (event) => this.$emit('update:modelValue', stringValue(event)), onChange: (event) => this.$emit('change', stringValue(event)),\n }),\n this.loading ? spinner() : null,\n !inactive && this.clearable && String(this.modelValue).length ? ripple(h('button', { type: 'button', class: 'apple-field__icon', 'aria-label': '清空', title: '清空', onClick: () => { this.$emit('update:modelValue', ''); this.$emit('clear'); this.focus(); } }, h(X, { size: 16 }))) : null,\n !inactive && this.type === 'password' ? ripple(h('button', { type: 'button', class: 'apple-field__icon', 'aria-label': this.passwordVisible ? '隐藏密码' : '显示密码', title: this.passwordVisible ? '隐藏密码' : '显示密码', 'aria-pressed': this.passwordVisible, onClick: () => { this.passwordVisible = !this.passwordVisible; } }, h(this.passwordVisible ? EyeOff : Eye, { size: 18 }))) : null,\n this.$slots.suffix ? h('span', { class: 'apple-input__affix' }, this.$slots.suffix()) : null,\n ]));\n },\n});\nexport default AppleInput;\n</script>\n\n<style>\n.apple-field { display: flex; flex-direction: column; gap: 8px; min-width: 0; color: var(--apple-text); font: inherit; }\n.apple-field__label { font-size: 14px; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; }\n.apple-field__required, .apple-field__message.is-error { color: var(--apple-danger); }\n.apple-field__message { margin: 0; color: var(--apple-secondary); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }\n.apple-field__counter { align-self: flex-end; color: var(--apple-secondary); font-size: 12px; font-variant-numeric: tabular-nums; }\n.apple-control { box-sizing: border-box; min-width: 0; width: 100%; min-height: 48px; border: 1px solid var(--apple-border); border-radius: 8px; padding: 11px 14px; background: var(--apple-surface); color: var(--apple-text); font: inherit; font-size: 16px; line-height: 1.5; transition: border-color var(--apple-duration-fast, 120ms) ease, box-shadow var(--apple-duration-fast, 120ms) ease, background-color var(--apple-duration-fast, 120ms) ease, color var(--apple-duration-fast, 120ms) ease; }\n.apple-field .apple-control { font-size: 16px; }\n.apple-control::placeholder { color: var(--apple-secondary); opacity: 1; }\n.apple-control:hover:where(:not([data-apple-touch] *)):not(:disabled) { border-color: color-mix(in srgb, var(--apple-secondary) 60%, var(--apple-border)); }\n.apple-control:focus { border-color: var(--apple-accent); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--apple-accent) 20%, transparent); }\n.apple-field.has-error .apple-control, .apple-field.has-error .apple-input-wrap { border-color: var(--apple-danger); }\n.apple-control:disabled, .apple-choice:has(input:disabled), .apple-switch-row:has(input:disabled), .apple-field .is-disabled { opacity: .5; cursor: not-allowed; }\n.apple-field .apple-control:disabled { background: var(--apple-surface-alt); border-color: var(--apple-surface-alt); }\n.apple-input-wrap { display: flex; align-items: center; min-width: 0; border: 1px solid var(--apple-border); border-radius: 8px; background: var(--apple-surface); transition: border-color var(--apple-duration-fast, 120ms) ease, box-shadow var(--apple-duration-fast, 120ms) ease, background-color var(--apple-duration-fast, 120ms) ease; }\n.apple-input-wrap:hover:where(:not([data-apple-touch] *)):not(:has(input:disabled)) { border-color: color-mix(in srgb, var(--apple-secondary) 60%, var(--apple-border)); }\n.apple-input-wrap:focus-within { border-color: var(--apple-accent); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--apple-accent) 20%, transparent); }\n.apple-input-wrap > .apple-control, .apple-input-wrap > .apple-control:focus, .apple-input-wrap > .apple-control:focus-visible { border: 0; background: transparent; box-shadow: none; outline: none; }\n.apple-input-wrap > .apple-control:disabled { background: transparent; }\n.apple-field .apple-input-wrap:has(input:disabled) { background: var(--apple-surface-alt); border-color: var(--apple-surface-alt); }\n.apple-input__affix { display: inline-flex; align-items: center; flex-shrink: 0; max-width: 35%; padding-inline: 12px; color: var(--apple-secondary); overflow-wrap: anywhere; }\n.apple-input-wrap > .apple-input__affix:has(button) { align-self: stretch; align-items: stretch; gap: 0; max-width: none; padding: 0; }\n.apple-input-wrap > .apple-input__affix button { align-self: stretch; box-sizing: border-box; min-height: 44px; padding: 7px 12px; border-width: 1px; border-style: solid; border-radius: 0; line-height: 1.4; }\n.apple-input-wrap > .apple-input__affix button:not(.apple-button) { border-color: transparent; }\n.apple-input-wrap > .apple-input__affix .apple-button--icon, .apple-input-wrap > .apple-input__affix .apple-field__icon { width: 44px; height: auto; padding: 0; }\n.apple-input-wrap > .apple-field__icon { align-self: stretch; height: auto; min-height: 44px; padding: 0; border-radius: 0; }\n.apple-input-wrap > .apple-field__icon:first-child, .apple-input-wrap > .apple-input__affix:first-child > button:first-child { border-radius: 7px 0 0 7px; }\n.apple-input-wrap > .apple-field__icon:last-child, .apple-input-wrap > .apple-input__affix:last-child > button:last-child { border-radius: 0 7px 7px 0; }\n.apple-input__affix + .apple-control { padding-inline-start: 0; }\n.apple-field__icon { position: relative; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 44px; height: 44px; border: 0; border-radius: 6px; color: var(--apple-secondary); background: transparent; cursor: pointer; transition: background-color var(--apple-duration-fast, 120ms) ease, color var(--apple-duration-fast, 120ms) ease; }\n.apple-field__icon:hover:where(:not([data-apple-touch] *)):not(:disabled) { color: var(--apple-text); background: var(--apple-surface-alt); }\n.apple-field__icon:active:not(:disabled) { background: color-mix(in srgb, var(--apple-text) 15%, var(--apple-surface)); }\n.apple-field__icon:focus-visible, .apple-stepper__button:focus-visible, .apple-rate__star:focus-visible { outline: 3px solid var(--apple-accent); outline-offset: 2px; }\n.apple-field__icon:disabled, .apple-stepper__button:disabled { opacity: .4; cursor: not-allowed; }\n.apple-control__spinner { flex-shrink: 0; margin-inline: 12px; color: var(--apple-accent); animation: apple-control-spin .9s linear infinite; }\n</style>\n"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ComponentDocument, ApiDefinition } from './contract'
|
|
2
|
+
const definitions = {
|
|
3
|
+
name: 'AppleInput', source: 'src/components/forms.ts:21–89',
|
|
4
|
+
props: [
|
|
5
|
+
{ name: 'modelValue / v-model', type: 'string | number', default: "''", description: '输入框里显示的内容,用 v-model 绑定。初始值可以是文字或数字;用户输入后,得到的值都是字符串。例如输入 123,得到的是 "123",不是数字 123。清空后得到空字符串 ""。' },
|
|
6
|
+
{ name: 'type', type: 'string', default: "'text'", description: '设置输入类型,例如 text(普通文字)、password(密码)、number(数字)。密码类型会显示“显示密码”按钮,点击后可以查看或隐藏内容,输入的值不会改变。数字类型仍返回字符串。' },
|
|
7
|
+
{ name: 'placeholder', type: 'string', default: 'undefined', description: '输入框为空时显示的提示文字,开始输入后消失。例如“请输入姓名”。它不能代替字段名称,建议同时设置 label。' },
|
|
8
|
+
{ name: 'clearable', type: 'boolean', default: 'false', description: '设为 true 后,输入框有内容时会显示清空按钮,值为数字 0 时也会显示。点击按钮会清空内容、触发 clear 事件,并让光标回到输入框。禁用或加载时会隐藏清空按钮。' },
|
|
9
|
+
{ name: 'label', type: 'string', default: "''", description: '输入框上方的字段名称,例如“姓名”。字段名称只用于说明,点击输入框本身可进入编辑。屏幕阅读器默认使用它介绍这个字段;可以用 aria-label 单独设置朗读名称。' },
|
|
10
|
+
{ name: 'hint', type: 'string', default: "''", description: '输入框下方的帮助文字,例如“最多输入 20 个字”。如果设置了 error,会优先显示错误信息,暂时隐藏这段帮助。屏幕阅读器也能读到这段说明。' },
|
|
11
|
+
{ name: 'error', type: 'string', default: "''", description: '输入框下方的错误信息,例如“请输入姓名”,同时显示错误样式。把它设为空字符串就能恢复正常。组件只显示你传入的错误,不会自己检查内容,也不会阻止提交。' },
|
|
12
|
+
{ name: 'disabled', type: 'boolean', default: 'false', description: '设为 true 后,输入框不能编辑,清空和密码按钮也会隐藏。按 Tab 会跳过这个字段,提交 HTML 表单时也不会包含这个字段。' },
|
|
13
|
+
{ name: 'loading', type: 'boolean', default: 'false', description: '设为 true 后显示加载图标,表示正在处理。此时不能输入,清空和密码按钮会隐藏,只保留加载图标。处理结束后设为 false,就能继续操作。' },
|
|
14
|
+
{ name: 'required', type: 'boolean', default: 'false', description: '设为 true 后,字段名称旁显示星号。放在 HTML 表单中时,浏览器会检查是否填写;为空就不能正常提交。它不会自动设置 error,也不会生成自定义错误信息。' },
|
|
15
|
+
{ name: 'motion', type: "'inherit' | 'auto' | 'full' | 'reduced' | 'none'", default: "'inherit'", description: '设置这个输入框的动效偏好。默认 inherit,跟随外层设置;none 表示不使用动效,reduced 表示减少动效。这个设置不会改变输入内容或校验结果。' },
|
|
16
|
+
],
|
|
17
|
+
events: [
|
|
18
|
+
{ name: 'update:modelValue', type: '(value: string) => void', description: '输入内容变化时触发,事件参数就是最新的文字;点击清空时,参数是空字符串。使用 v-model 时通常不用手动处理。即使 type 是 number,参数也仍是字符串,需要数字时自行转换。' },
|
|
19
|
+
{ name: 'change', type: '(value: string) => void', description: '修改内容后,把光标移出输入框时通常会触发,事件参数是当前文字。它不会像 update:modelValue 那样每输入一个字就触发。点击组件的清空按钮不会主动触发它。' },
|
|
20
|
+
{ name: 'clear', type: '() => void', description: '点击清空按钮时触发,没有事件参数。触发前,组件已经发出清空内容的更新事件;随后会让光标回到输入框。可以用它处理清空后的操作。' },
|
|
21
|
+
],
|
|
22
|
+
slots: [
|
|
23
|
+
{ name: 'prefix', type: '() => VNode[](无 slot props)', description: '在输入框内容的左侧放自定义内容,例如 @ 符号或图标。插槽不会提供额外数据。如果放的是按钮,请给按钮设置清楚的名称;纯装饰图标可以用 aria-hidden 隐藏朗读。' },
|
|
24
|
+
{ name: 'suffix', type: '() => VNode[](无 slot props)', description: '在输入框右侧放自定义内容,例如单位、图标或操作按钮。它位于组件自带的加载、清空、密码按钮之后。插槽不会提供额外数据;直接放在组件里的内容不会显示,需使用具名插槽。' },
|
|
25
|
+
],
|
|
26
|
+
methods: [{ name: 'focus()', type: '() => void', description: '让光标进入输入框。先用 ref 获取组件,再调用 input.value.focus()。例如点击“编辑姓名”后自动聚焦。输入框被禁用时无法聚焦;组件没有提供 blur() 或 select() 方法。' }],
|
|
27
|
+
notes: [
|
|
28
|
+
'常见 HTML 输入属性可以直接使用,例如 maxlength 限制长度、autocomplete 自动填充。@focus、@blur、@keydown 也可以直接监听。这些沿用浏览器的行为,不是组件另设的接口。class 和 style 应用在内部输入框上。不要使用 readable。',
|
|
29
|
+
'不传 id 时,组件会自动生成,保证名称和输入框正确关联。帮助、错误、加载状态也会告知屏幕阅读器;这些状态跟随 hint、error、loading、required 和 disabled。',
|
|
30
|
+
'可以用 Tab 切换焦点,用键盘选中文字、复制和操作按钮。密码按钮会提示当前是显示还是隐藏;清空后光标回到输入框。如果不显示 label,请设置 aria-label 或 aria-labelledby,让屏幕阅读器知道字段名称。',
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function entries(group: string, definitions: ApiDefinition[]) {
|
|
35
|
+
return definitions.map(entry => {
|
|
36
|
+
const id = entry.name === 'modelValue / v-model' ? 'model-value'
|
|
37
|
+
: entry.name === 'update:modelValue' ? 'update-model-value'
|
|
38
|
+
: entry.name === 'focus()' ? 'focus' : entry.name
|
|
39
|
+
return { ...entry, id: `${group}-${id}` }
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
export default {
|
|
43
|
+
...definitions,
|
|
44
|
+
props: entries('prop', definitions.props),
|
|
45
|
+
events: entries('event', definitions.events),
|
|
46
|
+
slots: entries('slot', definitions.slots),
|
|
47
|
+
methods: entries('method', definitions.methods),
|
|
48
|
+
} satisfies ComponentDocument
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Public API descriptions are required; component-specific runnable examples are optional. */
|
|
2
|
+
export interface VueExample { title: string; description: string; code: string }
|
|
3
|
+
export interface ApiDefinition { name: string; type: string; default?: string; description: string }
|
|
4
|
+
export interface ApiEntry extends ApiDefinition { id: string; example?: VueExample }
|
|
5
|
+
export interface ComponentDocument {
|
|
6
|
+
name: string
|
|
7
|
+
source: string
|
|
8
|
+
props: ApiEntry[]
|
|
9
|
+
events: ApiEntry[]
|
|
10
|
+
slots: ApiEntry[]
|
|
11
|
+
methods: ApiEntry[]
|
|
12
|
+
notes: string[]
|
|
13
|
+
implementation?: { files: Record<string, string>; activeFile: string; description: string }
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineAsyncComponent, type Component } from 'vue'
|
|
2
|
+
import type { ComponentDocument } from './contract'
|
|
3
|
+
/** Lightweight route directory. Each document is downloaded only on its own page. */
|
|
4
|
+
export interface DocumentRegistration {
|
|
5
|
+
name: string
|
|
6
|
+
label: string
|
|
7
|
+
description: string
|
|
8
|
+
group: string
|
|
9
|
+
preview?: Component
|
|
10
|
+
load: () => Promise<{ default: ComponentDocument }>
|
|
11
|
+
}
|
|
12
|
+
export const componentDocuments: Record<string, DocumentRegistration> = {
|
|
13
|
+
'apple-input': {
|
|
14
|
+
name: 'AppleInput', label: '输入框', group: '表单',
|
|
15
|
+
description: '从输入、反馈到辅助操作,逐条了解输入框的行为与接口。',
|
|
16
|
+
preview: defineAsyncComponent(() => import('./AppleInputPreview.vue')),
|
|
17
|
+
load: () => import('./apple-input'),
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
export const componentDocumentPath = (slug: string) => `/component-docs/${slug}`
|