diff --git a/.scratch/mobile-settings-m3-expressive/spec.md b/.scratch/mobile-settings-m3-expressive/spec.md new file mode 100644 index 00000000..80553a7a --- /dev/null +++ b/.scratch/mobile-settings-m3-expressive/spec.md @@ -0,0 +1,40 @@ +# 窄屏设置:Material 3 Expressive 分段列表 + +Status: implemented — awaiting device acceptance + +在 `.scratch/mobile-settings-material/spec.md`(换掉 iOS 的图标、`›`、✓、顶栏)之后,窄屏设置的骨架仍是 iOS 的 inset grouped 列表,整体仍有「iPhone 设置」味。本次把列表骨架换成 Android 16 系统设置(Material 3 Expressive)的分段写法。导航栈、返回行为与桌面设置弹窗不变。 + +## Problem Statement + +- 灰底 + 白色圆角卡片 + 卡片内行间分隔线:iOS inset grouped 的标志性外观。 +- 当前值以灰字放在行尾(「外观 …… 浅色」):iOS detail 样式;Android 写在标题下方。 +- 开关关闭 / 打开时滑块等大、白色带阴影:iOS 开关的样子。 +- 首页账户卡片的大头像 + 大字号名称,接近 Apple ID 栏。 + +## Solution + +| 元素 | 之前 | 现在 | +|---|---|---| +| 分组 | 一张圆角卡片,行间分隔线 | 每行一块独立圆角块,行间 2px 空隙;首行上圆角 / 末行下圆角 24px,其余 4px;无分隔线 | +| 当前值(`SettingsRow.value`) | 行尾灰字 | 标题下方第二行灰字(summary) | +| 开关(窄屏) | 主题色 / 灰轨道,24px 白滑块带阴影 | 关闭:描边轨道 + 16px 灰滑块;打开:主题色轨道 + 24px 滑块;无阴影 | +| 首页账户行 | 48px 头像、18px 名称 | 40px 头像、16px 名称 | + +分组小标题、footer 说明、单选圆点、顶栏、页面底色沿用上一版。 + +## Implementation Decisions + +- 分段样式放在 `SettingsGroup` 的行容器上,用 `[&>*]` 作用于直接子元素:行本身或包裹层(如提醒可靠性的 `data-testid` 包裹 div)都能成块,各设置页无需改动。 +- `SettingsGroup` 新增 `role`:`SettingsOptionGroup` 与时区列表把 `radiogroup` 放在行容器上,选项按钮因此是直接子元素、各自成块。 +- 开关的 Material 3 尺寸 / 颜色全部在 `max-md:` 下,桌面保持 36×20 原样。 + +## Testing Decisions + +- 既有设置测试(role / aria、导航栈、Escape)不变,全部通过。 +- 390px 截图验收:首页、通用、外观、关于;亮 / 暗;开关开 / 关。 + +## Out of Scope + +- 按下波纹(ripple)、开关按下时滑块放大。 +- 把 footer 说明并入行内副标题。 +- 主色(接近 iOS 系统蓝)调整:属全局 token。 diff --git a/packages/ui/src/components/settings/MobileSettings.tsx b/packages/ui/src/components/settings/MobileSettings.tsx index b2551dcb..d2455b93 100644 --- a/packages/ui/src/components/settings/MobileSettings.tsx +++ b/packages/ui/src/components/settings/MobileSettings.tsx @@ -96,15 +96,15 @@ function SettingsRoot({ open }: { open: (tab: SettingsTab) => void }) { open('account')} leading={ - + {user?.avatarUrl ? ( - + ) : ( initial )} } - label={{name}} + label={{name}} description={user?.displayName ? user.email : t('settings:account')} /> @@ -161,7 +161,7 @@ function SettingsRoot({ open }: { open: (tab: SettingsTab) => void }) { } /** - * 窄屏设置(iOS 设置 / Android 系统设置惯例):分类首页 → 推入详情页 → + * 窄屏设置(Android 系统设置惯例):分类首页 → 推入详情页 → * 推入选项页的页面栈。系统返回 / Escape 逐级后退,在首页才关闭设置。 * 设置页经 SettingsNavProvider 感知自己处于栈内,渲染列表单元格形态。 */ @@ -250,11 +250,7 @@ export function MobileSettingsContent() { )} > }> - {entry ? ( - entry.render() - ) : ( - nav.push(tabEntry(tab))} /> - )} + {entry ? entry.render() : nav.push(tabEntry(tab))} />} ); diff --git a/packages/ui/src/components/settings/SettingsGeneral.tsx b/packages/ui/src/components/settings/SettingsGeneral.tsx index 745d23a7..d8191b7d 100644 --- a/packages/ui/src/components/settings/SettingsGeneral.tsx +++ b/packages/ui/src/components/settings/SettingsGeneral.tsx @@ -94,7 +94,7 @@ function TimeZonePicker({ aria-label={t('timeZoneSearch')} className="h-12 w-full rounded-full bg-card px-5 text-base outline-none placeholder:text-muted-foreground" /> - + 0 ? 'radiogroup' : undefined}> {filtered.length === 0 ? ( {t('noMatches')}} /> ) : ( diff --git a/packages/ui/src/components/settings/SettingsList.tsx b/packages/ui/src/components/settings/SettingsList.tsx index b1a071f5..ea20998e 100644 --- a/packages/ui/src/components/settings/SettingsList.tsx +++ b/packages/ui/src/components/settings/SettingsList.tsx @@ -31,29 +31,35 @@ export function useSettingsNav(): SettingsNav | null { /* ───────────────────────── 列表基础组件 ───────────────────────── */ /* - * 窄屏设置采用 Material(Android 系统设置)写法:主题色分组小标题、单色图标、 - * 行首单选圆点、16px 正文、无 `›`、危险 / 主操作行左对齐。分组仍为圆角卡片 - * (Android 12+ 系统设置同样使用分组容器)。 + * 窄屏设置采用 Material 3 Expressive(Android 16 系统设置)写法:分组内每行是 + * 一块独立圆角块、行间 2px 空隙(首尾行大圆角、中间行小圆角,无分隔线); + * 当前值作为标题下方的第二行;主题色分组小标题、单色图标、行首单选圆点、 + * 16px 正文、无 `›`、危险 / 主操作行左对齐。 */ +/** 分组内各行的分段块样式:作用于直接子元素(行本身或包裹层),首尾行大圆角。 */ +const TILES_CLASS = + 'flex flex-col gap-0.5 [&>*]:overflow-hidden [&>*]:rounded-[4px] [&>*]:bg-card [&>*:first-child]:rounded-t-3xl [&>*:last-child]:rounded-b-3xl'; + /** 分组:可选的小标题(header,主题色)与分组下方说明(footer)。 */ export function SettingsGroup({ header, footer, + role, children, className, }: { header?: React.ReactNode; footer?: React.ReactNode; + /** 行容器的 role(如单选组的 radiogroup)。 */ + role?: React.AriaRole; children: React.ReactNode; className?: string; }) { return (
- {header && ( -

{header}

- )} -
+ {header &&

{header}

} +
{children}
{footer &&

{footer}

} @@ -87,7 +93,7 @@ interface SettingsRowProps { leading?: React.ReactNode; /** 行下方的补充说明(单行内的小字)。 */ description?: React.ReactNode; - /** 右侧当前值(灰字)。 */ + /** 当前值:标题下方的第二行灰字(Material summary)。 */ value?: React.ReactNode; /** 右侧行内控件(开关、按钮等);有 control 时行本身不可点击。 */ control?: React.ReactNode; @@ -119,11 +125,7 @@ export function SettingsRow({ submit = false, htmlFor, }: SettingsRowProps) { - const labelNode = htmlFor ? ( - - ) : ( - {label} - ); + const labelNode = htmlFor ? : {label}; const body = ( <> {leading} @@ -135,13 +137,13 @@ export function SettingsRow({ )} > {labelNode} + {value !== undefined && ( + {value} + )} {description && ( {description} )} - {value !== undefined && ( - {value} - )} {control} ); @@ -200,25 +202,23 @@ export function SettingsOptionGroup({ onChange: (value: T) => void; }) { return ( - -
- {options.map((opt) => { - const selected = opt.value === value; - return ( - - ); - })} -
+ + {options.map((opt) => { + const selected = opt.value === value; + return ( + + ); + })} ); } diff --git a/packages/ui/src/components/ui/switch.tsx b/packages/ui/src/components/ui/switch.tsx index 71f1abdd..11ef8b5a 100644 --- a/packages/ui/src/components/ui/switch.tsx +++ b/packages/ui/src/components/ui/switch.tsx @@ -9,8 +9,9 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => (