choice · minimal · 灵感来源:APG Checkbox / Radio Group + iOS 设置勾选
已选:email · 「短信」是禁用项
当前:staging · 原生方向键切换
Checkbox & Radio Group (APG) · 极简勾选 · 勾选 / Minimal
做一个 Checkbox & Radio Group (APG)(极简勾选):
- 原生 checkbox / radio + fieldset / legend;单选组同 name,方向键与空格交给浏览器
- input sr-only,focus-visible 环用 peer 画在可视框上,读屏与键盘都不丢
- values 数组同时兼容多选与单选;组级 fieldset[disabled] 与单项禁用互不干扰
- 未选白底 + 发丝边 + shadow-sm;选中 accent 实底 + 反色勾 / 点,去掉阴影
用这些 CSS 变量:--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --shadow-sm --text-xs
灵感参考:APG Checkbox / Radio Group + iOS 设置勾选--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --shadow-sm --text-xs
焦点 / 禁用
(尚未接进整页场景)
/r/choice--minimal.json
免外壳的 markdown 版:/llms/components/choice--minimal.mdkind 决定方框或圆圈;两种都走同一套 label 行结构sr-only,focus-visible 环画在可视框上values 数组:多选多值、单选 0 / 1 值;组级与单项都可禁用--shadow-smtext-sm,与极简输入框同一档变量:--color-surface / --color-divider / --color-accent / --shadow-sm。
import { useId } from 'react';
import { Check } from 'lucide-react';
import { Icon } from '~/components/Icon';
export interface ChoiceOption {
value: string;
label: string;
disabled?: boolean;
}
interface Props {
kind: 'checkbox' | 'radio';
options: ChoiceOption[];
values: string[];
onChange?: (values: string[]) => void;
label?: string;
disabled?: boolean;
className?: string;
}
/**
* 极简勾选:复选框与单选框共用一套行结构,只有可视框形状不同。
* 用原生 input 保留键盘与读屏行为;焦点环画在可视框上(input 本身 sr-only)。
*/
export function MinimalChoice({
kind,
options,
values,
onChange,
label,
disabled = false,
className = '',
}: Props) {
const groupId = useId();
const toggle = (value: string) => {
if (kind === 'radio') {
onChange?.([value]);
return;
}
const next = new Set(values);
if (next.has(value)) next.delete(value);
else next.add(value);
onChange?.([...next]);
};
return (
<fieldset
disabled={disabled || undefined}
aria-disabled={disabled || undefined}
className={`border-0 p-0 m-0 min-w-0 ${disabled ? 'opacity-60' : ''} ${className}`}
>
{label && (
<legend className="text-xs uppercase tracking-[0.18em] text-[var(--color-text-muted)] mb-2.5">
{label}
</legend>
)}
<div className="space-y-2.5">
{options.map((option) => {
const isOn = values.includes(option.value);
const isDisabled = disabled || option.disabled;
return (
<label
key={option.value}
className={`flex items-center gap-3 text-sm ${
isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
}`}
>
<input
type={kind}
name={groupId}
value={option.value}
checked={isOn}
disabled={isDisabled}
onChange={() => toggle(option.value)}
className="peer sr-only"
/>
<span
aria-hidden="true"
className={`
grid place-items-center shrink-0 size-5
${kind === 'checkbox' ? 'rounded-md' : 'rounded-full'}
${isOn
? 'bg-[var(--color-accent)] border border-[var(--color-accent)] text-[var(--color-bg)]'
: 'bg-[var(--color-surface)] border border-[var(--color-divider)] shadow-[var(--shadow-sm)]'}
peer-focus-visible:outline peer-focus-visible:outline-2
peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]
`}
>
{isOn && kind === 'checkbox' && (
<Icon icon={Check} size={12} strokeWidth={3} />
)}
{isOn && kind === 'radio' && (
<span className="size-2 rounded-full bg-current" />
)}
</span>
<span>{option.label}</span>
</label>
);
})}
</div>
</fieldset>
);
}
src/components/choice/minimal/