Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .scratch/mobile-settings-m3-expressive/spec.md
Original file line number Diff line number Diff line change
@@ -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。
14 changes: 5 additions & 9 deletions packages/ui/src/components/settings/MobileSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,15 @@ function SettingsRoot({ open }: { open: (tab: SettingsTab) => void }) {
<SettingsRow
onClick={() => open('account')}
leading={
<span className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary text-lg font-medium text-primary-foreground">
<span className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary text-base font-medium text-primary-foreground">
{user?.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-12 w-12 object-cover" />
<img src={user.avatarUrl} alt="" className="h-10 w-10 object-cover" />
) : (
initial
)}
</span>
}
label={<span className="text-lg font-medium">{name}</span>}
label={<span className="font-medium">{name}</span>}
description={user?.displayName ? user.email : t('settings:account')}
/>
</SettingsGroup>
Expand Down Expand Up @@ -161,7 +161,7 @@ function SettingsRoot({ open }: { open: (tab: SettingsTab) => void }) {
}

/**
* 窄屏设置(iOS 设置 / Android 系统设置惯例):分类首页 → 推入详情页 →
* 窄屏设置(Android 系统设置惯例):分类首页 → 推入详情页 →
* 推入选项页的页面栈。系统返回 / Escape 逐级后退,在首页才关闭设置。
* 设置页经 SettingsNavProvider 感知自己处于栈内,渲染列表单元格形态。
*/
Expand Down Expand Up @@ -250,11 +250,7 @@ export function MobileSettingsContent() {
)}
>
<Suspense fallback={<PageFallback />}>
{entry ? (
entry.render()
) : (
<SettingsRoot open={(tab) => nav.push(tabEntry(tab))} />
)}
{entry ? entry.render() : <SettingsRoot open={(tab) => nav.push(tabEntry(tab))} />}
</Suspense>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/settings/SettingsGeneral.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
<SettingsGroup>
<SettingsGroup role={filtered.length > 0 ? 'radiogroup' : undefined}>
{filtered.length === 0 ? (
<SettingsRow label={<span className="text-muted-foreground">{t('noMatches')}</span>} />
) : (
Expand Down
70 changes: 35 additions & 35 deletions packages/ui/src/components/settings/SettingsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section className={cn('flex flex-col gap-1.5', className)}>
{header && (
<h3 className="px-4 pt-1 text-sm font-medium text-primary">{header}</h3>
)}
<div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-2xl bg-card">
{header && <h3 className="px-4 pt-1 text-sm font-medium text-primary">{header}</h3>}
<div role={role} className={TILES_CLASS}>
{children}
</div>
{footer && <p className="px-4 text-sm leading-snug text-muted-foreground">{footer}</p>}
Expand Down Expand Up @@ -87,7 +93,7 @@ interface SettingsRowProps {
leading?: React.ReactNode;
/** 行下方的补充说明(单行内的小字)。 */
description?: React.ReactNode;
/** 右侧当前值(灰字)。 */
/** 当前值:标题下方的第二行灰字(Material summary)。 */
value?: React.ReactNode;
/** 右侧行内控件(开关、按钮等);有 control 时行本身不可点击。 */
control?: React.ReactNode;
Expand Down Expand Up @@ -119,11 +125,7 @@ export function SettingsRow({
submit = false,
htmlFor,
}: SettingsRowProps) {
const labelNode = htmlFor ? (
<label htmlFor={htmlFor}>{label}</label>
) : (
<span>{label}</span>
);
const labelNode = htmlFor ? <label htmlFor={htmlFor}>{label}</label> : <span>{label}</span>;
const body = (
<>
{leading}
Expand All @@ -135,13 +137,13 @@ export function SettingsRow({
)}
>
{labelNode}
{value !== undefined && (
<span className="truncate text-sm text-muted-foreground">{value}</span>
)}
{description && (
<span className="text-sm leading-snug text-muted-foreground">{description}</span>
)}
</span>
{value !== undefined && (
<span className="max-w-[55%] truncate text-sm text-muted-foreground">{value}</span>
)}
{control}
</>
);
Expand Down Expand Up @@ -200,25 +202,23 @@ export function SettingsOptionGroup<T extends string | number>({
onChange: (value: T) => void;
}) {
return (
<SettingsGroup header={header} footer={footer}>
<div role="radiogroup" className="flex flex-col divide-y divide-border/60">
{options.map((opt) => {
const selected = opt.value === value;
return (
<button
key={String(opt.value)}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(opt.value)}
className={cn(ROW_CLASS, 'transition-colors active:bg-accent')}
>
<SettingsRadio checked={selected} />
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
</button>
);
})}
</div>
<SettingsGroup header={header} footer={footer} role="radiogroup">
{options.map((opt) => {
const selected = opt.value === value;
return (
<button
key={String(opt.value)}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(opt.value)}
className={cn(ROW_CLASS, 'transition-colors active:bg-accent')}
>
<SettingsRadio checked={selected} />
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
</button>
);
})}
</SettingsGroup>
);
}
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/src/components/ui/switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
// 窄屏(Android)按 Material 3 尺寸:52×32 轨道、24px 滑块。
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-base max-md:h-8 max-md:w-[52px] max-md:border-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-sidebar-accent',
// 窄屏(Android)按 Material 3:52×32 轨道;关闭时描边轨道 + 16px 灰色滑块,
// 打开时主题色轨道 + 24px 滑块。
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-base max-md:h-8 max-md:w-[52px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-sidebar-accent max-md:data-[state=checked]:border-primary max-md:data-[state=unchecked]:border-muted-foreground max-md:data-[state=unchecked]:bg-muted',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-[0_1px_3px_hsl(0_0%_0%/0.2)] ring-0 transition-transform duration-base ease-spring data-[state=checked]:translate-x-4 max-md:h-6 max-md:w-6 max-md:data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-[0_1px_3px_hsl(0_0%_0%/0.2)] ring-0 transition-transform duration-base ease-spring data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
'max-md:shadow-none max-md:transition-all max-md:data-[state=checked]:h-6 max-md:data-[state=checked]:w-6 max-md:data-[state=checked]:translate-x-[22px] max-md:data-[state=checked]:bg-primary-foreground max-md:data-[state=unchecked]:translate-x-1.5 max-md:data-[state=unchecked]:bg-muted-foreground',
)}
/>
</SwitchPrimitives.Root>
Expand Down
Loading