本页由 3 个真实组件拼成;提示词与源码都可直接复制带走。
复现「账户设置」整页场景(/showcase/demo/page-settings):
- 页面级规则按 /design#pages 的对应小节执行,不在此复述
- 本页由这些真实组件拼成(手写等价物不算):
- 玻璃输入框(Floating Label Input):标签在框上方,不做浮动标签动画|/showcase/component/input/glass-apple
- 玻璃滑块(UISlider):role=slider + aria-valuenow/min/max,← → 单步、PageUp/Down 十步、Home/End 到端|/showcase/component/slider/glass-apple
- 玻璃开关(iOS UISwitch):role=switch + aria-checked,Space / Enter 切换|/showcase/component/switch/glass-apple
- 骨架:视口高度固定,页头不滚,正文在容器内滚;分区页的页头与正文共用同一条内容列
清单是扫 src/pages/showcase/demo/page-settings.astro 的 import 闭包得出的,
手写等价物不会出现在这里。
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import DemoChrome from '../../../components/DemoChrome.astro';
import PageHeader from '../../../components/PageHeader.astro';
import PageShell from '../../../components/PageShell.astro';
import SceneDocLink from '../../../components/SceneDocLink.astro';
import { GlassAppleSwitch } from '../../../components/switch/glass-apple/Switch';
import { GlassAppleSlider } from '../../../components/slider/glass-apple/Slider';
import SettingsProfile from './SettingsProfile.tsx';
import data from '../../../mocks/page-settings.json';
import type { SettingsSection } from '../../../mocks/types';
const { profile, sections } = data as {
profile: { title: string; fields: { label: string; type: string; placeholder: string }[] };
sections: SettingsSection[];
};
/** 左栏分组导航:id 与右栏每个 fieldset 一一对应 */
const groups = [
{ id: 'settings-profile', title: profile.title },
...sections.map((sec, i) => ({ id: `settings-${i}`, title: sec.title })),
];
const panel = 'rounded-2xl bg-[var(--color-surface)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)]';
const groupTitle = 'w-full text-sm font-medium text-[var(--color-text-muted)] uppercase tracking-[0.12em] mb-3 lg:sr-only';
---
<BaseLayout title="UI Echo · 设置页复现">
<DemoChrome />
<PageShell demo col>
<PageHeader
slot="header"
title="设置"
description="管理账户、安全、通知和隐私选项。"
size="sm"
>
<SceneDocLink id="page-settings" slot="actions" />
</PageHeader>
<!-- 分区页:左栏分组导航 + 右栏卡片列(Polaris app-settings 的 2fr / 5fr)。
导航吸顶靠 ul 上的 sticky:nav 这个网格项被拉伸到整行高,sticky 才有可滑动的行程。 -->
<div class="lg:grid lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-10">
<nav aria-label="设置分组" class="hidden lg:block">
<ul class="sticky top-0 space-y-1">
{groups.map((g) => (
<li>
<a
href={`#${g.id}`}
data-scene-nav={g.id}
class="block px-3 py-2 rounded-lg text-sm text-[var(--color-text-muted)] transition-colors hover:bg-[var(--glass-recess)] hover:text-[var(--color-text)] aria-[current=true]:bg-[var(--glass-recess)] aria-[current=true]:text-[var(--color-text)] aria-[current=true]:font-medium"
>
{g.title}
</a>
</li>
))}
</ul>
</nav>
<div class="min-w-0">
<SettingsProfile id={groups[0].id} title={profile.title} fields={profile.fields} client:visible />
{sections.map((sec, i) => (
<fieldset id={groups[i + 1].id} tabindex="-1" class="mb-8 min-w-0 scroll-mt-4 border-0 p-0 m-0 focus-visible:outline-none">
<legend class={groupTitle}>{sec.title}</legend>
<div class={`${panel} overflow-hidden`}>
{sec.rows.map((row) => (
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-[var(--glass-border)] last:border-b-0">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{row.label}</div>
<div class="text-xs text-[var(--color-text-muted)] mt-0.5">{row.desc}</div>
</div>
{row.toggle && (
<GlassAppleSwitch
defaultChecked={row.defaultOn}
aria-label={row.label}
client:visible
/>
)}
{row.range && (
<GlassAppleSlider
className="w-40 shrink-0"
defaultValue={row.defaultValue}
unit={row.unit}
showValue
aria-label={row.label}
client:visible
/>
)}
{row.cta && (
<button class={`
press-feedback shrink-0 px-3 py-1.5 rounded-lg text-xs
border transition-colors cursor-pointer
${row.danger
? 'text-[var(--color-warning-fg)] border-[var(--color-warning-bd)] hover:bg-[var(--color-warning-bg)]'
: 'text-[var(--color-text-muted)] border-[var(--glass-border)] bg-[var(--color-surface)] hover:text-[var(--color-text)]'}
`}>
{row.cta}
</button>
)}
</div>
))}
</div>
</fieldset>
))}
</div>
</div>
</PageShell>
</BaseLayout>
<script>
/** 滚动联动:吸顶的导航得说出"你在哪一组",否则固定住也没有意义。
* 判定用"最后一个越过顶线的分组"——分组本身很高,用可见带相交会一直卡在上一组。 */
const links = [...document.querySelectorAll<HTMLElement>('[data-scene-nav]')];
const scrollRoot = document.querySelector<HTMLElement>('.page-scroll');
const sections = links
.map((a) => document.getElementById(a.dataset.sceneNav!))
.filter((el): el is HTMLElement => Boolean(el));
const setCurrent = (id: string | null) => {
for (const a of links) {
if (a.dataset.sceneNav === id) a.setAttribute('aria-current', 'true');
else a.removeAttribute('aria-current');
}
};
const update = () => {
if (!sections.length) return;
// 滚到底时最后一组的顶线永远到不了上方——到底就算它,否则导航会卡在倒数第二组
const atBottom = scrollRoot
? scrollRoot.scrollTop + scrollRoot.clientHeight >= scrollRoot.scrollHeight - 2
: window.innerHeight >= document.documentElement.scrollHeight - 2;
if (atBottom) {
setCurrent(sections[sections.length - 1].id);
return;
}
const line = (scrollRoot?.getBoundingClientRect().top ?? 0) + 96;
let current = sections[0];
for (const s of sections) if (s.getBoundingClientRect().top <= line) current = s;
setCurrent(current.id);
};
scrollRoot?.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
update();
// hash 跳转不移焦点,键盘用户会被丢回页首:跳完把焦点送到该分组
for (const a of links) {
a.addEventListener('click', () => {
const target = sections.find((s) => s.id === a.dataset.sceneNav);
if (!target) return;
setCurrent(target.id);
window.setTimeout(() => target.focus({ preventScroll: true }), 80);
});
}
</script>
import { useState } from 'react';
import { GlassAppleInput } from '../../../components/input/glass-apple/Input';
interface Field {
label: string;
type: string;
placeholder: string;
}
const panel = 'rounded-2xl bg-[var(--color-surface)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)]';
/** 文本编辑显式保存;开关与滑块仍即时生效(见 docs/standards/pages.md §4) */
export default function SettingsProfile({ id, title, fields }: { id: string; title: string; fields: Field[] }) {
const initial = fields.map(() => '');
const [values, setValues] = useState(initial);
const [saved, setSaved] = useState(initial);
const [message, setMessage] = useState('');
const dirty = values.some((v, i) => v !== saved[i]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
setSaved(values);
setMessage('已保存');
};
const discard = () => {
setValues(saved);
setMessage('已放弃更改');
};
return (
<form onSubmit={submit}>
<fieldset id={id} tabIndex={-1} className="mb-8 min-w-0 scroll-mt-4 border-0 p-0 m-0 focus-visible:outline-none">
<legend className="w-full text-sm font-medium text-[var(--color-text-muted)] uppercase tracking-[0.12em] mb-3 lg:sr-only">
{title}
</legend>
<div className={`${panel} p-6`}>
{/* 宽卡里两个字段并排,避免卡片右侧空出一大片 */}
<div className="grid gap-4 lg:grid-cols-2">
{fields.map((f, i) => (
<GlassAppleInput
key={f.label}
id={`settings-field-${i}`}
label={f.label}
type={f.type}
placeholder={f.placeholder}
value={values[i]}
onChange={(e) =>
setValues((prev) => prev.map((v, j) => (j === i ? e.target.value : v)))
}
/>
))}
</div>
{(dirty || message) && (
<div className="flex flex-wrap items-center justify-between gap-3 mt-5">
<p aria-live="polite" className="text-xs text-[var(--color-text-subtle)]">
{dirty ? '有未保存的更改' : message}
</p>
{dirty && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={discard}
className="press-feedback px-3 py-1.5 rounded-lg text-xs border border-[var(--glass-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors cursor-pointer"
>
放弃
</button>
<button
type="submit"
className="press-feedback press-hover px-4 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-accent)] text-white cursor-pointer"
>
保存
</button>
</div>
)}
</div>
)}
</div>
</fieldset>
</form>
);
}
页面级规则(页头、工具栏、表格、设置、详情、流程、状态)只写在 页面模式 一处,这里不复述——复述就会漂移。