mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-29 01:22:31 +08:00
feat(ui): Modal and the Input family land in @bisheng/ui
Two more baseline components, plus the tokens they forced into the open. Modal is one shell for every business dialog: 56px header, a body that is the only scrolling area, a fixed footer. The spec's pinned values — four sizes with their step-down ladder, the 40% black mask with no blur, the z-tier, the 200/160ms curves, the three exits — live in the component, so a business page never restates them. Radix Dialog is the base, which is what adds @radix-ui/react-dialog to the catalog. Input covers every single-line field (plus password, search and the multi-line Textarea) behind one shell: sizes, the four states and the touch rules baked in, every string a prop. Two token groups come with them: - `shadow-focus`, the control focus ring. Deliberately not a third elevation tier — it indicates focus and is built from existing values, the exception 圆角与阴影规范 §4 allows. Its colour sits in its own var so an error / warning field swaps the tint without a second token. - Four `z-*` overlay tiers (modal / popover / toast / tooltip), ordered so each can cover the one below. Hand-rolled z-[…] values are what produced the old z-50 / z-[100] / z-[110] / z-[9999] zoo; 组件-Modal 弹窗 §5 is now the SSOT and every new overlay picks a tier. Client's style.css and tailwind.config keep their value-for-value copy of the preset, per the token SSOT rule. Specs and demo pages updated; the docs sidebar gains 输入框 Input. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,12 @@ export default defineConfig({
|
||||
{ text: '按钮 Button', link: '/components/button' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '数据录入 Data Entry',
|
||||
items: [
|
||||
{ text: '输入框 Input', link: '/components/input' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '数据展示 Data Display',
|
||||
items: [
|
||||
@@ -122,6 +128,7 @@ export default defineConfig({
|
||||
text: '组件规范',
|
||||
items: [
|
||||
{ text: '按钮 Button', link: '/组件-Button按钮' },
|
||||
{ text: '输入框 Input', link: '/组件-Input输入框' },
|
||||
{ text: '文字提示 Tooltip', link: '/组件-Tooltip文字提示' },
|
||||
{ text: '气泡卡片 Popover', link: '/组件-Popover气泡卡片' },
|
||||
{ text: '面包屑 Breadcrumb', link: '/组件-Breadcrumb面包屑' },
|
||||
|
||||
@@ -146,6 +146,25 @@
|
||||
* packages/ui/src/styles/tokens.css. */
|
||||
--shadow-popup: 0 2px 16px -2px rgba(0, 23, 66, 0.10);
|
||||
--shadow-modal: 0 0 16px 0 rgba(3, 7, 117, 0.05);
|
||||
|
||||
/* Focus ring (组件-Input输入框.md §5.1) — NOT a third elevation tier: it is a
|
||||
* focus INDICATOR, it does not express height, and every value comes from
|
||||
* existing tokens (圆角与阴影规范 §4 exception clause). Only the COLOR is a
|
||||
* variable; the ring geometry lives in the Tailwind `shadow-focus` utility,
|
||||
* because a pre-composed `--shadow-focus` declared here would freeze the
|
||||
* color at :root (var() in a custom property is substituted where it is
|
||||
* DECLARED) and error / warning could never swap it. Kept value-identical
|
||||
* with packages/ui/src/styles/tokens.css. */
|
||||
--shadow-focus-ring: var(--fill-2);
|
||||
|
||||
/* ---------- Overlay stacking (组件-Modal弹窗.md §5) — four tiers, nothing else ----------
|
||||
* Each tier must be able to cover the one below it: a dropdown opens inside a
|
||||
* dialog, a toast shows over both, a tooltip beats everything. Carried as vars
|
||||
* so a host app that must sit under a legacy layer can retune them centrally. */
|
||||
--z-modal: 1000;
|
||||
--z-popover: 1100;
|
||||
--z-toast: 1200;
|
||||
--z-tooltip: 1300;
|
||||
}
|
||||
|
||||
/* Mobile (narrow viewport ≤768px) remap of the SEMANTIC layer only:
|
||||
@@ -376,6 +395,41 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* Input hit-area expansion (组件-Input输入框.md §6): the field keeps its 24/32/40
|
||||
height while the tappable area grows to ≥44px on touch. Two strips above and
|
||||
below — NOT one overlay like the button's: an overlay lying on the field would
|
||||
swallow the tap that places the caret. The Input shell sets `relative` + this
|
||||
class and focuses the field when a strip is tapped. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.input-touch-hit::before,
|
||||
.input-touch-hit::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: max(0px, calc((44px - 100%) / 2));
|
||||
}
|
||||
.input-touch-hit::before {
|
||||
bottom: 100%;
|
||||
}
|
||||
.input-touch-hit::after {
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS anti-zoom (组件-Input输入框.md §6): Safari zooms the whole page when a field
|
||||
smaller than 16px takes focus. Every size档 therefore types at 16px on touch /
|
||||
narrow screens — the 双判定 of 多端适配原则 §4. Element+class selector on
|
||||
purpose: it must beat the size档's own `text-[length:…]` utility whatever order
|
||||
the stylesheets end up in. */
|
||||
@media (max-width: 768px), (hover: none) and (pointer: coarse) {
|
||||
input.input-no-zoom,
|
||||
textarea.input-no-zoom {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--presentation: var(--gray-800);
|
||||
--text-primary: var(--gray-100);
|
||||
|
||||
@@ -62,6 +62,25 @@ module.exports = {
|
||||
authPageWidth: '370px',
|
||||
},
|
||||
keyframes: {
|
||||
'modal-overlay-in': {
|
||||
from: { opacity: '0' },
|
||||
to: { opacity: '1' },
|
||||
},
|
||||
'modal-overlay-out': {
|
||||
from: { opacity: '1' },
|
||||
to: { opacity: '0' },
|
||||
},
|
||||
// `scale` (the standalone property), NOT `transform: scale()` — the card
|
||||
// is centred with `translate(-50%, -50%)` on the desktop档 and with
|
||||
// `inset: 0` on the phone档, so the animation must not own `transform`.
|
||||
'modal-content-in': {
|
||||
from: { opacity: '0', scale: '0.96' },
|
||||
to: { opacity: '1', scale: '1' },
|
||||
},
|
||||
'modal-content-out': {
|
||||
from: { opacity: '1' },
|
||||
to: { opacity: '0' },
|
||||
},
|
||||
'accordion-down': {
|
||||
from: { height: 0 },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
@@ -119,6 +138,13 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
// Modal / dialog motion (组件-Modal弹窗.md §6): 200ms in (fade + 96%→100%),
|
||||
// 160ms out (fade ONLY — shrinking on exit reads as "it went back
|
||||
// somewhere"). Same curve both ways.
|
||||
'modal-overlay-in': 'modal-overlay-in 200ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
'modal-overlay-out': 'modal-overlay-out 160ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
'modal-content-in': 'modal-content-in 200ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
'modal-content-out': 'modal-content-out 160ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
||||
'crawl-slide': 'crawl-slide 1.4s linear infinite',
|
||||
'sheen-sweep': 'sheen-sweep 2s linear infinite',
|
||||
@@ -310,7 +336,23 @@ module.exports = {
|
||||
boxShadow: {
|
||||
popup: 'var(--shadow-popup)',
|
||||
modal: 'var(--shadow-modal)',
|
||||
// `shadow-focus` is the control focus RING (组件-Input输入框.md §5.1),
|
||||
// not a third elevation tier — see the exception note in tokens.css.
|
||||
// The geometry is spelled out here rather than carried in a var so the
|
||||
// color resolves ON THE ELEMENT: a field in error / warning overrides
|
||||
// `--shadow-focus-ring` and the ring follows.
|
||||
focus: '0 0 0 2px rgb(var(--shadow-focus-ring))',
|
||||
},
|
||||
// Overlay stacking (组件-Modal弹窗.md §5 / design-token.cjs Z_INDEX) — the
|
||||
// only four layers there are. `z-modal` < `z-popover` < `z-toast` <
|
||||
// `z-tooltip`; anything else is a hand-rolled value and off-spec.
|
||||
zIndex: {
|
||||
modal: 'var(--z-modal)',
|
||||
popover: 'var(--z-popover)',
|
||||
toast: 'var(--z-toast)',
|
||||
tooltip: 'var(--z-tooltip)',
|
||||
},
|
||||
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -239,6 +239,32 @@ const SHADOW = [
|
||||
},
|
||||
];
|
||||
|
||||
/* Focus ring — deliberately NOT a member of SHADOW above. It is a focus
|
||||
* INDICATOR (组件-Input输入框.md §5.1: 灰描边加深 + 一圈灰阴影), it expresses no
|
||||
* elevation, and every value in it comes from existing tokens, which is the
|
||||
* exception 圆角与阴影规范 §4 allows. The ring COLOR sits in its own var so a
|
||||
* field in error / warning swaps it for the matching tint (danger-tint /
|
||||
* warning-tint) without inventing a second shadow token. */
|
||||
const FOCUS_RING = {
|
||||
name: 'focus',
|
||||
colorVar: '--shadow-focus-ring',
|
||||
colorDefault: '--fill-2',
|
||||
value: '0 0 0 2px rgb(var(--shadow-focus-ring))',
|
||||
usage: '控件聚焦指示环:输入框、文本域;错误 / 警告态由 --shadow-focus-ring 换成对应 tint',
|
||||
};
|
||||
|
||||
/* Overlay stacking — exactly four tiers (组件-Modal弹窗.md §5 is the SSOT for
|
||||
* layering). Every new overlay picks one of these; hand-rolled `z-[…]` values
|
||||
* are what produced the old z-50 / z-[100] / z-[110] / z-[9999] zoo. Ordered so
|
||||
* each tier can cover the one below: a dropdown opens inside a dialog, a toast
|
||||
* shows over both, and a tooltip beats everything. */
|
||||
const Z_INDEX = [
|
||||
{ name: 'modal', cssVar: '--z-modal', value: 1000, usage: '弹窗、抽屉(含各自的遮罩)' },
|
||||
{ name: 'popover', cssVar: '--z-popover', value: 1100, usage: '气泡卡片、下拉菜单' },
|
||||
{ name: 'toast', cssVar: '--z-toast', value: 1200, usage: '轻提示 Toast' },
|
||||
{ name: 'tooltip', cssVar: '--z-tooltip', value: 1300, usage: '文字提示 Tooltip' },
|
||||
];
|
||||
|
||||
const ICON_SIZE = [
|
||||
{ name: 'xs', px: 12, strokeWidth: 2.5, usage: '极小标记(badge、密集表格角标),仅纯展示' },
|
||||
{ name: 'sm', px: 14, usage: 'small / medium 按钮的文字+icon' },
|
||||
@@ -289,8 +315,14 @@ const boxShadow = {};
|
||||
SHADOW.forEach((s) => {
|
||||
boxShadow[s.name] = `var(${s.cssVar})`;
|
||||
});
|
||||
boxShadow[FOCUS_RING.name] = FOCUS_RING.value;
|
||||
|
||||
const tailwindTheme = { colors, fontSize, boxShadow };
|
||||
const zIndex = {};
|
||||
Z_INDEX.forEach((z) => {
|
||||
zIndex[z.name] = `var(${z.cssVar})`;
|
||||
});
|
||||
|
||||
const tailwindTheme = { colors, fontSize, boxShadow, zIndex };
|
||||
|
||||
/* ================================================================== *
|
||||
* Migration map — old numeric class → new role class, for the app's
|
||||
@@ -320,6 +352,8 @@ module.exports = {
|
||||
TAG,
|
||||
RADIUS,
|
||||
SHADOW,
|
||||
FOCUS_RING,
|
||||
Z_INDEX,
|
||||
ICON_SIZE,
|
||||
tailwindTheme,
|
||||
MIGRATION,
|
||||
|
||||
@@ -93,8 +93,8 @@ BISHENG 前台(client)有很多历史遗留的重复组件——同一种东
|
||||
| 图标规范(Icon) | 🟨 进行中 | lucide 159 文件 / bisheng-icons 39 文件双轨并行(2026-07-16 扫描) | 规范 **v1 已定稿**:bisheng-icons 单一来源(24×24 / 2px),六档尺寸阶梯 + 线宽对照 + 着色/可达性规则;剩:lucide 高频逐批迁移(设计师点名) | [基础-图标规范.mdx](基础-图标规范.mdx) |
|
||||
| 色彩规范(Color) | 🟨 进行中 | 9 套体系并行、裸 hex 2469 处/215 值(2026-07-14 扫描) | Arco 色板规范 **v1 已定稿**;**token 两层 + Tailwind 接线 + 画廊「色彩 Colors」页已落地**(2026-07-15,实际命名见文档 §7.1 落地注:`border` 避让为 `border-base`);剩:逐批迁移(第一优先 = LibreChat 语义 token 重指向 Arco 值) | [基础-色彩规范.md](基础-色彩规范.md) |
|
||||
| 圆角与阴影规范(基础) | 🟨 v1 提案 | 圆角任意值 81 处 12 种、阴影预设 5 档 117 处 + 任意值 94 处 44 种(2026-07-23 扫描,排除 _gallery) | 圆角八档阶梯 + 四条使用原则(尺寸定档 / 同级同档 / 嵌套同心 / 贴边归零,2026-07-31 收拢命名;弹窗定 16px)+ 投影两档 popup/modal(取下拉/弹窗基准组件已落地值);待设计师验收 → 落 token → 逐批归并 | [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx) |
|
||||
| Modal 弹窗 | 🟨 进行中 | 4(含1个死代码) | 待定(见组件文档) | [组件-Modal弹窗.md](组件-Modal弹窗.md) |
|
||||
| Toast 轻提示 | 🟨 进行中 | 组件库 1 套(`@bisheng/ui` Toast,client 全量接线;私有撤销 toast 已删) | 规范 **v1 已定稿** + **基准组件已落地**(2026-08-13):四类型(不做 loading)+ 顶部最多 3 条堆叠去重 + 至多一个操作按钮 + 时长按字数 3~10 秒动态 + 悬停暂停 + 分级播报;404 个调用点经 `showToast` 转接未动;剩:收敛 `severity`/`status` 双入参 → 暗色浅底取值 → z-index 待 Modal 期层级表 | [组件-Toast轻提示.md](组件-Toast轻提示.md) |
|
||||
| Modal 弹窗 | 🟨 进行中 | 4(含1个死代码) | 规范 **v1 已定稿**(2026-08-20):尺寸四档 400/600/960/全屏 + 遮罩黑 40% 不加模糊 + 内边距 16(主体只留左右)+ 「×」按有无取消按钮决定 + 手机档一律走全屏档 + 层级四档 1000/1100/1200/1300 在此定稿;**组件库 `@bisheng/ui` 的 `Modal` 已落地**,client 两套旧壳未动、迁移批次待排 | [组件-Modal弹窗.md](组件-Modal弹窗.md) |
|
||||
| Toast 轻提示 | 🟨 进行中 | 组件库 1 套(`@bisheng/ui` Toast,client 全量接线;私有撤销 toast 已删) | 规范 **v1 已定稿** + **基准组件已落地**(2026-08-13):四类型(不做 loading)+ 顶部最多 3 条堆叠去重 + 至多一个操作按钮 + 时长按字数 3~10 秒动态 + 悬停暂停 + 分级播报;404 个调用点经 `showToast` 转接未动;剩:收敛 `severity`/`status` 双入参 → 暗色浅底取值 → z-index 回填 `1200`(层级表已定稿) | [组件-Toast轻提示.md](组件-Toast轻提示.md) |
|
||||
| Drawer 抽屉 | 🟨 v1 已建 | 待扫描(已知 CreateKnowledgeSpaceDrawer / CreateChannelDrawer / VersionHistorySheet 等) | 规范 **v1 已定稿**:宽度三档 400/600/800(不设全屏档)+ 断点逐级降档 + 移动端底部上滑 + 模态/非模态两类;剩:真实页面量宽验收 → 基准组件落地 | [组件-Drawer抽屉.md](组件-Drawer抽屉.md) |
|
||||
| Select / 下拉菜单 | ⬜ 待办 | 多个 | 待定 | 待建 |
|
||||
| Button 按钮 | 🟨 进行中 | 5 路并行(详见文档附录 A) | color×variant 双轴规范 **v1 已定稿**;**基准组件已重构落地 + 旧 API 兼容映射 + 画廊已重做**(2026-07-14,见组件文档改动记录);剩:设计师验收推导值 → 逐批迁移业务页 → 清退 btn 系全局类与 Generations/Button | [组件-Button按钮.md](组件-Button按钮.md) |
|
||||
@@ -103,7 +103,7 @@ BISHENG 前台(client)有很多历史遗留的重复组件——同一种东
|
||||
| Tooltip 文字提示 | 🟨 规范 v1 | 待扫描 | 规范 **v1 已建**(2026-07-30:深底白字 / 悬停 100ms / 触屏不依赖;与 Popover 的判定归其 §2);实现与迁移待开工 | [组件-Tooltip文字提示.md](组件-Tooltip文字提示.md) |
|
||||
| Popover 气泡卡片 | 🟨 规范 v1 | 待扫描 | 规范 **v1 已建**(2026-07-30:默认 hover 触发 / 白底卡片 / 不做 Popconfirm);实现与迁移待开工 | [组件-Popover气泡卡片.md](组件-Popover气泡卡片.md) |
|
||||
| State 状态页 | 🟨 规范 v1 | 无统一壳,各业务页自拼「插画 + 一行字 + 按钮」(插画本身已收敛:7 张组件 + 120/80 两档,见插画规范附录 A 台账) | 规范 **v1 已建** + **基准组件已落地**(2026-08-14):空状态与结果反馈合成一份;插画尺寸只看容器三档,排版只有一套(16 / 4 / 16,主提示有没有说明决定它当标题还是当描述);一主一次按钮、主按钮在右;区域内 flex 居中,少数要上移的容器由业务侧加 `pb-*`。组件 `<StateView />`(只有 `size` 一根轴,文档仍叫状态页 / State)+ demo 页已上线,首批迁 4 处(PermissionEmptyState 辐射 5 个调用点、ChatEmptyState、KnowledgeListPanel、SkillSelector);§11 四项视觉待决策已全部结案(视觉部分定稿);剩:照插画台账逐处迁移 | [组件-State状态页.md](组件-State状态页.md) |
|
||||
| Input 输入框 | ⬜ 待办 | 待扫描 | 待定 | 待建 |
|
||||
| Input 输入框 | 🟨 进行中 | 待扫描(client 现存 `components/ui/Input` 与手拼原生 `<input>` 并存,用量与迁移映射待扫) | 规范 **v1 已建**(2026-08-20):五种形态(基础 / Textarea / Password / Search / addon)+ 尺寸三档 24/32/40 对齐按钮阶梯 + 内容形态四件套(placeholder / 前后缀 icon / 一键清除 / 字数统计)+ 状态七态(含 readonly 与 warning);**聚焦不用主题色**,改灰描边 border-deep + 2px gray-2 阴影环,全取现有 token;**组件库 `@bisheng/ui` 的 `Input` / `Textarea` / `PasswordInput` / `SearchInput` 已落地**(2026-08-20,含 `--shadow-focus` 环 token 与 iOS 防缩放、触屏热区两条 CSS 规则),demo 页已上线;剩:扫描存量 → 逐批迁移 → 归并 ExpandableSearchField 的两个裸 hex → 图标包补 eye 后收默认值 | [组件-Input输入框.md](组件-Input输入框.md) |
|
||||
| Tabs 标签页 | ⬜ 待办 | 待扫描 | 待定 | 待建 |
|
||||
|
||||
> 优先级:先 **Modal**,再 **Select menu**,后续由设计师逐个指定。
|
||||
@@ -128,13 +128,13 @@ BISHENG 前台(client)有很多历史遗留的重复组件——同一种东
|
||||
- 章节名含 **改动记录 / 关键结论 / 附录(附:)/ 落地记录 / 给实现窗口 / 待决策清单 / 代码锚点 / 扫描存档** 的整节自动隐藏——新写内部内容时**沿用这些章节名**即可,站点自动干净;
|
||||
- **首个 H1 正下方的引用块自动隐藏**(约定它是工作元信息:版本戳、与总纲配套、给下个窗口的话)——面向读者的正文别写在这个位置;
|
||||
- **`---` 分节线全部剔除**(rspress 主题本来就在每个 h2 前画分隔线,源文件里的 `---` 会叠成双线)——md 里照常写没关系,站点不渲染;
|
||||
- 个别章节要藏,在标题上一行加 `<!-- site-hide -->`(藏整节);任意范围用 `<!-- site-hide:start -->` / `<!-- site-hide:end -->`。已标记:本文 §二§三、Modal 文档 §5(用量台账)与 §1 的目前进度。
|
||||
- 个别章节要藏,在标题上一行加 `<!-- site-hide -->`(藏整节);任意范围用 `<!-- site-hide:start -->` / `<!-- site-hide:end -->`。已标记:本文 §二§三、Modal 文档的「现状与用量台账」。
|
||||
- **接线要点**(都在 rspress.config.ts,动它前先懂):别名 `~`/`@`/$fonts 镜像 vite;`globalStyles` 直指 `src/style.css`(带 Tailwind + 全部 token;**别用 @import 包一层**,rspack cssExtract 会崩);`source.define` 补了 vite 的 `__APP_ENV__` / `import.meta.env`(组件经 `~/utils` barrel 在模块顶层读它们,缺了整页白屏且只在 console 报);`url` 模块 alias 到 `stubs/url-stub.ts`;Node 内置模块 fallback false。
|
||||
- **坑**:① demo 目录**只能 ASCII**(`components/`),中文子目录 rspress v1 路由匹配失败 → 页面空白;② rspress 与 @rspress/plugin-preview **大版本必须一致**(v1 配 v1);③ npm 装完若报 `rspress: command not found`,跑 `npm rebuild rspress` 重建 bin 链接。
|
||||
|
||||
### 画廊双模式结构(2026-07-15 重构)
|
||||
画廊按"读者"分成两个模式,侧栏顶部 segment control 切换:
|
||||
- **设计规范**(默认,给设计师/工程师/PM 看):`_gallery/sections/*` —— 每页 = 使用规则 + 档位/token 表 + 实时演示,无迁移噪音;总览页含六条设计原则 + 规范索引;未定稿组件(Modal)带「未定稿」标。
|
||||
- **设计规范**(默认,给设计师/工程师/PM 看):`_gallery/sections/*` —— 每页 = 使用规则 + 档位/token 表 + 实时演示,无迁移噪音;总览页含六条设计原则 + 规范索引;未定稿组件带「未定稿」标。
|
||||
- **迁移进度**(给改造负责人看):`_gallery/progress/*` —— 总览看板(同本文件 §四)+ 每组件的迁移账本(用量盘点 / 旧写法清单 / 旧 API 台账 / 待设计师定夺)。状态点只在此模式显示。
|
||||
|
||||
记账规则更新:迁移类内容(盘点表、selectClasses 清单、迁移速查、待定夺)写进 `progress/` 页;规范类内容(规则、档位、anatomy、标准 demo)写进 `sections/` 页,别再混排。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> ⚠️ **草稿**,等设计师定稿。与 [00-总纲.md](00-总纲.md) 配套。
|
||||
> 本文只管**尚未拆出独立规范的通用 token**:间距、层级 z-index。
|
||||
> 圆角与阴影已于 2026-07-23 迁出独立成文([基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)),本文对应两节只留指针;色彩、字体、按钮、多端适配已各自成文并 v1 定稿(见 §0 索引);遮罩按设计师批注(2026-07-14)**归 Modal 文档定夺**,本文不重复。
|
||||
> 圆角与阴影已于 2026-07-23 迁出独立成文([基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)),本文对应两节只留指针;色彩、字体、按钮、多端适配已各自成文并 v1 定稿(见 §0 索引);遮罩与层级 z-index 已于 2026-08-20 迁出至 [组件-Modal弹窗.md](组件-Modal弹窗.md) §5 定稿,本文 §4 / §5 只留指针。
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
| 文字提示(判定/样式/时序/移动端) | [组件-Tooltip文字提示.md](组件-Tooltip文字提示.md) | ✅ 规范 v1(2026-07-30),实现待开工 |
|
||||
| 气泡卡片(结构/触发/层级/移动端) | [组件-Popover气泡卡片.md](组件-Popover气泡卡片.md) | ✅ 规范 v1(2026-07-30),实现待开工 |
|
||||
| 状态页(空状态 + 结果反馈:场景/档位/排版/按钮/定位) | [组件-State状态页.md](组件-State状态页.md) | ✅ 规范 v1(2026-08-14),实现待开工 |
|
||||
| 输入框(形态/尺寸/内容形态/状态) | [组件-Input输入框.md](组件-Input输入框.md) | ✅ 规范 v1(2026-08-20),组件库已落地(2026-08-20),存量迁移待排 |
|
||||
| 间距/层级(本文) | 01-设计规范.md | 🟨 待定 |
|
||||
|
||||
---
|
||||
@@ -38,44 +39,25 @@
|
||||
| 弹窗 footer 按钮间距 | 12px(`gap-3`) | 按钮规范 §4(对齐本文) |
|
||||
| 弹窗 footer 按钮 min-width | 60px;移动端等宽平铺 | 按钮规范 §3.1 / §5.5 |
|
||||
| 二次确认弹窗内边距(C 套,已落地) | 20px(`p-5`) | Modal 文档改动记录 2026-07-02 |
|
||||
| 弹窗内边距(v1 定稿) | header 高 56 / 左右 20;body 20;footer 上下 12 左右 20 | [组件-Modal弹窗.md](组件-Modal弹窗.md) §4 |
|
||||
|
||||
### 待定
|
||||
|
||||
| 场景 | 现状 | 待拍板 |
|
||||
|---|---|---|
|
||||
| Modal 弹窗内边距 | A 套 `p-5`(20) vs 手拼 AlertDialog `p-6`(24) 并存 | 20 vs 24 二选一(Modal 文档待决策清单);C 套先例是 20 |
|
||||
| 表单项垂直间距 | 未统计 | 建议 16px(`space-y-4`),随 Input 组件期定 |
|
||||
| 表单项垂直间距 | 未统计 | 建议 16px(`space-y-4`);Input 期(2026-08-20)明确把 label / 必填标记 / 错误文字的排版归口未来的 Form 规范(见 [组件-Input输入框.md](组件-Input输入框.md)),本项随 Form 期定 |
|
||||
|
||||
## 3. 阴影 / 投影 Shadow → 已迁出
|
||||
|
||||
2026-07-23 起阴影规范独立成文,见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx):只留两档——浮层投影 `0 2px 16px -2px rgba(0,23,66,0.10)`(下拉/气泡)与模态投影 `0 0 16px 0 rgba(3,7,117,0.05)`(弹窗/抽屉),两档取值即下拉与弹窗基准组件已落地值,一律搭配 1px 边框、静态区块不加投影、禁散写 `shadow-*`。本节旧的「三种现状 + 两档建议」作废,以新文档为准。
|
||||
|
||||
## 4. 遮罩 Overlay → 归 Modal 文档
|
||||
## 4. 遮罩 Overlay → 已迁出
|
||||
|
||||
设计师批注(2026-07-14):遮罩不放本文,**归 [组件-Modal弹窗.md](组件-Modal弹窗.md) 定夺**。
|
||||
当前之争已演进(旧的 `bg-black/80` 纯深色在二次确认期淘汰):现为 **浅黑毛玻璃**(A 套 `bg-black/40`+blur)vs **灰白毛玻璃**(B/C 套 `bg-gray-500/90`+blur)二选一,见 Modal 文档 §4。
|
||||
2026-08-20 起遮罩规则归 [组件-Modal弹窗.md](组件-Modal弹窗.md) §5:黑色 40% 不透明、不加模糊,同一时间只有一层。旧的「浅黑毛玻璃 vs 灰白毛玻璃」之争就此了结(`bg-gray-500/90` 列为待迁移旧值)。
|
||||
|
||||
## 5. 层级 z-index
|
||||
## 5. 层级 z-index → 已迁出
|
||||
|
||||
### 现状(三档并存,不一致)
|
||||
|
||||
| 层 | z-index |
|
||||
|---|---|
|
||||
| B 套弹窗 | `z-50` |
|
||||
| A 套弹窗 | `z-[100]` |
|
||||
| AlertDialog(C 套底座) | `z-[110]` |
|
||||
|
||||
### 已定原则(2026-07-30,数值仍待定稿)
|
||||
|
||||
- **Tooltip 永远是最高的浮层**:必须能出现在弹窗、抽屉、Popover 之上(弹窗里的图标钮也要有提示)。
|
||||
- **Popover 高于弹窗 / 抽屉、低于 Tooltip**。
|
||||
- 出处:Tooltip / Popover 两份组件规范的层级节引用本节,数值以本节定稿为准。
|
||||
|
||||
### 待定稿
|
||||
|
||||
- 建议统一一套层级阶梯:遮罩 + 弹窗一档,Drawer / Sheet / Popover / Tooltip 各归其位,写成 token 表(排序遵循上面的已定原则)。
|
||||
- 统一时需**先盘 Drawer / Sheet / Popover 与弹窗的叠放关系**(Modal 文档 §4 已提示),避免改完出现弹窗被抽屉盖住类回归。
|
||||
- **轻提示要盖住弹窗与抽屉**(在弹窗里点保存,提示得能看见),规则见 [组件-Toast轻提示.md](组件-Toast轻提示.md) §3;它与 Tooltip 谁在最上尚未拍板,随阶梯一并定。现状是组件库 Toaster 容器一处 `z-[9999]`(2026-08-13 落地时取的临时值,旧的 `z-[1000]` viewport 与私有撤销 toast 都已删除),定稿后归并。
|
||||
2026-08-20 起层级阶梯归 [组件-Modal弹窗.md](组件-Modal弹窗.md) §5 定稿,四档:弹窗 · 抽屉 1000 → 气泡卡片 · 下拉菜单 1100 → 轻提示 1200 → 文字提示 Tooltip 1300。此前定下的三条原则(Tooltip 最高、Popover 高于弹窗 / 抽屉、轻提示盖住弹窗 / 抽屉)全部落在该表内;旧的 `z-50` / `z-[100]` / `z-[110]` 三档现状与 Toaster 临时值 `z-[9999]` 作废,回填进度见 Modal 文档「给实现窗口」。
|
||||
|
||||
---
|
||||
|
||||
@@ -95,4 +77,6 @@
|
||||
| 2026-07-30 | §0 索引加 Tooltip / Popover 两行(两份规范 v1 建档);§5 新增「已定原则」——Tooltip 最高、Popover 高于弹窗低于 Tooltip,数值仍待阶梯定稿 | 待 committer 窗口提交 |
|
||||
| 2026-07-31 | §1 指针同步圆角规范 v1.3:§1.2「使用原则」模块四条(尺寸定档 / 同级同档 / 嵌套同心 / 贴边归零),同级同档为新增原则;该文档改名「基础-圆角与阴影规范.mdx」,本文链接与 §0 索引行标签同步 | 待 committer 窗口提交 |
|
||||
| 2026-08-14 | §0 索引加状态页一行([组件-State状态页.md](组件-State状态页.md) v1 建档,一份文档同时管空状态与结果反馈)。本文规则未增未减未改 | 待 committer 窗口提交 |
|
||||
| 2026-08-20 | **§4 遮罩、§5 层级迁出至 [组件-Modal弹窗.md](组件-Modal弹窗.md) §5 并定稿**(遮罩黑 40% 无模糊;层级四档 1000/1100/1200/1300),两节改为指针;§2 间距「已定」补弹窗内边距一行,「待定」删去 20 vs 24 一项(Modal v1 取 20)。本文其余规则未增未减未改 | 待 committer 窗口提交 |
|
||||
| 2026-08-13 | §0 索引加轻提示一行([组件-Toast轻提示.md](组件-Toast轻提示.md) v1 定稿);§5 待定稿补一条——轻提示要盖住弹窗与抽屉,与 Tooltip 的先后待拍板,现状 `z-[1000]` / `z-[9999]` 定稿后归并 | 待 committer 窗口提交 |
|
||||
| 2026-08-20 | §0 索引加输入框一行([组件-Input输入框.md](组件-Input输入框.md) v1 建档,聚焦不用主题色、全取现有 token)。本文规则未增未减未改 | 待 committer 窗口提交 |
|
||||
|
||||
@@ -78,7 +78,7 @@ export default () => (
|
||||
| 部位 | 值 | 备注 |
|
||||
|---|---|---|
|
||||
| 弹窗容器 | `rounded-2xl p-5 gap-4` + border `#ebebeb` + 淡投影 | 圆角 16 / padding 20 |
|
||||
| 遮罩 | `bg-gray-500/90` + `backdrop-blur-md` | 灰底毛玻璃 |
|
||||
| 遮罩 | `bg-gray-500/90`(不带模糊) | **待迁移旧值**:遮罩规则已归「Modal 弹窗」§5——黑 40% 不加模糊。毛玻璃 2026-08-04 已全量清除,颜色随 `ConfirmContext` 一处改 |
|
||||
| 标题 | `text-base font-medium leading-6` | |
|
||||
| 确认按钮 | 两档:danger `#f53f3f` / primary 品牌主色 | 语义红不换肤;特例可自定义 |
|
||||
| 取消按钮 | 白底描边 `hover:bg-fill-1`(`#f8f8f8`),`focus-visible` 焦点环 | 默认文案「暂不」;`hideCancel` 时不渲染 |
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# 输入框 Input
|
||||
|
||||
用户自己敲字的地方。组件库 `@bisheng/ui` 导出 **`Input`**(单行基座)、**`Textarea`**(多行)、**`PasswordInput`** 与 **`SearchInput`**(同一基座填不同插槽的组合形态)。规范全文见「文档 → 组件规范 → 输入框 Input」。
|
||||
|
||||
**壳把规范写死的部分**:24 / 32 / 40 三档高度与对应圆角 4 / 6 / 8、内边距 8 / 12 / 12(含 1px 描边即 7 / 11 / 11)、图标 14 / 16 / 18;聚焦不用主题色——描边加深到 `border-deep` + 2px 灰阴影环(`shadow-focus`);错误 / 警告只换描边与环色;触屏关掉 hover、字号升到 16px 防 iOS 缩放、上下各补一条透明热区把可点区域撑到 44px。这些没有 prop。
|
||||
|
||||
## 基础用法
|
||||
|
||||
宽度跟随布局,所以 `className` 落在外壳上(`w-64`、`w-full` 写在这里),`inputClassName` 才是 `<input>` 自己的。`placeholder` 写例子不写字段名——字段名交给 label。
|
||||
|
||||
```tsx
|
||||
import { Input } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 12, maxWidth: 320 }}>
|
||||
<Input placeholder="如 138xxxx0000" />
|
||||
<Input defaultValue="产品手册" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 三档尺寸
|
||||
|
||||
高度与按钮同一套阶梯,同排控件天然对齐——**不要手写 height / padding 去凑**。
|
||||
|
||||
```tsx
|
||||
import { Button, Input } from '@bisheng/ui';
|
||||
|
||||
const SIZES = [
|
||||
{ size: 'small', hint: 'small 24 · 表格行内、紧凑工具条' },
|
||||
{ size: 'medium', hint: 'medium 32 · 默认,绝大多数表单' },
|
||||
{ size: 'large', hint: 'large 40 · 登录页、大表单' },
|
||||
];
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 12, maxWidth: 420 }}>
|
||||
{SIZES.map(({ size, hint }) => (
|
||||
<div key={size} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Input size={size} placeholder={hint} />
|
||||
<Button size={size} color="default" variant="outlined">搜索</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 前缀 / 后缀 与 一键清除
|
||||
|
||||
前缀说明「这个框是什么」,后缀承载「对内容的动作」或单位。`allowClear` 在悬停或聚焦、且框里有内容时才露出清除按钮——搜索、筛选类默认开,正式表单字段别开,误触清掉一整段输入的代价太大。
|
||||
|
||||
```tsx
|
||||
import { Input, SearchInput } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 12, maxWidth: 320 }}>
|
||||
<SearchInput placeholder="搜索知识库" onSearch={(value) => console.log('search:', value)} />
|
||||
<Input suffix="元" placeholder="0.00" />
|
||||
<Input allowClear clearLabel="清空" defaultValue="鼠标移上来看清除按钮" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
`SearchInput` 就是基座 + 放大镜前缀:回车触发 `onSearch`(输入即筛的场景仍然用 `onChange`),清除默认开启。
|
||||
|
||||
## 密码框
|
||||
|
||||
后缀放「明暗切换」——这个动作属于这个框,做成框外按钮会把表单排版撑乱。
|
||||
|
||||
> 图标由调用方传入:`bisheng-icons` 目前没有 eye 图标,而组件库不允许自己画图标(库契约)。图标包补上之后这两个 prop 会有默认值。
|
||||
|
||||
```tsx
|
||||
import { PasswordInput } from '@bisheng/ui';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
export default () => (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<PasswordInput
|
||||
placeholder="8-20 位,含字母和数字"
|
||||
revealIcon={<Eye />}
|
||||
hideIcon={<EyeOff />}
|
||||
revealLabel="显示密码"
|
||||
hideLabel="隐藏密码"
|
||||
allowClear
|
||||
clearLabel="清空"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 前后置标签 addon
|
||||
|
||||
固定的协议前缀、单位这类「内容的一部分」放 addon:与输入框共享一条外描边,浅灰底 + 次要文字色,看上去属于框而不是一个按钮。清除、明暗切换这类**动作**不做 addon。
|
||||
|
||||
```tsx
|
||||
import { Input } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 12, maxWidth: 380 }}>
|
||||
<Input addonBefore="https://" placeholder="dataelem.com" />
|
||||
<Input addonAfter="元" placeholder="0.00" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 状态
|
||||
|
||||
常态 → 悬停 → 聚焦是同一条灰色渐进链,只变深浅、不换色相:**聚焦不上主题色**。表单里输入框成片出现,聚焦只回答「光标在哪」,颜色留给校验。错误阻断提交,警告不阻断(「能提交,但值得再看一眼」);错误提示写在框下方,说清怎么改。
|
||||
|
||||
内容有效、只是不让改,用 `readOnly`;整个字段当前无意义才用 `disabled`——禁用的内容读屏读不到、也复制不走。
|
||||
|
||||
```tsx
|
||||
import { Input } from '@bisheng/ui';
|
||||
|
||||
const Field = ({ label, tip, tipColor, ...props }) => (
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: 14, color: 'rgb(var(--text-2))' }}>{label}</span>
|
||||
<Input {...props} />
|
||||
{tip && <span style={{ fontSize: 12, color: tipColor }}>{tip}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 16, maxWidth: 320 }}>
|
||||
<Field label="常态 / 悬停 / 聚焦" placeholder="点进来看聚焦环" />
|
||||
<Field
|
||||
label="错误 error"
|
||||
status="error"
|
||||
defaultValue="1380000"
|
||||
tip="请输入 11 位手机号"
|
||||
tipColor="rgb(var(--danger))"
|
||||
/>
|
||||
<Field
|
||||
label="警告 warning"
|
||||
status="warning"
|
||||
defaultValue="12345678"
|
||||
tip="这个密码太容易猜到了,建议换一个"
|
||||
tipColor="rgb(var(--warning))"
|
||||
/>
|
||||
<Field label="只读 readonly" readOnly defaultValue="kb_9f2c1a77" />
|
||||
<Field label="禁用 disabled" disabled placeholder="选中知识库后可填" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 多行文本域
|
||||
|
||||
`Textarea` 默认三行高,只允许纵向拉伸、禁止横向——横着长出去会把表单的栏宽撑破。自动长高没有内建:规范要求它必须有上限,等真有场景再定。
|
||||
|
||||
```tsx
|
||||
import { Textarea } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ maxWidth: 380 }}>
|
||||
<Textarea placeholder="这个知识库收了哪些资料、给谁用" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 字数统计
|
||||
|
||||
**需要限长才显示**,格式「当前 / 上限」:单行放后缀区,多行放框内右下角。达到上限停止录入、计数转危险色。上限宽裕、几乎碰不到的字段不要开——满屏计数只制造焦虑。
|
||||
|
||||
```tsx
|
||||
import { Input, Textarea } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 12, maxWidth: 380 }}>
|
||||
<Input showCount maxLength={20} placeholder="知识库名称" />
|
||||
<Textarea showCount maxLength={50} defaultValue="试着一直打字,看计数到上限时变红并停止录入" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
`Input` 之外的三个组件都透传基座的 props(`size` / `status` / `allowClear` / `showCount` / 原生 `<input>` 属性),下表只列组件自己加的。
|
||||
|
||||
### Input
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | 三档高度 24 / 32 / 40 |
|
||||
| `status` | `'default' \| 'error' \| 'warning'` | `'default'` | `error` 同时置 `aria-invalid` |
|
||||
| `prefix` / `suffix` | `ReactNode` | — | 前缀说明是什么,后缀放动作或单位 |
|
||||
| `addonBefore` / `addonAfter` | `ReactNode` | — | 与框共享描边的固定前后缀 |
|
||||
| `allowClear` | `boolean` | `false` | 悬停 / 聚焦且有内容时出现 |
|
||||
| `clearLabel` | `string` | — | 清除按钮的无障碍名称(文案由调用方给) |
|
||||
| `onClear` | `() => void` | — | 清空后触发;空值的 `onChange` 也会照常触发 |
|
||||
| `showCount` | `boolean` | `false` | 需配 `maxLength`,否则不渲染 |
|
||||
| `className` / `inputClassName` | `string` | — | 前者落在外壳(宽度写这里),后者落在 `<input>` |
|
||||
|
||||
### PasswordInput
|
||||
|
||||
| prop | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `revealIcon` / `hideIcon` | `ReactNode` | 必填,明暗切换的两个图标(图标包补 eye 后改为默认值) |
|
||||
| `revealLabel` / `hideLabel` | `string` | 切换按钮的无障碍名称 |
|
||||
| `defaultVisible` | `boolean` | 默认 `false`;生成的密钥这类「本来就要给人看」的内容才置 `true` |
|
||||
| `onVisibleChange` | `(visible: boolean) => void` | 明暗切换回调 |
|
||||
|
||||
### SearchInput
|
||||
|
||||
| prop | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `onSearch` | `(value: string) => void` | 回车触发(输入法组字中的回车不算) |
|
||||
| `prefixIcon` | `ReactNode` | 换掉默认放大镜 |
|
||||
| `allowClear` | `boolean` | 默认 `true` |
|
||||
|
||||
### Textarea
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `status` | `'default' \| 'error' \| 'warning'` | `'default'` | 同单行 |
|
||||
| `rows` | `number` | `3` | 默认三行高 |
|
||||
| `showCount` | `boolean` | `false` | 计数在框内右下角 |
|
||||
| `className` / `textareaClassName` | `string` | — | 前者落在外壳,后者落在 `<textarea>` |
|
||||
@@ -1,105 +1,255 @@
|
||||
# 弹窗 Modal
|
||||
|
||||
承载业务内容(表单、列表、预览)的普通弹窗。**统一标准尚未定稿**——本页只演示「已定」的基准候选壳与过渡期写法;现状盘点(5 套壳 64 文件)、待决策清单见「文档 → 组件规范 → Modal 弹窗」。
|
||||
页面正中打开的打断式浮层,用来让用户当场做个决定或完成一小段任务。组件库 `@bisheng/ui` 导出 **`Modal`**:头部 / 主体 / 底部三段、四档尺寸与降档、遮罩、层级、动效、三条关闭路径全在壳里,业务页只传内容。规范全文见「文档 → 组件规范 → 弹窗 Modal」。
|
||||
|
||||
## 基准候选壳
|
||||
**壳把规范写死的部分**:圆角 16px + 模态投影、头部高 56px、三段左右统一 16px、底部四边 16px(主体上下不另留)、标题 16px 字重 500、遮罩黑 40% 不加模糊、层级 `z-modal`(1000)、进 200ms / 出 160ms。这些没有 prop,改不了——要改就去改规范。
|
||||
|
||||
当前推荐的新弹窗写法:`OGDialogTemplate`(壳已对齐 C 套视觉——圆角 16 / p-5 / 灰底毛玻璃,取消/确认按钮已统一两档)。
|
||||
## 基础用法
|
||||
|
||||
`title` + 正文 + `footer`。`trigger` 里的元素会自动接上打开事件,也可以不给 `trigger`,改用 `open` / `onOpenChange` 自己控。底部按钮主按钮最右,`ModalClose` 包住的按钮点了就关。
|
||||
|
||||
```tsx
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import '~/locales/i18n';
|
||||
import store from '~/store';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Input } from '~/components/ui/Input';
|
||||
import { OGDialog, OGDialogTrigger } from '~/components/ui/OriginalDialog';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { Button, Modal, ModalClose } from '@bisheng/ui';
|
||||
|
||||
const field = {
|
||||
width: '100%',
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgb(var(--border-base))',
|
||||
background: 'rgb(var(--bg-page))',
|
||||
color: 'rgb(var(--text-1))',
|
||||
};
|
||||
|
||||
export default () => (
|
||||
<RecoilRoot initializeState={({ set }) => set(store.lang, 'zh-Hans')}>
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button color="default" variant="outlined">打开基准候选壳</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
title="弹窗标题"
|
||||
description="标题下的说明文字。"
|
||||
className="max-w-md"
|
||||
main={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p>这里是弹窗正文示例。观察内边距、行距与正文和标题/按钮的间距。</p>
|
||||
<Input placeholder="示例输入框" />
|
||||
</div>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: () => null,
|
||||
selectVariant: 'primary',
|
||||
selectText: '确定',
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</RecoilRoot>
|
||||
<Modal
|
||||
trigger={<Button color="default" variant="outlined">重命名</Button>}
|
||||
title="重命名知识库"
|
||||
footer={
|
||||
<>
|
||||
<ModalClose asChild>
|
||||
<Button color="default" variant="outlined">取消</Button>
|
||||
</ModalClose>
|
||||
<Button>保存</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label style={{ display: 'block', marginBottom: 8, color: 'rgb(var(--text-2))' }}>名称</label>
|
||||
<input style={field} defaultValue="产品手册" />
|
||||
</Modal>
|
||||
);
|
||||
```
|
||||
|
||||
## 壳规格(已定部分)
|
||||
## 四档尺寸
|
||||
|
||||
与二次确认弹窗同一套壳:
|
||||
|
||||
| 部位 | 值 | 备注 |
|
||||
|---|---|---|
|
||||
| 圆角 | `rounded-2xl`(16px) | 移动端是否保留直角/贴底待定 |
|
||||
| 内边距 | `p-5`(20px)· 区块间 `gap-4` | |
|
||||
| 遮罩 | `bg-gray-500/90` + `backdrop-blur-md` | 灰底毛玻璃 |
|
||||
| 边框 / 阴影 | border `#ebebeb` + 淡投影 | |
|
||||
| 标题 | `text-base font-medium leading-6` | |
|
||||
| 取消 / 确认按钮 | 白底描边 + danger / primary 两档 | 与二次确认一致 |
|
||||
|
||||
仍待定:遮罩、圆角 8 vs 16、内边距 20 vs 24、标题字重、z-index 三档(50 / 100 / 110)收敛、footer 按钮间距、原语收敛方向(A 套 22 处直拼是最大人群)。
|
||||
|
||||
## 过渡期规则
|
||||
|
||||
- 新增业务弹窗优先 `OGDialogTemplate`;**不要再新增手拼 `AlertDialog` 弹窗**。
|
||||
- 纯「确认 / 取消」的二次确认不属于本页——用 `useConfirm()`(见「Confirm 二次确认」)。
|
||||
- 窄屏下弹层贴边距、footer 按钮等宽平铺(见「文档 → 设计规范 → 多端适配」)。
|
||||
|
||||
## 反馈评论弹窗 CommentDialog(共享壳)
|
||||
|
||||
「标题 + textarea + 取消/提交」的轻量共享壳(容器零 padding、header/body/footer 各 `px-5`、移动端 `calc(100%-48px)` 宽 + 标题居中 + 按钮等宽、每次打开重置草稿、可选 `submitting` 支持异步提交)。点踩反馈、权限申请等场景都用它,不要再手拼。
|
||||
**按内容的横向复杂度选档,不按内容长短选档**——内容长的解法是滚动,不是加宽。`large` 只装分栏内容与表格,长段落正文放 `medium`(一行 40 字,WCAG 1.4.8 给的中文行长上限)。窗口变窄时 `large` → `medium` → `small` 自动逐级降档(1280 / 1024 两个断点),任何情况都不超过「窗口宽度 − 32px」。把浏览器拉窄就能看见。
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import '~/locales/i18n';
|
||||
import store from '~/store';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { CommentDialog } from '~/components/ui/CommentDialog';
|
||||
import { Button, Modal, ModalClose } from '@bisheng/ui';
|
||||
|
||||
function Demo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [last, setLast] = useState('—');
|
||||
const SIZES = [
|
||||
{ size: 'small', label: 'small 400', desc: '单字段表单、短提示' },
|
||||
{ size: 'medium', label: 'medium 600(默认)', desc: '分组表单、带列表的内容' },
|
||||
{ size: 'large', label: 'large 960', desc: '多列表单、数据表格、需要预览区的内容' },
|
||||
{ size: 'fullscreen', label: '全屏', desc: '文件预览、大表格——内容本身要占满整屏' },
|
||||
];
|
||||
|
||||
export default () => {
|
||||
const [open, setOpen] = useState(null);
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button color="default" variant="outlined" onClick={() => setOpen(true)}>
|
||||
打开 CommentDialog
|
||||
</Button>
|
||||
<span style={{ fontSize: 12, color: 'rgb(var(--text-3))' }}>最近提交:{last}</span>
|
||||
<CommentDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="接入申请"
|
||||
placeholder="请填写申请原因(选填)"
|
||||
onSubmit={(c) => setLast(c || '(空)')}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
|
||||
{SIZES.map((item) => (
|
||||
<Button key={item.size} color="default" variant="outlined" onClick={() => setOpen(item.size)}>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
{SIZES.map((item) => (
|
||||
<Modal
|
||||
key={item.size}
|
||||
size={item.size}
|
||||
open={open === item.size}
|
||||
onOpenChange={(next) => setOpen(next ? item.size : null)}
|
||||
title={`${item.label} 弹窗`}
|
||||
footer={
|
||||
<>
|
||||
<ModalClose asChild>
|
||||
<Button color="default" variant="outlined">取消</Button>
|
||||
</ModalClose>
|
||||
<Button>确定</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{item.desc}
|
||||
</Modal>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
全屏档四角直角、不带遮罩,**头尾结构和普通弹窗一样**——标题左、关闭「×」右,操作按钮在底部,且**等宽平铺占满一行**(卡片档才是右对齐原宽)。**手机档(< 576px)所有尺寸一律走全屏档**,四档宽度和降档表都不再生效——把窗口拉到 576px 以下,上面任意一个都会变成全屏。
|
||||
|
||||
## 只有主体滚动
|
||||
|
||||
内容装不下时**只有主体那一段滚动**,头部和底部始终固定。弹窗最高不超过「窗口高度 − 64px」。用户滚到哪里都能看见标题和「保存」,不用滚回去找。
|
||||
|
||||
```tsx
|
||||
import { Button, Modal, ModalClose } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<RecoilRoot initializeState={({ set }) => set(store.lang, 'zh-Hans')}>
|
||||
<Demo />
|
||||
</RecoilRoot>
|
||||
<Modal
|
||||
trigger={<Button color="default" variant="outlined">打开长内容</Button>}
|
||||
title="服务条款"
|
||||
footer={
|
||||
<>
|
||||
<ModalClose asChild>
|
||||
<Button color="default" variant="outlined">暂不同意</Button>
|
||||
</ModalClose>
|
||||
<Button>同意</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<p key={i} style={{ marginBottom: 12, color: 'rgb(var(--text-2))' }}>
|
||||
第 {i + 1} 段:滚动时留意头部标题和底部按钮——它们不跟着走。
|
||||
</p>
|
||||
))}
|
||||
</Modal>
|
||||
);
|
||||
```
|
||||
|
||||
> demo 里的 `RecoilRoot` + `import '~/locales/i18n'` 只是文档站的挂载脚手架(组件内部用了 `useLocalize`);业务页在 App 全局已具备,无需自己包。
|
||||
## 三条关闭路径
|
||||
|
||||
普通弹窗给三条关闭路径,**一条都不能少**:头部的关闭按钮、点击遮罩、按 Esc。关闭「×」按**有没有取消按钮**决定:表单类弹窗必须有(那是它唯一的出口),带取消按钮的确认类用 `closable={false}` 去掉——两个出口说的是同一件事,反而让人犹豫该点哪个。
|
||||
|
||||
```tsx
|
||||
import { Button, Modal, ModalClose } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
|
||||
<Modal
|
||||
trigger={<Button color="default" variant="outlined">表单类:有「×」</Button>}
|
||||
title="新建频道"
|
||||
footer={<Button>创建</Button>}
|
||||
>
|
||||
表单弹窗的唯一出口就是右上角这个「×」,必须留着。
|
||||
</Modal>
|
||||
<Modal
|
||||
closable={false}
|
||||
trigger={<Button color="default" variant="outlined">确认类:无「×」</Button>}
|
||||
title="要放弃这次编辑吗"
|
||||
footer={
|
||||
<>
|
||||
<ModalClose asChild>
|
||||
<Button color="default" variant="outlined">继续编辑</Button>
|
||||
</ModalClose>
|
||||
<Button color="danger">放弃</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
底部两个按钮已经把话说完了,右上角不再给第二个出口。
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 有未保存内容 / 提交进行中
|
||||
|
||||
填了十分钟的表单,不能因为手滑点到遮罩就没了:`beforeClose` 返回 `false` 就把弹窗留住,业务页自己弹二次确认。提交进行中传 `submitting`,**三条路径全部禁用**,直到接口返回——接口还没回就关掉,用户不知道这一步到底成没成。
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { Button, Modal, ModalClose } from '@bisheng/ui';
|
||||
|
||||
export default () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [hint, setHint] = useState('');
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button color="default" variant="outlined" onClick={() => { setDraft(''); setHint(''); setOpen(true); }}>
|
||||
打开表单
|
||||
</Button>
|
||||
<span style={{ fontSize: 12, color: 'rgb(var(--text-3))' }}>{hint}</span>
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="填写反馈"
|
||||
submitting={submitting}
|
||||
beforeClose={(source) => {
|
||||
if (!draft) return true;
|
||||
setHint(`已拦截一次「${source}」关闭:内容还没保存`);
|
||||
return false;
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<ModalClose asChild>
|
||||
<Button color="default" variant="outlined" disabled={submitting}>取消</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setSubmitting(true);
|
||||
setHint('提交中,三条出口都锁住了');
|
||||
window.setTimeout(() => { setSubmitting(false); setOpen(false); setHint('已提交'); }, 2000);
|
||||
}}
|
||||
>
|
||||
{submitting ? '提交中…' : '提交'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="随便打几个字,再去点遮罩或按 Esc 试试"
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 96,
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgb(var(--border-base))',
|
||||
background: 'rgb(var(--bg-page))',
|
||||
color: 'rgb(var(--text-1))',
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
> `ModalClose` 包住的按钮不走 `beforeClose`——那道拦截是给「误触的出口」用的,用户瞄准了按的取消不算误触。
|
||||
|
||||
## API
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `title` | `ReactNode` | 必填 | 头部标题,同时是弹窗的无障碍名称 |
|
||||
| `size` | `'small' \| 'medium' \| 'large' \| 'fullscreen'` | `'medium'` | 四档尺寸;手机档一律全屏 |
|
||||
| `open` / `defaultOpen` / `onOpenChange` | — | — | 受控 / 非受控开关 |
|
||||
| `trigger` | `ReactNode` | — | 打开弹窗的元素,自动接上打开事件 |
|
||||
| `children` | `ReactNode` | — | 主体内容,唯一会滚动的区域 |
|
||||
| `description` | `ReactNode` | — | 标题下的说明文字,排在主体最上方 |
|
||||
| `footer` | `ReactNode` | — | 底部操作区;不传则整段不出现 |
|
||||
| `closable` | `boolean` | `true` | 头部的关闭「×」 |
|
||||
| `closeLabel` | `string` | `'Close'` | 「×」的无障碍名称(组件库不含文案,由业务传) |
|
||||
| `maskClosable` / `escClosable` | `boolean` | `true` | 关掉其中一条关闭路径 |
|
||||
| `submitting` | `boolean` | `false` | 提交中:三条路径全部禁用 |
|
||||
| `beforeClose` | `(source) => boolean \| void` | — | 返回 `false` 留住弹窗;`source` 为 `'close-button' \| 'overlay' \| 'esc'` |
|
||||
| `className` / `bodyClassName` / `footerClassName` / `overlayClassName` | `string` | — | 局部覆盖;`overlayClassName` 用于叠在别的浮层上时把遮罩置空 |
|
||||
| `container` | `HTMLElement` | `document.body` | portal 挂载点 |
|
||||
|
||||
## 不要用弹窗的场合
|
||||
|
||||
- 信息在页面里就地展示得下 → 行内区域。每个弹窗都是一次强制打断。
|
||||
- 用户要一边看着页面内容一边改 → 抽屉 Drawer。
|
||||
- 只有几行文字或两三个操作 → 气泡卡片 Popover。
|
||||
- 只是告诉用户「刚才那步成了」→ 轻提示 Toast。
|
||||
- 内容有自己的地址、要能分享 → 独立页面。
|
||||
- 纯「确认 / 取消」的破坏性操作 → 二次确认(见「组件 → Confirm 二次确认」),不要用 `Modal` 手拼。
|
||||
|
||||
**一次只开一个弹窗**:弹窗里不能再开弹窗,两层浮层叠着,用户数不清关几次才回得去。弹窗里可以开二次确认,那是一问一答,不算一层——那种情况给它的遮罩传 `overlayClassName="bg-transparent"`,同一时间只压暗一次页面。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
BRAND, BRAND_STEPS, GRAY, TEXT, FILL, BORDER, BG, FUNCTIONAL, TAG,
|
||||
FONT_FAMILY, TYPE_SCALE, FONT_WEIGHT, RADIUS, ICON_SIZE,
|
||||
FONT_FAMILY, TYPE_SCALE, FONT_WEIGHT, RADIUS, ICON_SIZE, Z_INDEX,
|
||||
} from '~/design-token.cjs';
|
||||
|
||||
export const mono = 'ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono", monospace';
|
||||
@@ -245,6 +245,18 @@ export const IconSizeTable = () => (
|
||||
/>
|
||||
);
|
||||
|
||||
export const ZIndexTable = () => (
|
||||
<TokenTable
|
||||
cols={[
|
||||
{ t: '变量名', r: (x) => <Name>z-{x.name}</Name> },
|
||||
{ t: '变量值', r: (x) => <Code>{x.value}</Code> },
|
||||
{ t: 'Tailwind 类', r: (x) => <Code>z-{x.name}</Code> },
|
||||
{ t: '用在哪', r: (x) => x.usage },
|
||||
]}
|
||||
rows={Z_INDEX}
|
||||
/>
|
||||
);
|
||||
|
||||
# 设计变量 Design Token
|
||||
|
||||
组件库的全局 Token——单一源为 <code>src/design-token.cjs</code>(SSOT),本页所有表格与色块直接从该文件渲染,**改 token 即改此页**,规范不会与组件实际取值漂移。业务代码只消费 Tailwind 类 / CSS 变量(semantic 层),**禁止裸 hex**。色块与「变量值」随右上角站点主题切换,展示对应的亮 / 暗色取值。
|
||||
@@ -326,3 +338,9 @@ export const IconSizeTable = () => (
|
||||
原始画布 24px、线宽 2;缩放只改容器尺寸,线宽随缩放视觉等比(xs / xl2 例外档单独标注)。
|
||||
|
||||
<IconSizeTable />
|
||||
|
||||
## 层级 Z-Index
|
||||
|
||||
浮层前后关系只有四档,新增浮层一律从这四档里选,**不自造 `z-[100]` / `z-[9999]` 这类数值**。排在上面的层要能盖住下面的:弹窗里的下拉菜单得展得开,弹窗里点保存轻提示得看得见,任何图标按钮都还要能弹出文字提示。规范正文见「文档 → 组件规范 → 弹窗 Modal」§5(层级唯一事实源)。
|
||||
|
||||
<ZIndexTable />
|
||||
|
||||
@@ -318,6 +318,7 @@ BISHENG 的投影走「淡」路线:带一点蓝黑色、透明度很低,把
|
||||
|
||||
- **圆角与投影一律取表内档位**:出现表外的手写值即视为未走规范;确属例外的,在对应组件文档里写明原因并注明「见圆角与阴影规范」。
|
||||
- **新档位先进规范、再进代码**:先由设计师在本文与 design token 里定档,之后才能出现在代码里,不允许先用后补。
|
||||
- **聚焦环不算投影档**:控件的聚焦指示环 `shadow-focus`(`0 0 0 2px`,颜色取变量 `--shadow-focus-ring`,默认灰阶第 2 档;错误 / 警告态由组件覆盖成对应 tint)用的是 box-shadow 的写法,但它表达的是「光标在哪」而不是浮起的高度,因此不进 §2.1 两档、也不受 §2.2「只用这两档」约束——按本节第一条写明原因的例外处理。取值与用法归口 [组件-Input输入框.md](组件-Input输入框.md) §5.1,2026-08-20 随输入框组件落地;值全部来自现有 token,没有新色。
|
||||
|
||||
{/* ## 附录 A:现状扫描存档(2026-07-23,仅迁移参考)——站点不渲染
|
||||
|
||||
@@ -387,4 +388,5 @@ BISHENG 的投影走「淡」路线:带一点蓝黑色、透明度很低,把
|
||||
| 2026-07-31 | v1.3 三件事:① **新增 §1.2「使用原则」模块**,原 §1.2–§1.5 收进其下成四条具名原则——**尺寸定档**(§1.2.1,原控件档 + 容器档合并)、**同级同档**(§1.2.2,新增:同一界面相同重要程度的模块圆角一致,与尺寸定档冲突时同级优先、统一取较大档)、**嵌套同心**(§1.2.3)、**贴边归零**(§1.2.4);正误对照升为 §1.3,增同级反例一行;新增 PeerDemo 图示;§3「使用约定」改名「例外与新增」避免与模块名相撞。② **文档改名**:「阴影与圆角规范 Elevation」→「圆角与阴影规范 Radius & Shadow」,文件名同步改为 基础-圆角与阴影规范.mdx,全站引用链接与侧栏一并刷新。③ 外部指针同步:多端适配原则索引表(贴边条目指 §1.2.4)、01-设计规范 §1 指针、00-总纲看板行 | 本文件(改名)、基础-多端适配原则.md、01-设计规范.md、00-总纲.md、组件-Tooltip文字提示.md、组件-Popover气泡卡片.md、组件-Modal弹窗.md、组件-Confirm二次确认.md、组件-Button按钮.md、rspress.config.ts | 待 committer 窗口提交 |
|
||||
| 2026-07-31 | v1.4 按元规范 §0.1「一条规则一句话」压缩展示层文案,**规则、数值、档位一条未增未减未改**:① 删自述式导语(§1 开头两句合一、§1.2 导语去「前两条定档后两条修正」的元叙述、§1.2.1 去「分两类先判断你在做哪一类」铺垫);② 删与图示 caption / 小节标题重复的解释(§1.2.2「读不成一组」原因句、§1.2.3 月牙形空隙反例、§1.2.4 全屏举例、§2.1 token 与类名同名同物一段);③ §1.2.1 由 4 条并为 3 条(「拿不准就用 md」并入控件条);④ §2.2 四条重排,「只用这两档」提到首位(最强约束先说),各条删重复论证;⑤ §1.3 / §2.3 原因列去冗词——**❌ 列一字未动**(元规范 §1 反例铁律);⑥ §3 第二条删「规范评审的全部含义」自指解释。展示层正文 3318 → 2702 字符(−19%,口径:`# 圆角与阴影规范` 至附录注释之间)。另修隐藏改动记录表头错字「改了什之」→「改了什么」。设计师 2026-07-31 要求:内容太多太复杂、读不下去,故只做减法;已知的两处规则空洞(拖拽元素用哪一档投影、圆角 × 投影配对表)暂不补 | 本文件 | 待 committer 窗口提交 |
|
||||
| 2026-08-13 | 两档投影 token 接线(给实现窗口 1):`design-token.cjs` SHADOW + `tailwindTheme.boxShadow`、tokens.css / client style.css 两处 CSS 变量、client tailwind.config.cjs / packages/ui tailwind-preset.cjs 两处 boxShadow —— `shadow-popup` / `shadow-modal` 类名自此可用(Toast 落地时先行需要)。**规范正文未改**,存量 shadow 迁移未动 | 本文件、design-token.cjs、tokens.css、style.css、tailwind.config.cjs、tailwind-preset.cjs | 待 committer 窗口提交 |
|
||||
| 2026-08-20 | §4 增一条例外:控件聚焦环 `shadow-focus`(几何写在类里、颜色走变量 `--shadow-focus-ring`,`design-token.cjs` 记为 `FOCUS_RING`,刻意不并进两档 SHADOW)不表达浮起高度,不算投影档,归口输入框规范 §5.1。**两档投影本身未改**,存量 shadow 迁移未动 | 本文件、design-token.cjs、tokens.css、style.css、tailwind.config.cjs、tailwind-preset.cjs | 待 committer 窗口提交 |
|
||||
*/}
|
||||
|
||||
@@ -64,7 +64,7 @@ hover 操作是鼠标悬停时的变色。手指没有「悬停」,点一下
|
||||
|---|---|---|---|
|
||||
| 弹窗底部按钮 | 等宽平铺占满一行 | 同桌面 | 右对齐,自适应宽 |
|
||||
| 页面主操作 | block 占满整行 | 同桌面 | 正常宽度 |
|
||||
| 弹窗位置 | 左右各留 16px,或从底部滑入 | 居中 | 居中 |
|
||||
| 弹窗位置 | 全屏(弹窗)/从底部上滑(抽屉),见各自组件文档 §7 | 居中 | 居中 |
|
||||
| 容器圆角 | 全屏时四角直角、贴底时仅顶部留圆角 | 同桌面 | 按容器档取圆角 |
|
||||
|
||||
平板档默认走桌面布局,只在组件文档另有说明时单独处理。先例:C 套确认弹窗、AlertDialog 底座。新组件照抄,不自造。
|
||||
@@ -91,7 +91,8 @@ hover 操作是鼠标悬停时的变色。手指没有「悬停」,点一下
|
||||
| Drawer 抽屉 | [组件-Drawer抽屉.md](组件-Drawer抽屉.md) §7 |
|
||||
| 圆角(全屏/贴底容器) | [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx) §1.2.4 贴边归零 |
|
||||
| State 状态页 | [组件-State状态页.md](组件-State状态页.md) §7 |
|
||||
| Modal / 字体 / Select … | 各组件文档补「移动端适配」节,建后登记于此 |
|
||||
| Modal 弹窗 | [组件-Modal弹窗.md](组件-Modal弹窗.md) §7 |
|
||||
| 字体 / Select … | 各组件文档补「移动端适配」节,建后登记于此 |
|
||||
|
||||
## 改动记录
|
||||
|
||||
@@ -103,5 +104,6 @@ hover 操作是鼠标悬停时的变色。手指没有「悬停」,点一下
|
||||
| 2026-07-14 | 定位从「手机适配」改为**多端适配**(v1.3):两断点 576/768 切手机/平板/桌面三档(与 platform 端一致);布局惯例表扩为三列,平板默认走桌面布局;client 断点现状混用的对齐策略记入「给实现窗口」 | 不提交 |
|
||||
| 2026-07-23 | 补容器圆角的多端处理:全屏时四角直角、贴底时仅顶部留圆角(规则家在[基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx) §1.4,本文布局惯例表与索引加指针) | 待 committer 窗口提交 |
|
||||
| 2026-07-31 | 圆角规范改版:文档改名「基础-圆角与阴影规范.mdx」(链接同步),贴边直角收进其 §1.2「使用原则」模块为 §1.2.4「贴边归零」,本文索引表指针同步 | 待 committer 窗口提交 |
|
||||
| 2026-08-20 | 索引新增 Modal 弹窗一行(规则家在[组件-Modal弹窗.md](组件-Modal弹窗.md) §7);布局惯例表「弹窗位置」手机档随 Modal v1 定稿改为「弹窗全屏/抽屉底部上滑」,细则指向各组件 §7。本文自己的四条原则未增未减未改 | 待 committer 窗口提交 |
|
||||
| 2026-08-14 | 索引新增 State 状态页一行(规则家在[组件-State状态页.md](组件-State状态页.md) §7)。本文规则未增未减未改 | 待 committer 窗口提交 |
|
||||
| 2026-08-04 | 索引新增 Drawer 抽屉一行(规则家在[组件-Drawer抽屉.md](组件-Drawer抽屉.md) §7)。本文规则未增未减未改 | 待 committer 窗口提交 |
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
## 7. 移动端适配
|
||||
|
||||
**手机档(< 576px)抽屉改为从底部上滑。** 手机没有「侧边」可言,从右侧滑进来只会铺满整屏;而且抽屉的关闭按钮在右上角,正是单手最够不着的位置。改从底部上滑,也和 BISHENG 弹窗在手机上的做法一致(见 [基础-多端适配原则.md](基础-多端适配原则.md) 布局惯例表)。
|
||||
**手机档(< 576px)抽屉改为从底部上滑。** 手机没有「侧边」可言,从右侧滑进来只会铺满整屏;而且抽屉的关闭按钮在右上角,正是单手最够不着的位置。改从底部上滑,拇指自然够得到。
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
@@ -121,7 +121,7 @@
|
||||
- 移动端底部形态与 AlertDialog 底座现有做法一致(贴底、仅顶部圆角),可直接复用其位移动画;安全区用 `padding-bottom: calc(12px + env(safe-area-inset-bottom))`,需 `viewport-fit=cover`。
|
||||
- 无障碍:模态 `role="dialog"` + `aria-modal="true"` + `aria-labelledby` 指向标题;非模态不加 `aria-modal`。焦点归还触发元素。
|
||||
- 动效曲线:进出统一 `cubic-bezier(0.2, 0, 0, 1)`;240ms / 200ms。
|
||||
- 遮罩与层级**不在本文定义**,随[组件-Modal弹窗.md](组件-Modal弹窗.md)统一(当前 z 有 50 / 100 / 110 三档并存,抽屉接入时一并收敛)。
|
||||
- 遮罩与层级**不在本文定义**,见[组件-Modal弹窗.md](组件-Modal弹窗.md) §5:遮罩黑 40% 无模糊,抽屉与弹窗同为一档 `z-index: 1000`。
|
||||
- 现有抽屉类实现(待盘点确认):`CreateKnowledgeSpaceDrawer`、`CreateChannelDrawer`、`VersionHistorySheet`、`RelateDocumentPanel`。
|
||||
- 新页上线:本文需在 `src/frontend/client/rspress.config.ts` 的 `themeConfig.sidebar` 注册入口,改完重启 dev 服务。
|
||||
|
||||
@@ -130,10 +130,10 @@
|
||||
- [ ] 三档宽度 400 / 600 / 800 需在真实页面比对验收(现有创建类抽屉实际宽度待量)
|
||||
- [ ] 非模态抽屉的首个落地场景由设计师点名
|
||||
- [ ] 移动端下拉关闭手势是否补做(第一版明确不做)
|
||||
- [ ] 遮罩样式与 z-index 随 Modal 规范一并定稿
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-20 | 遮罩与层级随 [组件-Modal弹窗.md](组件-Modal弹窗.md) v1 定稿:「给实现窗口」那条指针改为具体取值,待决策清单删去已结清的一项;§7 删去「与弹窗手机端做法一致」一句(弹窗手机档已改为全屏,抽屉仍是底部上滑)。**抽屉自己的规则未增未减未改** | 待 committer 窗口提交 |
|
||||
| 2026-08-04 | 建档 v1:先调研 antd / Arco / TDesign / Material 3 / Fluent 2 / SLDS 六家的抽屉与侧边面板定义,设计师拍板四项——宽度三档固定 px(不设全屏档)、PC 到断点逐级降一档、移动端改底部上滑(第一版不做下拉手势)、非模态作为独立档位一起定。成文 7 节 + 给实现窗口 | 待 committer 窗口提交 |
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# 输入框 Input
|
||||
|
||||
> 设计系统 · 输入框 v1 · 2026-08-20 建档
|
||||
> 与 [00-总纲.md](00-总纲.md)、[01-设计规范.md](01-设计规范.md) 配套;字号见 [基础-字体规范.mdx](基础-字体规范.mdx)、颜色见 [基础-色彩规范.mdx](基础-色彩规范.mdx)、圆角见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)、图标见 [基础-图标规范.mdx](基础-图标规范.mdx)、移动端通则见 [基础-多端适配原则.md](基础-多端适配原则.md)、文案见 [基础-文案规范.md](基础-文案规范.md)。
|
||||
> 调研来源(不进展示层):尺寸阶梯对齐 antd / TDesign 的 24/32/40 并与本站按钮同档;「聚焦不用主题色、灰描边 + 阴影」为设计师输入的品牌约束,业内同路线先例为 shadcn/ui(Vercel 系)的中性 ring 画法(常态灰边,focus 灰环,错误才上红);placeholder 与清除按钮的原则取自 Apple HIG。聚焦取值 2026-08-20 定稿:设计师拍板全部取现有 token(描边 border-deep、阴影环 gray-2),参照 client 消息提醒弹窗搜索框(ExpandableSearchField)的既有画法归并而来,归并口径见文末落地区。
|
||||
|
||||
## 1. 什么时候用
|
||||
|
||||
输入框用来收集**用户自己敲出来的文本**——名称、链接、密码、一段描述。
|
||||
|
||||
- 答案要用户自由输入,用输入框。
|
||||
- 答案在有限选项里挑,用选择器,别让用户打字。
|
||||
- 一行装不下的长内容,用多行文本域(见 §2)。
|
||||
|
||||
## 2. 形态 Type
|
||||
|
||||
先挑形态,再定尺寸;拿不准就用基础输入框。
|
||||
|
||||
| 形态 | 什么时候用 | 长相 |
|
||||
|---|---|---|
|
||||
| **基础 Input** | 绝大多数单行输入 | 白底 + 灰描边 |
|
||||
| **多行 Textarea** | 描述、备注等一行装不下的内容 | 同基础款,高度多行 |
|
||||
| **密码 Password** | 密码、密钥等敏感内容 | 默认密文,后缀「明暗切换」icon |
|
||||
| **搜索 Search** | 列表、表格上方的过滤与检索 | 前缀放大镜 icon,回车即搜 |
|
||||
| **前后置标签 addon** | 固定的协议前缀、单位(https://、元) | 标签与输入框拼成一体,标签用浅灰底 |
|
||||
|
||||
- **Textarea 默认三行高**,允许纵向手动拉伸、禁止横向;自动长高的场景要设上限,别让一个框吃掉整屏。
|
||||
- **addon 与输入框共享一条外描边**,圆角只保留整体的外侧两角;addon 用浅灰填充底 + 次要文字色,看上去「属于框」而不是一个按钮。
|
||||
- addon 只放「内容的一部分」(前缀、单位)。清除、明暗切换这类动作放后缀 icon,不做成 addon。
|
||||
|
||||
## 3. 尺寸 Size
|
||||
|
||||
三档,medium 是默认。高度与按钮同一套阶梯(见 [组件-Button按钮.md](组件-Button按钮.md) §3),同排控件天然对齐;高度定死,不要手写高度、内边距去凑。
|
||||
|
||||
| size | 高度 | 字号 / 行高 | 圆角 | 水平内边距 | 什么时候用 |
|
||||
|---|---|---|---|---|---|
|
||||
| `small` | 24px | 14 / 22 | 4px | 8px | 表格行内、紧凑工具条 |
|
||||
| `medium`(**默认**) | 32px | 14 / 22 | 6px | 12px | 绝大多数表单 |
|
||||
| `large` | 40px | 16 / 24 | 8px | 12px | 登录页、大表单 |
|
||||
|
||||
- 字号跟《字体规范》、圆角跟《圆角与阴影规范》控件档,随档取值不单独定义。
|
||||
- **Textarea 不分档**:字号 14 / 22、圆角 6px、内边距上下 8px 左右 12px。
|
||||
- **宽度不定档,跟随布局**:同一表单里同级字段等宽;只有内容长度天然固定的字段(验证码、端口号)才用短框暗示长度。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 输入框和旁边的按钮都用 medium | 32px 输入框配 40px 按钮 | 同排控件不同档,一高一低像没对齐的补丁 |
|
||||
| 直接选 `size` 档 | 手写 `height` / `padding` 凑尺寸 | 手写值会和三档慢慢漂移,同一表单里高矮不一 |
|
||||
|
||||
## 4. 内容形态
|
||||
|
||||
### 4.1 占位文字 placeholder
|
||||
|
||||
- **写例子或格式提示,不写字段名**——placeholder 输入后就消失,把字段名写进去,等于一开始打字就把「这是什么框」弄丢了。字段名交给表单的 label。
|
||||
- 用提示文字色(hint 档),和正文颜色拉开,见 [基础-色彩规范.mdx](基础-色彩规范.mdx)。
|
||||
|
||||
### 4.2 前缀 / 后缀 icon
|
||||
|
||||
- icon 尺寸随档取 14 / 16 / 18px,与文字间距 8px、small 档收紧到 4px,同《图标规范》与按钮一套。
|
||||
- **前缀说明「这个框是什么」**(放大镜、链接),**后缀承载「对内容的动作」**(清除、明暗切换)或单位。
|
||||
- 后缀最多两个动作 icon(如密码框的清除 + 明暗切换),再多说明该换控件。
|
||||
|
||||
### 4.3 一键清除
|
||||
|
||||
- 悬停或聚焦、且框内有内容时出现;点击清空并保持聚焦,方便立刻重输。
|
||||
- 搜索、筛选类输入框默认开启;正式表单字段默认不开——误触清掉一整段辛苦输入的代价太大。
|
||||
|
||||
### 4.4 字数统计
|
||||
|
||||
- 需要限长才显示,格式「当前 / 上限」:单行放后缀区,多行放框内右下角。
|
||||
- 达到上限后停止录入,计数转危险色提醒。
|
||||
- 上限宽裕、几乎不会触到的字段不开计数——计数本身就是一种输入压力。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| placeholder 写「如 138xxxx0000」 | placeholder 写「手机号」 | 字段名输入后就看不见了,用户回头检查时不知道这框是什么 |
|
||||
| 密码框后缀放明暗切换 icon | 把「显示密码」做成框外按钮 | 动作属于这个框,放后缀顺手可达;框外按钮撑乱表单排版 |
|
||||
| 备注字段限 500 字不开计数 | 每个输入框都挂「0 / 50」 | 触不到的上限不用提醒,满屏计数只制造焦虑 |
|
||||
|
||||
## 5. 状态 State
|
||||
|
||||
### 5.1 聚焦不用主题色
|
||||
|
||||
**BISHENG 输入框的聚焦是「灰描边加深 + 一圈灰色阴影」,不用主题色细描边。** 表单里输入框成片出现,聚焦只回答「光标在哪」,不承载语义;颜色留给真正需要说话的时刻——校验的危险色与警告色。
|
||||
|
||||
### 5.2 状态一览
|
||||
|
||||
常态 → 悬停 → 聚焦是同一条灰色渐进链,只变深浅、不换色相。
|
||||
|
||||
| 状态 | 样式 |
|
||||
|---|---|
|
||||
| 常态 default | 白底 + 灰描边(border-base) |
|
||||
| 悬停 hover | 描边加深为深边框色(border-deep),底色不变 |
|
||||
| 聚焦 active | 描边保持深边框色(border-deep),外加 2px 浅灰阴影环(gray-2) |
|
||||
| 只读 readonly | 浅灰填充底、描边同常态;可选中复制,聚焦不出阴影环 |
|
||||
| 禁用 disabled | 浅灰底 + 灰字 + 灰描边,与按钮 disabled 同一套(见 [组件-Button按钮.md](组件-Button按钮.md) §6),「禁止」光标 |
|
||||
| 错误 error | 描边转危险色,聚焦时阴影环同步换成危险色淡环 |
|
||||
| 警告 warning | 描边转警告色,聚焦时警告色淡环 |
|
||||
|
||||
- 边框只有 base / deep 两档(见「设计变量 Design Token」页),悬停与聚焦共用 deep 档,聚焦靠阴影环与悬停拉开——不为输入框另造第三档灰。
|
||||
- 错误、警告色取《色彩规范》功能色(淡环取对应 tint 档),禁用色一律引用按钮的取值,不另定义。
|
||||
- **错误提示文字放输入框下方**,用危险色,说清「怎么改」而不是只说「错了」;文案跟《文案规范》。
|
||||
- **错误阻断提交,警告不阻断**——警告是「能提交,但值得再看一眼」(如弱密码)。
|
||||
- **内容有效、只是不让改,用只读;整个字段当前无意义,才用禁用**——禁用的内容读屏读不到、也复制不走。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 聚焦用灰描边 + 灰阴影环 | 聚焦上主题色细描边 | 表单里框成片出现,聚焦不是语义时刻,主题色应留给校验反馈 |
|
||||
| 错误提示写「请输入 11 位手机号」 | 错误提示写「格式错误」 | 只说错了不说怎么改,用户还得自己猜规则 |
|
||||
| 系统生成的 ID 用 readonly 展示 | 用 disabled 展示还需复制的内容 | 禁用态复制不走、读屏也读不到,内容有效就不该禁用 |
|
||||
|
||||
## 6. 移动端适配
|
||||
|
||||
跨组件通则见 [基础-多端适配原则.md](基础-多端适配原则.md),这里只写输入框自己的细则。
|
||||
|
||||
| 项 | 触屏 / 窄屏规则 |
|
||||
|---|---|
|
||||
| 悬停 hover | 触屏没有悬停,关掉 hover 态,从常态直接进聚焦 |
|
||||
| 字号 | 全档位升到 **16px**——iOS 上小于 16px 的输入框聚焦会触发页面自动放大 |
|
||||
| 尺寸 | 触屏高频场景 small 直接升 medium;高度不足 44px 的档位用透明热区扩到 ≥44px(同按钮口径) |
|
||||
| 一键清除 | 聚焦且有内容时常驻显示(触屏没有悬停可言),热区 ≥44×44px |
|
||||
| 只读 / 禁用 / 错误 | 与桌面一致,无额外规则 |
|
||||
|
||||
<!-- site-hide -->
|
||||
## 落地(给实现窗口)
|
||||
|
||||
**v1 组件已落地(2026-08-20)**:`packages/ui/src/components/Input/` —— `Input.tsx`(单行基座)/ `Textarea.tsx` / `PasswordInput.tsx` / `SearchInput.tsx` / `shared.ts`(外壳 cva + 三档取值 + 受控&非受控取值、清空、热区聚焦的共用件),由 `@bisheng/ui` 导出,demo 页 `docs/components/input.mdx`。下面 1–5 条按落地实况回填,第 6 条仍未做。
|
||||
|
||||
1. ✅ cva `variants: { size, status, state }`(24 / 32 / 40,圆角 4 / 6 / 8,水平内边距含 1px 边框的 7 / 11 / 11),`status: error` 同步置 `aria-invalid`。Password / Search / addon 是同一基座填不同插槽的组合形态,没有另一套 API:`PasswordInput` / `SearchInput` 都只是 `Input` 的薄包装。**边框、填充、圆角、聚焦环全画在外壳 `div` 上,`<input>` 自身透明无边框**——这正是前后缀与 addon 能共用一条描边的原因。
|
||||
2. ✅ 颜色全走 token,组件内无裸 hex:常态描边 `border-border-base`;disabled 复用按钮三 token;错误 / 警告取功能色 main 档,聚焦环取对应 tint。
|
||||
3. ✅ **聚焦取值**:hover / 聚焦描边 `border-border-deep`;聚焦环为 Tailwind 类 `shadow-focus`(`tailwind-preset.cjs` + client `tailwind.config.cjs`,SSOT 记在 `design-token.cjs` 的 `FOCUS_RING`,**刻意不并进两档 SHADOW**:它是聚焦指示不是高度,按《圆角与阴影规范》§4 例外条款)。**只有环的颜色是变量 `--shadow-focus-ring`(默认 `--fill-2`),2px 的几何写在类里**——原本想落成一个 `--shadow-focus: 0 0 0 2px rgb(var(--shadow-focus-ring))` 变量,实测不成立:自定义属性里的 `var()` 在**声明它的那一层**就被替换掉,写在 `:root` 就等于把环色钉死在 `:root` 的取值上,错误 / 警告态再怎么覆盖 `--shadow-focus-ring` 都换不动(落地当天踩到并改掉)。现在错误 / 警告态只覆盖 `--shadow-focus-ring` 为 danger-tint / warning-tint,环色在元素上解析。⬜ 归并 `ExpandableSearchField` 的两个裸 hex 与它的 `rounded-lg` 仍待迁移窗口。
|
||||
4. ✅ placeholder 走 `text-text-3`(hint 档);字数统计常态 hint、达上限转危险色,单行在后缀区、多行在框内右下角(`bg-inherit` 垫底,滚动的正文从它下面过)。`showCount` 必须配 `maxLength`,缺了就不渲染。
|
||||
5. ✅ iOS 防缩放:`.input-no-zoom` 在 `@media (max-width: 768px), (hover: none) and (pointer: coarse)` 内把 input / textarea 提到 16px / 24,用 `input.input-no-zoom` 元素+类选择器写,确保压得住档位自己的 `text-[length:…]`。另补 `.input-touch-hit`:**上下各一条透明热区**把可点区域撑到 ≥44px,而不是像按钮那样盖一整块——盖在框上的覆盖层会吃掉「点到第几个字」的那一下,光标只能落到末尾。清除 / 明暗切换按钮仍用按钮那套 `btn-touch-hit`。
|
||||
6. ⬜ 现状扫描未做:client 内现存输入框体系(`components/ui/Input`、原生 `<input>` 手拼等)的用量与迁移映射,待迁移窗口扫描后补附录。组件落地不动任何存量调用点。
|
||||
|
||||
## 待决策清单
|
||||
|
||||
- 聚焦阴影环 gray-2 为不透明色,放在非白底(如 fill-subtle 浅灰底卡片内)上会露一圈浅边;若后续出现灰底表单场景,再议是否改半透明等效值(现按白底表单为主定稿)。
|
||||
- 字数统计超限策略现按「截断录入」写;antd 另有「允许超出、计数标红、提交时拦」路线,若表单校验需要再议。
|
||||
- **`bisheng-icons` 没有 eye 图标**,而组件库契约不许自己画图标,所以 `PasswordInput` 的明暗切换两个图标(`revealIcon` / `hideIcon`)暂由调用方传入。图标包补上之后收成默认值,prop 保留给需要换图标的场景。
|
||||
- Textarea 自动长高未内建:§2 要求它必须设上限,而目前没有调用场景来定这个上限,等真有场景再定 `autoSize` 的 API 形状。
|
||||
- Form 表单项(label、必填标记、错误文字字号与间距)未建档,错误提示的排版细节届时归口 Form 规范,本文只定「框下方 + 危险色 + 说清怎么改」。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-20 | 建档 v1:形态五种(基础 / Textarea / Password / Search / addon)、尺寸三档 24/32/40 对齐按钮阶梯、内容形态四件套(placeholder / 前后缀 / 清除 / 字数)、状态七态含 readonly 与 warning;定稿「聚焦不用主题色,灰描边 + 灰阴影环」原则,具体取值待设计师输入(见待决策清单);移动端 iOS 16px 防缩放规则 | 待 committer 窗口提交 |
|
||||
| 2026-08-20 | **聚焦取值定稿**:设计师拍板全取现有 token——hover / 聚焦描边 border-deep(gray-4),聚焦加 2px gray-2 阴影环,错误 / 警告环换对应 tint 档;归并自 ExpandableSearchField 现值(#DDDDDD→gray-4 按意图、#F1F5F9→gray-2 肉眼无差),口径见落地区第 3 条;待决策清单勾销聚焦两项,新记「灰底场景环色再议」 | 待 committer 窗口提交 |
|
||||
| 2026-08-20 | 站点接线(元规范 §5 上线清单):`rspress.config.ts` 侧栏「组件规范」注册入口、00-总纲 §四 进度看板由「⬜ 待办 / 待建」改写为 v1 规范行、01-设计规范 §0 索引加行。**文档内容未改**;`components/input.mdx` demo 页未建——`Input.tsx` 尚未落地,照 Tooltip / Popover / Drawer 的先例规范先行,组件落地后再补 | 待 committer 窗口提交 |
|
||||
| 2026-08-20 | **组件 v1 落地**:`packages/ui/src/components/Input/`(`Input` / `Textarea` / `PasswordInput` / `SearchInput`),三档尺寸、四态外壳、前后缀 / addon / 清除 / 字数、聚焦灰环全部按本文实现;新增环色 token `--shadow-focus-ring` + Tailwind 类 `shadow-focus`(`design-token.cjs` `FOCUS_RING`、两处 tailwind 配置、tokens.css 与 client style.css 双份同步;环的几何刻意留在类里,原因见落地区第 3 条)与两条 CSS 规则 `.input-no-zoom` / `.input-touch-hit`;demo 页 `components/input.mdx` 上线并注册侧栏「数据录入 Data Entry」。文档内容随实况回填落地区,新增两项待决策(eye 图标缺位、Textarea 自动长高) | 待 committer 窗口提交 |
|
||||
@@ -1,71 +1,170 @@
|
||||
# 组件改造 · Modal 弹窗
|
||||
# 弹窗 Modal
|
||||
|
||||
> 状态:🟨 进行中 · 优先级最高(第 1 个改造的组件)
|
||||
> 本文以现状梳理与收敛台账为主,弹窗的视觉标准多数仍在等设计师拍板,未定项集中列在 §4。已随其它规范定下的部分见 §3。
|
||||
> 接手本组件前先读 [00-总纲.md](00-总纲.md)。配套:圆角与投影见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)、按钮见 [组件-Button按钮.md](组件-Button按钮.md)、颜色见 [基础-色彩规范.mdx](基础-色彩规范.mdx)、移动端通则见 [基础-多端适配原则.md](基础-多端适配原则.md)。二次确认弹窗是弹窗的专用子类,已独立成文,见 [组件-Confirm二次确认.md](组件-Confirm二次确认.md)。
|
||||
> 用量台账、迁移记录、代码锚点等实现细节走文末隐藏区。2026-07-30 对照《元-文档撰写规范》重排:阿拉伯编号、正文/隐藏区分层、同步已定的 16px 圆角与模态投影;设计决策一项未替设计师拍板。
|
||||
> 设计系统 · v1 · 2026-08-20
|
||||
> 适用:在页面正中打开的打断式浮层,响应式 Web 三档(手机 / 平板 / 桌面)。
|
||||
> 通用规则见 [基础-多端适配原则.md](基础-多端适配原则.md)、[基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)、[基础-色彩规范.mdx](基础-色彩规范.mdx)、[组件-Button按钮.md](组件-Button按钮.md);二次确认是弹窗的专用子类,已独立成文,见 [组件-Confirm二次确认.md](组件-Confirm二次确认.md)。
|
||||
> 用量台账、迁移记录、代码锚点走文末隐藏区。
|
||||
|
||||
## 1. 这是什么、现在什么状态
|
||||
弹窗盖在页面正中,要求用户先处理完(确认、填表、看提示)才能回到下面的内容。它和抽屉的区别只有一个:**抽屉留着页面的边,弹窗不留**。
|
||||
|
||||
弹窗是**打断式的浮层**——盖在页面之上,要求用户先处理完(确认、填表、看提示)才能回到下面的内容。
|
||||
## 1. 什么时候用弹窗
|
||||
|
||||
BISHENG 现在的问题是**同样是弹窗、却有好几套并行**,遮罩深浅、圆角、层级各不相同,看起来不像一个产品。本文的任务就是把它们收敛成一套标准。
|
||||
用弹窗:**这件事要用户当场做个决定或完成一小段任务,做完就回到原处**。改名、填一张短表、看一条必须知道的提示,都是。
|
||||
|
||||
<!-- site-hide:start -->
|
||||
目前进度:
|
||||
不用弹窗,改用别的:
|
||||
|
||||
- **「二次确认」这一子类已基本收敛**到一处实现(一套样式、改一处全生效),它的规范已独立成文,见 [组件-Confirm二次确认.md](组件-Confirm二次确认.md)。
|
||||
- **其余弹窗(表单弹窗、提示弹窗等)的视觉标准还在等设计师定**,未定项见 §4。
|
||||
- **圆角与投影已随《圆角与阴影规范》定下**(弹窗 16px 圆角 + 模态投影档),见 §3。
|
||||
| 情况 | 用什么 | 为什么 |
|
||||
|---|---|---|
|
||||
| 信息在页面里就地展示得下 | 行内区域 | 每个弹窗都是一次强制打断 |
|
||||
| 用户要一边看着页面内容、一边改 | 抽屉 Drawer | 弹窗居中夺焦,会把要参照的内容盖住 |
|
||||
| 只有几行文字或两三个操作 | 气泡卡片 Popover | 弹窗的打断比内容还重 |
|
||||
| 只是告诉用户「刚才那步成了」 | 轻提示 Toast | 成功反馈不值得要一次点击才能消失 |
|
||||
| 内容有自己的地址、要能分享 | 独立页面 | 弹窗没有网址,也接不住浏览器的返回键 |
|
||||
|
||||
<!-- site-hide:end -->
|
||||
**一次只开一个弹窗。** 弹窗里不能再开弹窗——两层浮层叠着,用户数不清关几次才回得去。弹窗里可以开二次确认,那是一问一答,不算一层。
|
||||
|
||||
## 2. 什么时候用弹窗
|
||||
**破坏性、不可逆的操作要配二次确认**(删除、移交、清空),见 [组件-Confirm二次确认.md](组件-Confirm二次确认.md)。
|
||||
|
||||
- **需要打断用户、让他当场做个决定或完成一小段任务时,用弹窗**:删除确认、快速表单、重要提示。
|
||||
- **能不打断就不打断**。信息能在页面里就地展示的,用行内区域、抽屉或气泡,别动辄弹窗——每个弹窗都是一次强制打断。
|
||||
- **一次只开一个弹窗**。弹窗套弹窗会让用户迷路,也说不清关掉一层会回到哪。
|
||||
- **破坏性、不可逆的操作要配二次确认**(删除、移交、清空),见 [组件-Confirm二次确认.md](组件-Confirm二次确认.md)。
|
||||
## 2. 尺寸四档
|
||||
|
||||
## 3. 已随基础规范定下的项
|
||||
| 档位 | 宽度 | 装什么 |
|
||||
|---|---|---|
|
||||
| small | **400px** | 单字段表单、短提示、确认类 |
|
||||
| medium(默认) | **600px** | 分组表单、带列表的内容 |
|
||||
| large | **960px** | 多列表单、数据表格、需要预览区的内容 |
|
||||
| 全屏 | 铺满窗口 | 文件预览、宽表格;手机档的固定形态(§7) |
|
||||
|
||||
下面两项已在《圆角与阴影规范》定稿,弹窗直接遵循,不再单列待决策:
|
||||
**按内容的横向复杂度选档,不按内容长短选档。** 内容长的解法是滚动,不是加宽——加宽只会让每行文字变长、更难读。
|
||||
|
||||
- **圆角 16px**:所有弹窗统一 16px(容器档 `2xl`)。见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)。
|
||||
- **模态投影**:弹窗、抽屉用「模态投影」档(大而弥散、浮得最高),可搭配 1px 浅色边框。见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)。
|
||||
**large 档装分栏内容与表格,不装长段落正文。** 长正文放 medium:它的内容区一行正好排 40 个汉字,是无障碍标准(WCAG 1.4.8)给出的中文行长上限;large 一行 66 字,眼睛回扫时会频繁跳错行。
|
||||
|
||||
## 4. 尚未拍板的点(等设计师定)
|
||||
**弹窗最高不超过 `窗口高度 - 64px`**,超出的部分只在主体区滚动,头尾始终看得见。上下各留 32px,弹窗才不会看起来像顶到了屏幕边。
|
||||
|
||||
除二次确认子类外,下面这些是普通弹窗要统一、但**尚未拍板**的点。设计师定了哪一项,就把它从这里移走、写成正式规范。
|
||||
**全屏档按内容类型选,不按窗口宽度选**,它不参与 §3 的降档:手机档(< 576px)自动走全屏;桌面上只给「宽到 960px 还装不下的单件内容」——文件预览、要横向滚动才看得全的宽表格。**内容多不是用全屏的理由**,多的解法是滚动,只有内容宽才轮到它;而一个流程要分好几步走完的,那件事本身应该是一个独立页面。
|
||||
|
||||
- [ ] **遮罩**:现在是「浅黑毛玻璃」(A 套 `bg-black/40` + 模糊)和「灰白毛玻璃」(B/C 套 `bg-gray-500/90` + 模糊)二选一——定一套,全局统一。
|
||||
- [ ] **内边距**:header / body / footer 各多少?现有 20px(`p-5`)与 24px(`p-6`)并存,二次确认先例是 20px。
|
||||
- [ ] **标题**:字号与字重?现有 `text-base` 下 `font-semibold` 与 `font-medium` 并存。
|
||||
- [ ] **关闭按钮**:普通弹窗是否保留右上角「×」,样式与位置如何?
|
||||
- [ ] **footer 按钮**:直接用 Button 组件,还是沿用二次确认那对按钮?间距多少?(按钮排序可对齐 [组件-Button按钮.md](组件-Button按钮.md) 的 footer 规则:主按钮最右、危险场景主位放危险红实心。)
|
||||
- [ ] **层级 z-index**:现有 `z-50` / `z-[100]` / `z-[110]` 三档并存,统一时需连 Drawer / Sheet / Popover 的叠放关系一起盘。
|
||||
- [ ] **原语收敛方向**:A 套原语直拼是最大人群(约 22 处),是「把 A 套壳改成标准、业务零改动」,还是「逐批迁到 B 套」?
|
||||
**全屏档四角直角、不带遮罩**(遮罩底下已经什么都看不见了)。头尾结构与普通弹窗完全一致——头部高 56px、标题左、关闭「×」右,操作按钮在底部;铺满屏幕换的是内容区,不是用户已经记住的那套位置。**底部按钮等宽平铺占满一行**:这么宽的一行里,右下角两个按原宽挤在一起,跟左上角的标题隔了一整个屏幕。
|
||||
|
||||
## 3. 窗口变窄时逐级降档
|
||||
|
||||
弹窗宽度**不随大屏变宽**,只随小屏降档:
|
||||
|
||||
| 窗口宽度 | small | medium | large |
|
||||
|---|---|---|---|
|
||||
| ≥ 1280px | 400 | 600 | 960 |
|
||||
| 1024–1280px | 400 | 600 | **600** |
|
||||
| 768–1024px | 400 | **400** | **400** |
|
||||
| 576–768px(平板) | 400 | 400 | 400 |
|
||||
| < 576px(手机) | 见 §7 移动端适配 | | |
|
||||
|
||||
**全屏档不在这张表里**——它由内容类型决定,不随窗口宽度升降,见 §2。
|
||||
|
||||
另有一条兜底,任何情况都生效:**弹窗最宽不超过 `窗口宽度 - 32px`**。左右各留 16px,弹窗才不会看起来像一块贴死在屏幕上的面板。
|
||||
|
||||
大屏不加宽的原因:24 寸显示器上把 960px 的表单拉成 1400px,只会让每行更难读。
|
||||
|
||||
## 4. 结构与间距
|
||||
|
||||
圆角 16px、模态投影,取值见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)。从上到下三段:
|
||||
|
||||
| 区域 | 内边距 | 说明 |
|
||||
|---|---|---|
|
||||
| 头部 Header | 高 56px,左右 16px | 标题 16px / 字重 500,左对齐;关闭按钮 24px,靠右 |
|
||||
| 主体 Body | 左右 16px,上下不留 | 上下的空隙由头部高度和底部内边距让出来;内容装不下时**只有这里滚动** |
|
||||
| 底部 Footer | 16px | 按钮右对齐(全屏档与手机档改为等宽平铺占满一行);没有操作按钮时整段不出现 |
|
||||
|
||||
**头部和底部始终固定,只有主体滚动。** 用户滚到哪里都能看见标题和「保存」,不用滚回去找。
|
||||
|
||||
**关闭「×」按有没有取消按钮决定**:表单类弹窗必须有,那是它唯一的出口;带取消按钮的确认类不给「×」——两个出口说的是同一件事,反而让人犹豫该点哪个。
|
||||
|
||||
底部按钮直接用 Button 组件,排列顺序、间距与最小宽度见 [组件-Button按钮.md](组件-Button按钮.md):主按钮最右,危险场景主位放危险红实心。
|
||||
|
||||
## 5. 遮罩与层级
|
||||
|
||||
**遮罩用黑色 40% 不透明,不加模糊。** 遮罩的作用是把页面压暗、让注意力收到弹窗上;一旦模糊到认不出底下是什么,用户会以为自己跳到了新页面,然后去按返回键。
|
||||
|
||||
**同一时间只有一层遮罩。** 弹窗里再开二次确认,不叠第二层——叠两层等于把页面又压暗一次,用户会以为自己陷得更深了。
|
||||
|
||||
浮层的前后关系分四档,新增浮层一律从这四档里选,不自造数值:
|
||||
|
||||
| 层 | z-index |
|
||||
|---|---|
|
||||
| 弹窗 · 抽屉(含各自的遮罩) | **1000** |
|
||||
| 气泡卡片 · 下拉菜单 | **1100** |
|
||||
| 轻提示 Toast | **1200** |
|
||||
| 文字提示 Tooltip | **1300** |
|
||||
|
||||
排在上面的层要能盖住下面的:弹窗里的下拉菜单得展得开,弹窗里点保存、轻提示得看得见,而任何一个图标按钮——包括弹窗里的——都还要能弹出文字提示。
|
||||
|
||||
## 6. 打开与关闭
|
||||
|
||||
出现 **200ms**(淡入,同时从 96% 放大到 100%),消失 **160ms**(只淡出,不缩小)。出场比入场快一点——用户关闭时已经做完决定了,等待是纯粹的浪费。
|
||||
|
||||
普通弹窗给三条关闭路径,**一条都不能少**:
|
||||
|
||||
1. 头部的关闭按钮
|
||||
2. 点击遮罩
|
||||
3. 按 Esc 键
|
||||
|
||||
**弹窗里有没保存的内容时,点遮罩和按 Esc 都要先弹二次确认。** 填了十分钟的表单,不能因为手滑点到遮罩就没了。
|
||||
|
||||
**提交进行中,三条路径全部禁用**,直到接口返回。接口还没回就关掉,用户不知道这一步到底成没成。
|
||||
|
||||
全屏弹窗没有遮罩可点,只保留关闭按钮和 Esc。
|
||||
|
||||
焦点:打开时焦点移入弹窗、Tab 在弹窗内循环,关闭后焦点回到打开它的那个按钮。
|
||||
|
||||
## 7. 移动端适配
|
||||
|
||||
**手机档(< 576px)弹窗一律走全屏档**,§2 的四档宽度和 §3 的降档表都不再生效。手机屏幕本来就窄,居中弹窗左右都快贴边了,留那点缝既装不下内容、也证明不了「你还在原来那页」——不如把整屏交给这件事。
|
||||
|
||||
全屏形态见 §2:铺满窗口、四角直角、不带遮罩,头尾结构不变(头部高 56px、标题左、关闭「×」右,操作按钮在底部等宽平铺)。
|
||||
|
||||
**底部另有操作条时,按钮等宽平铺占满一行,并避开手机底部安全区。**
|
||||
|
||||
其余通用规则(触屏不显示 hover、可点范围 ≥ 44×44px、控件文字不放大)见 [基础-多端适配原则.md](基础-多端适配原则.md)。
|
||||
|
||||
## 给实现窗口(技术细节,设计师可跳过)
|
||||
|
||||
**壳已落地为组件库组件 `@bisheng/ui` 的 `Modal`**(`packages/ui/src/components/Modal/Modal.tsx`,实时预览见 components/modal.mdx):本文 §2–§7 的取值——四档宽度与降档表、遮罩、层级、结构与间距、200/160ms 动效、三条关闭路径与提交锁、手机档全屏——全部写死在壳里,业务页只传内容。下列条目是它的实现口径,同时记录尚未收口的部分。
|
||||
|
||||
- **降档断点用 1024 / 1280**,即 Tailwind 的 `lg:` / `xl:`。二者属桌面档(>768)内部的细分排版,不是新增档位断点,符合[基础-多端适配原则.md](基础-多端适配原则.md)「不自造断点」。档位断点仍只有 576 / 768 两个。
|
||||
- 宽度实现:`width: min(<档位>, calc(100vw - 32px))`,降档表用 `lg:` / `xl:` 前缀覆盖,不写 JS 计算。高度:`max-height: calc(100vh - 64px)`,body 区 `overflow-y: auto` + `overscroll-behavior: contain`。
|
||||
- 遮罩落地:`rgba(0, 0, 0, 0.4)`,**不加模糊**。毛玻璃已于 2026-08-04 随全站 `backdrop-blur` 清除下线(client 33 处 + platform 7 处),现存三套壳都不带模糊,**只剩颜色要迁**:A 套的 `bg-black/40` 数值即最终值,B / C 套的 `bg-gray-500/90` 改过来——二次确认那 16 处随 `ConfirmContext` 一处生效。
|
||||
- 嵌套时不叠遮罩:二次确认开在弹窗之上时,把它的遮罩置为透明(弹窗那层已经在压暗页面)。
|
||||
- z-index 四档已写成 token(`--z-modal: 1000` / `--z-popover: 1100` / `--z-toast: 1200` / `--z-tooltip: 1300`):`design-token.cjs` 的 `Z_INDEX` 表为名称与取值的 SSOT,两个运行时载体(`packages/ui/src/styles/tokens.css` + `client/src/style.css`)与两份 Tailwind 配置同步落地,类名 `z-modal` / `z-popover` / `z-toast` / `z-tooltip`;组件库 Toaster 容器的临时值 `z-[9999]` 已归并到 1200。**client 现存的 `z-50` / `z-[100]` / `z-[110]` 随两套壳收敛时替换,尚未动。** 本表为层级唯一事实源,[01-设计规范.md](01-设计规范.md) §5 改为指针。
|
||||
- 动效曲线:进出统一 `cubic-bezier(0.2, 0, 0, 1)`;200ms / 160ms。缩放只做入场,出场纯淡出(缩小会让人误以为「收回到某处」)。落地为 `modal-overlay-in/out` + `modal-content-in/out` 四条 keyframes(两份 Tailwind 配置同步)。两个坑:入场缩放写**独立的 `scale` 属性**而不是 `transform: scale()`,否则会和居中用的 `translate(-50%, -50%)` 打架(手机档又是 `inset: 0`,两档不能共用一条 transform);卡片不能再包一层居中 div——`Dialog.Portal` 会给每个子节点各套一个 `Presence`,没有自己动画的那层 div 一关就整棵卸载,出场动画根本来不及播。
|
||||
- 无障碍:`role="dialog"` + `aria-modal="true"` + `aria-labelledby` 指向标题;焦点陷阱在弹窗内,关闭后归还触发元素。全屏档同样带 `aria-modal`。
|
||||
- 手机档全屏:`< 576px` 时容器 `inset: 0`、圆角归零、不渲染遮罩层;现有 AlertDialog 底座的「贴底 + 仅顶部圆角」写法在弹窗上作废(抽屉仍保留)。底部操作条的安全区用 `padding-bottom: calc(16px + env(safe-area-inset-bottom))`,需 `viewport-fit=cover`。
|
||||
- **原语收敛**:组件库 `Modal` 落地后多出第三条路——业务页逐步迁到 `@bisheng/ui` 的 `Modal`(与 Button / Toast / StateView 同一收敛方式,client 侧保留旧路径的 re-export 壳)。原「改 A 套 `Dialog.tsx` 的壳、业务零改动」仍是成本最低的过渡手段,两者可并行:先改壳止血,再按页迁到组件库。A 套原语直拼 22 处是最大人群(见下方台账 §1.2);B 套模板 `OGDialogTemplate` 随二次确认收官后退役([组件-Confirm二次确认.md](组件-Confirm二次确认.md) §4.3 第 4 步)。**client 两套壳一处未动,迁移批次待排。**
|
||||
- 新页上线:本文已在 `src/frontend/client/rspress.config.ts` 的 `themeConfig.sidebar` 注册;改配置需重启 dev 服务。
|
||||
|
||||
## 待决策清单
|
||||
|
||||
- [ ] 四档宽度 400 / 600 / 960 / 全屏需在真实页面比对验收(组件库壳已可点,先在 components/modal 预览页比对)
|
||||
- [ ] client 两套壳的收敛批次:改 A 套壳止血 vs 直接按页迁到组件库 `Modal`,待核工作量后排期
|
||||
|
||||
<!-- site-hide -->
|
||||
## 5. 现状与用量台账(给实现窗口)
|
||||
## 现状与用量台账(给实现窗口)
|
||||
|
||||
> 以下为迁移排批次、估工作量用的现状数据,不进展示层。数字带扫描口径与日期,过时以最新一次重盘为准。
|
||||
|
||||
### 5.1 两套并行的弹窗体系(乱的根源)
|
||||
### 1.1 两套并行的弹窗体系(乱的根源)
|
||||
|
||||
BISHENG client 有两套弹窗,都包着同一个 Radix `@radix-ui/react-dialog`,但样式不同:
|
||||
|
||||
- **A 套(标准弹窗原语)**:原语 `src/components/ui/Dialog.tsx`、模板 `DialogTemplate.tsx`;遮罩 `bg-black/40` 半透明 + `backdrop-blur-md` 毛玻璃;层级 `z-[100]`。
|
||||
- **B 套(「Original」原语,OG 前缀)**:原语 `src/components/ui/OriginalDialog.tsx`(导出 `OGDialogContent` 等)、模板 `OGDialogTemplate.tsx`(用得最多);遮罩原为 `bg-black/80` 更黑无模糊(二次确认期已对齐灰白毛玻璃);层级 `z-50`。
|
||||
- **A 套(标准弹窗原语)**:原语 `src/components/ui/Dialog.tsx`、模板 `DialogTemplate.tsx`;遮罩 `bg-black/40` 半透明、无模糊;层级 `z-[100]`。
|
||||
- **B 套(「Original」原语,OG 前缀)**:原语 `src/components/ui/OriginalDialog.tsx`(导出 `OGDialogContent` 等)、模板 `OGDialogTemplate.tsx`(用得最多);遮罩原为 `bg-black/80`,二次确认期对齐 C 套改为 `bg-gray-500/90`(当时同带毛玻璃,2026-08-04 已清除);层级 `z-50`。
|
||||
|
||||
| 维度 | A 套(Dialog) | B 套(OriginalDialog / OG) |
|
||||
|---|---|---|
|
||||
| 遮罩颜色 | `bg-black/40`(浅) | `bg-gray-500/90`(已对齐 C 套;原 `bg-black/80`) |
|
||||
| 毛玻璃模糊 | 有 `backdrop-blur-md` | 二次确认期已加 |
|
||||
| 毛玻璃模糊 | 无 | 无(两套 2026-08-04 随全站 `backdrop-blur` 清除) |
|
||||
| 层级 z-index | `z-[100]` | `z-50` |
|
||||
| 便捷模板 | DialogTemplate | OGDialogTemplate |
|
||||
|
||||
### 5.2 Modal 期用量重盘(2026-07-09 扫描,最新,§5.1 旧数字以此为准)
|
||||
> v1 定稿后两套的目标值:遮罩 `rgba(0,0,0,0.4)` 无模糊、层级 1000、圆角 16、内边距 16(主体只留左右)、标题 `text-base font-medium`。
|
||||
|
||||
### 1.2 Modal 期用量重盘(2026-07-09 扫描,最新,§1.1 旧数字以此为准)
|
||||
|
||||
> 画廊 Modal 版块已按本节重做。
|
||||
|
||||
@@ -80,26 +179,55 @@ BISHENG client 有两套弹窗,都包着同一个 Radix `@radix-ui/react-dialo
|
||||
|
||||
**壳解剖当前真实值**(源码核对):
|
||||
|
||||
- A 套:遮罩 `bg-black/40` + blur、`z-[100]`、`sm:rounded-lg`(8px 移动端直角)、`p-5`、border + `shadow-lg`、标题 `text-base font-semibold`、暗色底 `dark:bg-[#303134]` 写死。
|
||||
- B 套(已对齐 C 套):遮罩 `bg-gray-500/90` + blur、`z-50`、`rounded-2xl`(16px)、`p-5`、border `#ebebeb` + 淡投影、标题 `text-base font-medium`、`bg-background` 跟主题。
|
||||
- AlertDialog 底座:遮罩同 B、`z-[110]`、`sm:rounded-lg`、`p-6`、无边框无阴影、无内置关闭钮、移动端从底部滑入贴底。
|
||||
- 层级三档并存:`z-50` / `z-[100]` / `z-[110]`,统一时需盘 Drawer / Sheet / Popover 关系。
|
||||
- A 套:遮罩 `bg-black/40` 无模糊、`z-[100]`、`sm:rounded-lg`(8px 移动端直角)、`p-5`、border + `shadow-lg`、标题 `text-base font-semibold`、暗色底 `dark:bg-[#303134]` 写死。
|
||||
- B 套(已对齐 C 套):遮罩 `bg-gray-500/90` 无模糊、`z-50`、`rounded-2xl`(16px)、`p-5`、border `#ebebeb` + 淡投影、标题 `text-base font-medium`、`bg-background` 跟主题。
|
||||
- AlertDialog 底座:遮罩同 B(`bg-gray-500/90` 无模糊)、`z-[110]`、`sm:rounded-lg`、`p-6`、无边框无阴影、无内置关闭钮、移动端从底部滑入贴底。
|
||||
- 层级三档并存:`z-50` / `z-[100]` / `z-[110]`,v1 统一为 1000。
|
||||
|
||||
### 5.3 死代码线索
|
||||
### 1.3 死代码线索
|
||||
|
||||
- `OriginalDialog` 已确认**不是**死代码(被 OGDialogTemplate 内部 import)。
|
||||
- SidePanel 死树:`SidePanelGroup.tsx:117-132` 整段被注释、面板不渲染,牵连约 5 处弹窗(书签删除 + 删 Agent / Agent 工具 / 删 Assistant / Assistant 工具)为死 UI,随死代码清理处置,不迁。
|
||||
- default 变体挂 `btn-brand-primary` 类的换肤 hack 与 Modal 无关,另见按钮文档。
|
||||
|
||||
### 1.4 v1 调研存档(2026-08-20)
|
||||
|
||||
七家弹窗定义的关键数值,供后续复核:
|
||||
|
||||
| 维度 | 业内区间 | BISHENG v1 取值与理由 |
|
||||
|---|---|---|
|
||||
| 宽度 | 15 家里 8 家用档位制、6 家单值;**4 档最常见,无一家超 5 档**。数值聚成五簇:min-width 地板 280–320、小档 380–464、中文体系默认 480–560、跨体系默认 600–640(9 次,最密集)、大档 900–980 | 四档 400 / 600 / 960 / 全屏。400 落在小档簇正中(Spectrum S、Atlassian small 同值),600 是共识最强的默认值;large 原定 800(只有 Atlassian 一家),2026-08-20 二轮调研后加宽到 960 对齐大档簇;全屏档设计师拍板加入(业内仅 TDesign、M3 有,M3 限手机端) |
|
||||
| 高度与垂直位置 | **两套配套方案,沿地域线分**:国内四家(antd / Arco / Semi / Element Plus)不设 max-height + 外层页面式滚动 + 距顶固定;西方八家(M3 / Fluent / SLDS / Carbon / Spectrum / Primer / Polaris / shadcn)设 max-height + 主体区内滚 + 垂直居中。Semi 源码注释是唯一写明因果的:垂直居中 + 外层滚动时 `margin:auto` 会按 flexbox 规范塌成 0,弹窗顶部滚不到。上下留白单边:M3 / Fluent 24、Polaris 30、Primer 32、Atlassian 60 | 走西方那一套(居中 + 主体内滚 + 设 max-height),三者配套、内部自洽。上下留白原定 96px(单边 48,比所有人都宽松),2026-08-20 收到 64px(单边 32,与 Primer 同档)。不设 min-height(业内仅 M3 有 140px) |
|
||||
| 最小高度 | 核了 19 家:**容器级常态 min-height 只有 Material 3 web 一家(140px)**,且硬编码在 `:host` 上不是 token、Compose 端无对应实现、两次提交与 spec 页都未解释这个数。其余为条件性或非容器级:Atlassian `100vh` 仅「滚动视口」模式、Fluent 内容区 32px、TDesign 头尾 56/64px 仅全屏档、M2 动作区 52px(M3 已删)。antd / Arco / Semi / Element Plus / Carbon / SLDS / Spectrum / Polaris / Primer / Radix / shadcn / Apple HIG 全部未定义。业内文档层面从未讨论过「弹窗太矮」,Carbon 对内容少的处方是收窄宽度而非补高度 | **不定 min-height。** 固定头尾已经撑出地板:头部 56 + 底部(16 + 按钮 32 + 16)= 120px,主体空着也有这么高;放一行正文即 142px,与 M3 的 140 基本重合——说明这个数本就是「固定头尾 + 一行字」自然长出来的,不必再写规则保证。若强行垫高,二次确认那类一行字的弹窗会内容贴顶、底下空一块 |
|
||||
| 行长依据 | WCAG 1.4.8(AAA)明文「不超过 80 字符,**CJK 40 字**」,是中文行长唯一有条文的数值;1 汉字 = 1em 的换算关系有 CSS Values 4(`ic` 单位回退 1em)与 WCAG 官方表述背书。⚠️ 「NN/g 推荐 50–75 字符」查无此说;clreq / JLREQ 都不给中文行长数值 | medium 内容区 = 600 − 32 = 568px,14px 正文约 40 字,正好卡在上限,反过来给 600 作默认档背书;large 内容区 928px 约 66 字,故限定它只装分栏与表格(§2) |
|
||||
| 遮罩 | 黑色系低不透明度:M3 32% / Fluent 40%(暗色 50%)/ antd 45% / SLDS 50% / Arco 深灰 60%。毛玻璃仅 antd 提供且默认关 | 黑 40% 无模糊。原候选 `bg-gray-500/90` 比七家都重一大截,设计师拍板淘汰 |
|
||||
| 内边距 | 24 最主流(M3 / Fluent / antd 横向);TDesign 32、SLDS 16 | 16,主体只留左右(定稿时为四边 20,设计师 2026-08-20 对着预览页收窄,落在 SLDS 一档)|
|
||||
| 标题 | 中文体系一致 16px(antd / Arco / TDesign);字重 antd 600 / TDesign 600 / Arco 500。西方体系更大(M3 24 / Fluent 20) | 16px / 字重 500,与抽屉、二次确认现状一致 |
|
||||
| 关闭「×」 | antd / Arco / TDesign 默认有;M3 basic dialog 无、Fluent 规定「无取消按钮时才加」、Apple alert 无 | 按有无取消按钮决定 |
|
||||
| footer 按钮 | 主按钮在右为压倒性共识(antd / Arco / TDesign / M3 / Apple / SLDS),仅 Fluent 在左;间距 8(antd / M3 / Fluent)、Arco 12 | 主按钮最右;间距沿用《Button 按钮》已定的 12px,本文不另立 |
|
||||
| z-index | 基数 + 逐层加:antd 1000 / Arco 1001 / TDesign 2500 / SLDS 9000 | 1000 / 1100 / 1200 / 1300 四档 |
|
||||
| 移动端 | 仅 M3 给显式断点(<600dp 转全屏);Fluent 用 480px / 359px CSS 断点;Apple、SLDS 无公开数值 | 沿用本站 576 断点,转全屏档 |
|
||||
|
||||
来源:ant.design、arco.design、tdesign.tencent.com、m3.material.io、fluent2.microsoft.design、developer.apple.com/design/human-interface-guidelines、lightningdesignsystem.com(含各家开源实现的 token 源码)。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 影响文件 | 提交 |
|
||||
|---|---|---|---|
|
||||
| — | 尚未改动组件源码,当前为现状梳理 + 画廊搭建 | — | — |
|
||||
| 2026-07-09 | **Modal 期开工**:全站弹窗重盘(5 体系 64 文件,见 §5.2;A 套原语直拼 22 处为最大人群);画廊 Modal 版块整体重做。未改组件源码,等设计师定标准 | `_gallery/sections/ModalSection.tsx`(重写) | 待 committer 窗口提交 |
|
||||
| 2026-07-09 | **Modal 期开工**:全站弹窗重盘(5 体系 64 文件,见台账 §1.2;A 套原语直拼 22 处为最大人群);画廊 Modal 版块整体重做。未改组件源码,等设计师定标准 | `_gallery/sections/ModalSection.tsx`(重写) | 待 committer 窗口提交 |
|
||||
| 2026-07-10 | **反馈弹窗抽成共享组件 `ui/CommentDialog`**(标题 + textarea + 取消 / 提交;header/body/footer 各 `px-5`、移动端等宽、每次打开重置草稿、可选异步提交)。MessageFeedbackButtons 改为消费方;MenuUnavailablePage 手写申请权限弹窗迁入——手拼弹窗 -1,新增 ESC / 遮罩 / 焦点圈定 | `ui/CommentDialog.tsx`(新)、`ui/index.ts`、`Chat/MessageFeedbackButtons.tsx`、`pages/MenuUnavailablePage.tsx` | 待提交 |
|
||||
| 2026-07-30 | **对照《元-文档撰写规范》重排**:汉字编号→阿拉伯;正文分层——可读规范留展示层,用量 / 迁移台账 / 代码锚点下沉隐藏区;同步《圆角与阴影规范》已定的弹窗 16px 圆角 + 模态投影,待决策清单删去「圆角用几 px」一项;引号统一「」、去手写分隔线;修正兄弟文档指向本文旧章节号的引用。**未替设计师拍板任何未定项。** | 本文件、01-设计规范.md、00-总纲.md | 待 committer 窗口提交 |
|
||||
| 2026-07-30 | **二次确认拆分为独立规范** [组件-Confirm二次确认.md](组件-Confirm二次确认.md):原 §3 二次确认规范正文与 §7 迁移台账整体迁出,本文仅在 §2 与 §1 留指针;二次确认相关的改动记录与代码锚点一并移入新文档。随之 §4 已定项→§3、§5 尚未拍板→§4、§6 现状台账→§5 顺次上移。 | 本文件、组件-Confirm二次确认.md(新)、00-总纲.md | 待 committer 窗口提交 |
|
||||
| 2026-07-30 | **二次确认拆分为独立规范** [组件-Confirm二次确认.md](组件-Confirm二次确认.md):原二次确认规范正文与迁移台账整体迁出,本文仅留指针。 | 本文件、组件-Confirm二次确认.md(新)、00-总纲.md | 待 committer 窗口提交 |
|
||||
| 2026-08-20 | 最小高度与全屏档时机:核 19 家后**不定 min-height**(业内仅 M3 web 一家有常态值 140px,存档见台账 §1.4);§2 补两句写清全屏档按内容类型选、桌面上只给「宽到 960 还装不下的单件内容」,§3 降档表下标明**全屏档不在表内**。规则未增未减,只把原本隐含的说法写显 | 本文件 | 待提交 |
|
||||
| 2026-08-20 | **宽高二轮调研后调整**(15 家专项,存档见台账 §1.4):**large 800 → 960**(业内大档聚在 900–980,800 仅 Atlassian 一家),降档表首行同步;**上下留白 96 → 64px**(单边 32,原值比所有体系都宽松);§2 补一条「large 只装分栏与表格,不装长段落正文」,依据 WCAG 1.4.8 的 40 CJK 字上限。矮视口特例与滚动分隔线两项设计师暂不补。组件库 `Modal` 的 large 宽度与 max-height 已同步 | 本文件、00-总纲.md、`packages/ui/src/components/Modal/Modal.tsx` | 待提交 |
|
||||
| 2026-08-20 | 设计师批注回填:**手机档(< 576px)弹窗一律走全屏档**,原「从底部上滑、最高 90% 屏高、顶部露一条主内容」那套作废;§2 全屏档补「手机档固定形态」、给实现窗口改为 `inset: 0` + 不渲染遮罩,待决策清单删去已结清的「全屏档首个落地场景」一项 | 本文件、基础-多端适配原则.md、组件-Drawer抽屉.md、00-总纲.md | 待 committer 窗口提交 |
|
||||
| 2026-08-20 | **升级为 v1 正式规范**:先调研 antd / Arco / TDesign / Material 3 / Fluent 2 / Apple HIG / SLDS 七家的弹窗定义(存档见台账 §1.4),设计师逐项拍板——尺寸四档 400/600/800/全屏(三档沿用抽屉阶梯、全屏档为设计师加入)、遮罩黑 40% 不加模糊(淘汰灰白毛玻璃)、结构与间距抄抽屉(header 56/body 20/footer 12·20)、标题 16px 字重 500、「×」按有无取消按钮决定、footer 排列与间距沿用《Button 按钮》、层级四档 1000/1100/1200/1300 在本文定稿。骨架改为与抽屉同构的 7 节 + 给实现窗口;原「尚未拍板的点」7 项全部结清,待决策清单收窄为 3 项验收类 | 本文件、01-设计规范.md、组件-Drawer抽屉.md、组件-Toast轻提示.md、基础-多端适配原则.md、00-总纲.md | 待 committer 窗口提交 |
|
||||
|
||||
| 2026-08-20 | **v1 规范落地为组件库组件**:`@bisheng/ui` 新增 `Modal`(四档尺寸 + 降档表、黑 40% 无模糊遮罩、结构与间距、三条关闭路径 + `beforeClose` 拦截 + `submitting` 锁、手机档全屏、焦点陷阱与 `aria-modal`),底层沿用 `@radix-ui/react-dialog`;层级四档写成 token(`Z_INDEX` 进 design-token.cjs,两个 CSS 载体 + 两份 Tailwind 配置同步),Toaster 的 `z-[9999]` 归并到 `z-toast`;动效四条 keyframes 落地;文档站 components/modal.mdx 重写为实时预览 + API。**client 两套旧壳未动**(迁移批次见待决策清单) | `packages/ui/src/components/Modal/*`(新)、`packages/ui/src/index.ts`、`packages/ui/design-token.cjs`、`packages/ui/src/styles/tokens.css`、`packages/ui/tailwind-preset.cjs`、`packages/ui/src/components/Toast/Toaster.tsx`、`packages/ui/docs/components/modal.mdx`、`packages/ui/docs/design-token.mdx`、`client/src/style.css`、`client/tailwind.config.cjs`、`packages/ui/package.json` + `pnpm-workspace.yaml`(radix dialog 进 catalog) | 待提交 |
|
||||
|
||||
| 2026-08-20 | 设计师对着预览页调整内边距:**三段左右 20 → 16px**、**底部上下 12 → 16px**(底部即四边 16)、**主体上下内边距去掉**(上下空隙交给头部高度和底部内边距);头部仍靠 56px 定高。§4 表、手机档安全区公式、台账目标值与 §1.4 调研取值同步 | 本文件、`packages/ui/src/components/Modal/Modal.tsx`、`packages/ui/docs/components/modal.mdx` | 待提交 |
|
||||
| 2026-08-20 | 遮罩现状回填:三套壳的毛玻璃 2026-08-04 已随全站 `backdrop-blur` 清除(client 33 + platform 7),文档里「灰底毛玻璃 / `+ blur`」的旧描述全部作废——**遮罩只剩颜色待迁**(B / C 套 `bg-gray-500/90` → 黑 40%)。二次确认预览页规格表同步 | 本文件、`packages/ui/docs/components/confirm.mdx` | 待提交 |
|
||||
| 2026-08-20 | 设计师对着预览页调整全屏档:**关闭「×」回到右上角、主操作回到底部**,原「左侧关闭 + 右侧主操作」的头部作废——全屏档与普通弹窗共用同一套头尾位置;**底部按钮等宽平铺占满一行**(与手机档同一条规则,卡片档仍是右对齐原宽)。组件同步删掉为它开的 `headerAction` prop | 本文件、`packages/ui/src/components/Modal/Modal.tsx`、`packages/ui/docs/components/modal.mdx` | 待提交 |
|
||||
|
||||
## 代码锚点
|
||||
|
||||
@@ -109,4 +237,6 @@ BISHENG client 有两套弹窗,都包着同一个 Radix `@radix-ui/react-dialo
|
||||
- B 套模板:`src/frontend/client/src/components/ui/OGDialogTemplate.tsx`
|
||||
- 反馈共享壳:`src/frontend/client/src/components/ui/CommentDialog.tsx`
|
||||
- 画廊 Modal 版块:`src/frontend/client/src/pages/_gallery/sections/ModalSection.tsx`
|
||||
- 组件库实现(v1 壳):`src/frontend/packages/ui/src/components/Modal/Modal.tsx`
|
||||
- 层级 token:`src/frontend/packages/ui/design-token.cjs`(`Z_INDEX`)→ `packages/ui/src/styles/tokens.css` + `client/src/style.css`(`--z-*`)→ `packages/ui/tailwind-preset.cjs` + `client/tailwind.config.cjs`(`zIndex` / keyframes)
|
||||
- 实时预览页:`src/frontend/packages/ui/docs/components/modal.mdx`
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
- Popover 可以出现在弹窗、抽屉之上——弹窗里同样会有筛选、信息卡。
|
||||
- Tooltip 永远压在 Popover 之上,提示是最贴近指针的一层。
|
||||
- 具体 z-index 阶梯归 [01-设计规范.md](01-设计规范.md) §5 统一定稿,本文不定数值。
|
||||
- 具体 z-index 归 [组件-Modal弹窗.md](组件-Modal弹窗.md) §5 的四档层级表,气泡卡片取 `1100`,本文不另立。
|
||||
|
||||
## 7. 移动端适配
|
||||
|
||||
@@ -95,11 +95,12 @@
|
||||
2. 样式 token:`bg-background`(跟暗色)、1px 描边(中性描边色,色彩规范接线)、圆角 12px、`p-4`(16px)、`max-w-[320px]`、浮层投影档;箭头同底色带描边。组件内不留裸 hex。
|
||||
3. hover 档时序与 Tooltip 共用同一组常量(进入延时 100ms、移入保持、150ms 淡入);封装处统一,禁止业务页自调 delay。
|
||||
4. 触屏降级:`coarse-pointer` 媒体查询下 hover 档自动走 click 行为,处理惯例参照 Button 落地 §6。
|
||||
5. z-index 待 [01-设计规范.md](01-设计规范.md) §5 阶梯定稿后接线;过渡期保证 Popover ≥ 弹窗层、< Tooltip 层。
|
||||
5. z-index 取 `1100`([组件-Modal弹窗.md](组件-Modal弹窗.md) §5 层级表),在弹窗 / 抽屉之上、Tooltip 之下。
|
||||
6. 现状扫描待做:口径 `src/frontend/client/src`,排除 `ui/` 与 `_gallery/`,盘 Popover / HoverCard / 自造 hover 卡片(手写 absolute 浮层)的实现与用量,作迁移排批参考;Dropdown 菜单不算在内(另有规范待建)。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-20 | 层级随 [组件-Modal弹窗.md](组件-Modal弹窗.md) v1 定稿的四档层级表回填:气泡卡片取 `1100`(在弹窗 / 抽屉之上、Tooltip 之下);原「归 [01-设计规范.md](01-设计规范.md) §5 定稿」的指针改为具体数值。本文其余规则未增未减未改 | 待 committer 窗口提交 |
|
||||
| 2026-07-30 | 建档 v1:调研 antd 5 / Arco / TDesign / Apple HIG / Fluent 2 / Radix / WAI-ARIA / WCAG 1.4.13 → 设计师拍板(默认 hover 触发)→ 成文。与 Tooltip 的判定归 Tooltip 文档 §2 引用;「不做 Popconfirm」写入 §1.1 | 待 committer 窗口提交 |
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
- **最多同时显示 3 条**,纵向排列、间距 8px,新的一条追加在最下方。
|
||||
- **第 4 条来的时候,最上面那条(最旧的)立即消失**,给新的让位。最新的消息永远看得见。
|
||||
- **同一条消息重复触发时,更新已有的那条并重新计时,不新增。** 用户连点三次保存,应该只看到一条「已保存」。
|
||||
- **轻提示永远在最上层**,盖住弹窗与抽屉——在弹窗里点保存,提示必须能看见。具体层级数值随弹窗期的层级表统一定(见 [01-设计规范.md](01-设计规范.md) §5)。
|
||||
- **轻提示盖住弹窗与抽屉**——在弹窗里点保存,提示必须能看见。层级取 `1200`,只有文字提示 Tooltip 在它之上;四档层级表见 [组件-Modal弹窗.md](组件-Modal弹窗.md) §5。
|
||||
|
||||
## 4. 尺寸与样式
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
| 投影 | `0 0 1px rgba(67,90,111,.3), 0 5px 8px -4px rgba(67,90,111,.3)`(style.css `.toast-root .alert-root`),灰系杂值,不在两档内 | `shadow-popup`(§4) |
|
||||
| 尺寸 | 行内 style 写死 `height: 74px`;`px-3 py-2`;viewport `max-w-[560px]` | 单行 40px、水平 16px、最宽 480px(§4) |
|
||||
| 位置 | 顶部居中,viewport `my-2`(8px) | 顶部居中,距顶 16px(§3) |
|
||||
| 层级 | viewport `z-[1000]`;私有撤销 toast `z-[9999]` | 恒在最上层,数值随 Modal 期定(§3) |
|
||||
| 层级 | viewport `z-[1000]`;私有撤销 toast `z-[9999]` | `1200`,盖住弹窗与抽屉(§3) |
|
||||
| 并发 | 1 条(recoil 单例) | 最多 3 条堆叠(§3) |
|
||||
| 动效 | 进 240ms `cubic-bezier(0.175,0.885,0.32,1.175)`(回弹)、出 120ms `cubic-bezier(0.4,0,1,1)`;另有 100ms `showDelay` 才开始显示 | 进 200ms / 出 160ms,`cubic-bezier(0.2,0,0,1)`(§7) |
|
||||
| 时长 | 默认 3000ms 恒定,不随字数变 | 3~10 秒动态(§6) |
|
||||
@@ -210,7 +210,7 @@
|
||||
|
||||
实现时定下的三件事:
|
||||
|
||||
- **容器 portal 到 `<body>`,z-index 取 `9999`**(原私有撤销 toast 的层级),Modal 期层级表定稿后回填 §3。留在挂载点原地时,容器会跟着所在页面区块一起排版——组件站上就被 z-20 的站点顶栏压住,z-index 调到 10 万也没用(层级只在自己所处的层叠上下文里比)。§3「永远在最上层」只有 portal 到 body 才成立,业务侧同理:弹窗、抽屉里带 transform 的祖先都会埋掉它。
|
||||
- **容器 portal 到 `<body>`,z-index 取 `9999`**(原私有撤销 toast 的层级),层级表已在 [组件-Modal弹窗.md](组件-Modal弹窗.md) §5 定稿为 `1200`,待回填。留在挂载点原地时,容器会跟着所在页面区块一起排版——组件站上就被 z-20 的站点顶栏压住,z-index 调到 10 万也没用(层级只在自己所处的层叠上下文里比)。§3「永远在最上层」只有 portal 到 body 才成立,业务侧同理:弹窗、抽屉里带 transform 的祖先都会埋掉它。
|
||||
- **暗色底色**:功能色暂无暗色阶,浅底 + 主文字色在暗色下会白字压浅底,故暗色回退为同一个功能色 token 的 15% 透明底(仍是 token,不是裸值),待 §12 拍板。
|
||||
- **进场不用 `requestAnimationFrame`**:后台标签页里 rAF 会冻结,导致提示永远停在透明态;改为读一次布局强制刷新起始态再切类。
|
||||
|
||||
@@ -222,14 +222,14 @@
|
||||
4. 去重口径:按 `id` 更新;业务未传 `id` 时以「类型 + 文案」哈希作为默认 id,实现 §3「同一条消息重复触发只更新不新增」。
|
||||
5. 队列上限 3,超出时 `shift()` 移除最旧一条(与 §3 一致)。
|
||||
6. 色值取功能色 token 的 `tint` 浅底档与主色档,禁止裸 hex;圆角取 `rounded-xl`、投影取 `shadow-popup`,禁止 Tailwind 预设投影(见圆角与阴影规范 §2.2)。
|
||||
7. z-index 暂时取现有最高档之上,待 Modal 期层级表定稿后统一接入,届时回填本文 §3。
|
||||
7. z-index 落地暂取现有最高档之上;层级表已在 [组件-Modal弹窗.md](组件-Modal弹窗.md) §5 定稿为 `1200`,接入时回填本文 §3。
|
||||
8. 上线前照元规范 §5 清单:在 `src/frontend/client/rspress.config.ts` 的 `themeConfig.sidebar` 注册本文入口,`components/` 下建 ASCII 命名的 demo 页。
|
||||
|
||||
<!-- site-hide -->
|
||||
## 12. 待决策清单
|
||||
|
||||
- [x] ~~现有代码里的轻提示实现有几套、分别用在哪~~ → 2026-08-13 已扫,见 §10
|
||||
- [ ] z-index 具体数值(阻塞项在 Modal 期层级表)——落地暂取 `9999`,见 §11.1
|
||||
- [ ] 把落地暂取的 `z-[9999]` 回填为层级表定稿值 `1200`,见 §11.1
|
||||
- [ ] 暗色主题下四类浅底的取值是否需要单独给——原方案「跟功能色 token 走不单独调」在暗色下不成立(白字压浅底),落地暂用同色 15% 透明底顶着,等功能色暗色阶
|
||||
- [ ] 是否需要「不自动关闭」这档——§6 已按需要保留,若实际场景一个都没有,下一版删掉。落地已实现(`duration: 0`),现有业务无人使用
|
||||
- [ ] 收敛入参:`severity` 枚举与 `status` 字符串并成哪一个(§10.3 现状 72 vs 45 个文件),以及是否给旧字段留兼容期——落地两个都留着转接,未收敛
|
||||
@@ -241,6 +241,7 @@
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-13 | 建档 v1:先调研 antd / Arco / TDesign / Semi / Material / Fluent 六家,再由设计师拍板四项——四类型(不做 loading)、最多 3 条堆叠顶掉最旧、允许一个操作按钮且时长翻倍、时长按字数动态延长 3~10 秒;另拍板移动端仍在顶部、悬停暂停计时、浅语义色底无边框。共 9 节展示层 + 落地与待决策两节隐藏区 | 待 committer 窗口提交 |
|
||||
| 2026-08-20 | 层级随 [组件-Modal弹窗.md](组件-Modal弹窗.md) v1 定稿:§3「永远在最上层」改为「盖住弹窗与抽屉,取 `1200`,Tooltip 在其上」(此前 [01-设计规范.md](01-设计规范.md) §5 标注的「与 Tooltip 谁在最上待拍板」就此了结);§11 现状对照表与待决策清单同步为具体数值。轻提示自己的类型 / 堆叠 / 时长 / 动效规则未增未减未改 | 待 committer 窗口提交 |
|
||||
| 2026-08-13 | 站点接线(元规范 §5 上线清单):`rspress.config.ts` 侧栏「组件规范」注册入口、00-总纲 §四 进度看板加行、01-设计规范 §0 索引加行并在 §5 层级待定稿留下指针(轻提示须盖住弹窗与抽屉,与 Tooltip 的先后待拍板)。**文档内容未改**;§11.8 的 demo 页仍未建——照 圆角与阴影 / 多端适配 的先例,规范先行、组件落地后再补 `components/toast.mdx` | 待 committer 窗口提交 |
|
||||
| 2026-08-13 | 换行时图标改为对齐第一行(套一个一行高的盒子居中,行高取 `--leading-body`,随断点自适应);§规格 补一行 | 待 committer 窗口提交 |
|
||||
| 2026-08-13 | 关闭按钮调淡并拉开:`text-3` → `text-4`(禁用文字色),离文案 8px → 16px(与操作按钮同口径);§5.3 补规则 | 待 committer 窗口提交 |
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
3. 触屏不显示:Radix Tooltip 原生不响应 touch,保持默认;**禁止业务页用 onTouchStart 自造长按提示**。
|
||||
4. 禁用控件的提示:封装组件属性,内部对 disabled 触发元素包一层可聚焦热区(`span` + `tabIndex=0`);禁止业务页各自手包。
|
||||
5. 单 icon 按钮的 `aria-label` 与 tooltip 文案同源:IconButton 封装用同一个 `label` 属性同时喂给两者。
|
||||
6. z-index:挂统一层级阶梯的**最高浮层档**——tooltip 必须能出现在弹窗、抽屉、Popover 之上(弹窗里的图标钮也要有提示)。阶梯数值归 [01-设计规范.md](01-设计规范.md) §5 定稿,本文不定数值。
|
||||
6. z-index 取 `1300`,即层级表的**最高浮层档**——tooltip 必须能出现在弹窗、抽屉、Popover 之上(弹窗里的图标钮也要有提示)。四档层级表见 [组件-Modal弹窗.md](组件-Modal弹窗.md) §5,本文不另立。
|
||||
7. 动效暂定 150ms 淡入 / 100ms 淡出(无动效规范,先用此值,建档后归并)。
|
||||
8. 现状扫描待做:口径 `src/frontend/client/src`,排除 `ui/` 与 `_gallery/`,盘现有 tooltip 实现(Radix 封装 / 原生 `title` 属性 / 自造 hover 层)与用量,作迁移排批参考。
|
||||
|
||||
@@ -119,5 +119,6 @@
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-20 | 层级随 [组件-Modal弹窗.md](组件-Modal弹窗.md) v1 定稿的四档层级表回填:Tooltip 取 `1300`,即最高浮层档;原「归 [01-设计规范.md](01-设计规范.md) §5 定稿」的指针改为具体数值。本文其余规则未增未减未改 | 待 committer 窗口提交 |
|
||||
| 2026-07-30 | 建档 v1:调研 antd 5 / Arco / TDesign / Apple HIG / Material 3 / Fluent 2 / Radix / WAI-ARIA / WCAG 1.4.13 / NN/g / Primer → 设计师拍板(深底白字、出现延时 100ms、触屏不依赖 tooltip)→ 成文。与 Popover 的判定归本文 §2,姊妹篇引用不抄写;「不做 rich tooltip」写入 §2 | 待 committer 窗口提交 |
|
||||
| 2026-07-30 | 拍板:底色由实色改为**带透明度的深底**——灰阶第 10 档 90% 不透明度(90% 为初值,透过度待设计师目检微调);不加毛玻璃模糊。§3 与落地 §2 同步更新 | 待 committer 窗口提交 |
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "catalog:",
|
||||
"@radix-ui/react-slot": "catalog:",
|
||||
"bisheng-icons": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import * as React from 'react';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import cn from '../../utils/cn';
|
||||
import {
|
||||
ADDON_BASE,
|
||||
ADDON_PADDING,
|
||||
CLEAR_VISIBILITY,
|
||||
CONTROL_ROW,
|
||||
COUNT_BASE,
|
||||
FIELD_ACTION,
|
||||
FIELD_BASE,
|
||||
assignRef,
|
||||
clearField,
|
||||
focusFieldFromShell,
|
||||
resolveState,
|
||||
shellStyles,
|
||||
useFieldText,
|
||||
type InputSize,
|
||||
type InputStatus,
|
||||
} from './shared';
|
||||
|
||||
/**
|
||||
* Input — the single-line text field (组件-Input输入框.md v1).
|
||||
*
|
||||
* One base for every single-line form (§2): the plain field, and — by filling
|
||||
* the prefix / suffix / addon slots — search, password and unit-prefixed
|
||||
* fields. Password / Search ship as thin wrappers over this same component
|
||||
* rather than as an API of their own.
|
||||
*
|
||||
* What the component pins down and a page cannot restate: the 24/32/40 height
|
||||
* ladder with its radii and paddings (§3), the gray focus chain (§5.1 — no
|
||||
* brand color on focus, ever), the four state shells (§5.2), and the touch
|
||||
* rules (§6: no hover, 16px type to stop iOS zooming, ≥44px hot zone).
|
||||
*
|
||||
* Not this component: a value picked from a fixed list (that is a select), or
|
||||
* anything that needs more than one line (that is `Textarea`).
|
||||
*/
|
||||
export interface InputProps
|
||||
// `size` is our ladder, not the HTML character-count attr; `prefix` is our
|
||||
// slot, not the RDFa attribute of the same name.
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
|
||||
/** §3 — pick the档, never hand-write height / padding. */
|
||||
size?: InputSize;
|
||||
/** §5.2 — `error` blocks submit and turns the border red; `warning` is "look again". */
|
||||
status?: InputStatus;
|
||||
/** §4.2 — says WHAT the box is (magnifier, link icon). */
|
||||
prefix?: React.ReactNode;
|
||||
/** §4.2 — a unit, a counter, or an action; at most two action icons. */
|
||||
suffix?: React.ReactNode;
|
||||
/** §2 — a fixed part of the value (`https://`, `元`), fused into the outline. */
|
||||
addonBefore?: React.ReactNode;
|
||||
addonAfter?: React.ReactNode;
|
||||
/** §4.3 — on for search / filter fields, off for form fields where a mis-tap costs real typing. */
|
||||
allowClear?: boolean;
|
||||
/** Accessible name of the clear button — text comes from the caller (library contract). */
|
||||
clearLabel?: string;
|
||||
/** Fires after the box is emptied; the `onChange` for the empty value fires too. */
|
||||
onClear?: () => void;
|
||||
/** §4.4 — "current / limit"; needs `maxLength`, and only for limits users actually hit. */
|
||||
showCount?: boolean;
|
||||
/** Classes for the shell (width lives here: `className="w-64"`). */
|
||||
className?: string;
|
||||
/** Classes for the `<input>` itself — rarely needed. */
|
||||
inputClassName?: string;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{
|
||||
size = 'medium',
|
||||
status = 'default',
|
||||
prefix,
|
||||
suffix,
|
||||
addonBefore,
|
||||
addonAfter,
|
||||
allowClear = false,
|
||||
clearLabel,
|
||||
onClear,
|
||||
showCount = false,
|
||||
className,
|
||||
inputClassName,
|
||||
disabled,
|
||||
readOnly,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
maxLength,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const fieldRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const { text, track } = useFieldText(value, defaultValue);
|
||||
const state = resolveState(disabled, readOnly);
|
||||
|
||||
const setRefs = React.useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
fieldRef.current = node;
|
||||
assignRef(ref, node);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
track(event.target.value);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearField(fieldRef.current);
|
||||
onClear?.();
|
||||
};
|
||||
|
||||
// §4.3 — a clear button on a field nobody can edit would be a lie.
|
||||
const showClear = allowClear && !disabled && !readOnly && text.length > 0;
|
||||
// §4.4 — the counter only makes sense against a limit.
|
||||
const countVisible = showCount && maxLength !== undefined;
|
||||
const atLimit = maxLength !== undefined && text.length >= maxLength;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(shellStyles({ size, status, state }), className)}
|
||||
onPointerDown={(event) => {
|
||||
if (!disabled) focusFieldFromShell(event, fieldRef.current);
|
||||
}}
|
||||
>
|
||||
{addonBefore !== undefined && (
|
||||
<span
|
||||
data-input-addon="before"
|
||||
className={cn(ADDON_BASE, ADDON_PADDING[size], 'border-r border-border-base')}
|
||||
>
|
||||
{addonBefore}
|
||||
</span>
|
||||
)}
|
||||
<div className={CONTROL_ROW[size]}>
|
||||
{prefix !== undefined && (
|
||||
<span className="flex shrink-0 items-center text-text-3">{prefix}</span>
|
||||
)}
|
||||
<input
|
||||
ref={setRefs}
|
||||
className={cn(FIELD_BASE, inputClassName)}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
maxLength={maxLength}
|
||||
aria-invalid={status === 'error' || undefined}
|
||||
{...props}
|
||||
/>
|
||||
{countVisible && (
|
||||
<span className={cn(COUNT_BASE, atLimit ? 'text-danger' : 'text-text-3')}>
|
||||
{text.length} / {maxLength}
|
||||
</span>
|
||||
)}
|
||||
{showClear && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={clearLabel}
|
||||
className={cn(FIELD_ACTION, CLEAR_VISIBILITY)}
|
||||
// Keep the caret (and the focus ring) in the box while clearing.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={handleClear}
|
||||
>
|
||||
<Outlined.CloseCircle />
|
||||
</button>
|
||||
)}
|
||||
{suffix !== undefined && (
|
||||
<span className="flex shrink-0 items-center text-text-3">{suffix}</span>
|
||||
)}
|
||||
</div>
|
||||
{addonAfter !== undefined && (
|
||||
<span
|
||||
data-input-addon="after"
|
||||
className={cn(ADDON_BASE, ADDON_PADDING[size], 'border-l border-border-base')}
|
||||
>
|
||||
{addonAfter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
import cn from '../../utils/cn';
|
||||
import { FIELD_ACTION } from './shared';
|
||||
import { Input, type InputProps } from './Input';
|
||||
|
||||
/**
|
||||
* PasswordInput — the password form of the base field (组件-Input输入框.md §2).
|
||||
*
|
||||
* Masked by default with a reveal toggle in the suffix, because that action
|
||||
* belongs to the box: a "show password" button parked outside it breaks the
|
||||
* form's alignment (§4.2). Combined with `allowClear` the suffix carries the
|
||||
* two actions the spec allows, and no more.
|
||||
*
|
||||
* The two icons come in as props: the icon package has no eye glyph yet, and
|
||||
* the library contract forbids drawing one here. Pass the app's own pair
|
||||
* (see the docs page) until `bisheng-icons` ships one.
|
||||
*/
|
||||
export interface PasswordInputProps extends Omit<InputProps, 'type' | 'suffix'> {
|
||||
/** Shown while the value is masked — clicking it reveals the text. */
|
||||
revealIcon: React.ReactNode;
|
||||
/** Shown while the value is visible — clicking it masks the text again. */
|
||||
hideIcon: React.ReactNode;
|
||||
/** Accessible name of the toggle while masked (caller-supplied copy). */
|
||||
revealLabel?: string;
|
||||
/** Accessible name of the toggle while revealed. */
|
||||
hideLabel?: string;
|
||||
/** Start revealed — for a generated key the user is meant to read. */
|
||||
defaultVisible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
function PasswordInput(
|
||||
{
|
||||
revealIcon,
|
||||
hideIcon,
|
||||
revealLabel,
|
||||
hideLabel,
|
||||
defaultVisible = false,
|
||||
onVisibleChange,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [visible, setVisible] = React.useState(defaultVisible);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !visible;
|
||||
setVisible(next);
|
||||
onVisibleChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type={visible ? 'text' : 'password'}
|
||||
disabled={disabled}
|
||||
suffix={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visible ? hideLabel : revealLabel}
|
||||
aria-pressed={visible}
|
||||
disabled={disabled}
|
||||
className={cn(FIELD_ACTION, disabled && 'cursor-not-allowed')}
|
||||
// Toggling must not steal the caret out of the field.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={toggle}
|
||||
>
|
||||
{visible ? hideIcon : revealIcon}
|
||||
</button>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import { Input, type InputProps } from './Input';
|
||||
|
||||
/**
|
||||
* SearchInput — the filter / search form of the base field (组件-Input输入框.md §2).
|
||||
*
|
||||
* Magnifier in the prefix so the box says what it is at a glance, Enter runs the
|
||||
* search, and clear is ON by default (§4.3: search and filter fields get it —
|
||||
* dropping a query costs the user one keystroke, not a paragraph).
|
||||
*/
|
||||
export interface SearchInputProps extends Omit<InputProps, 'type' | 'prefix'> {
|
||||
/** Runs on Enter with the current text. Filtering as you type stays on `onChange`. */
|
||||
onSearch?: (value: string) => void;
|
||||
/** Replace the magnifier — e.g. a link icon for a URL filter. */
|
||||
prefixIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
function SearchInput({ onSearch, prefixIcon, allowClear = true, onKeyDown, ...props }, ref) {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
onKeyDown?.(event);
|
||||
// IME: Enter while composing commits the candidate, it does not search.
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
onSearch?.(event.currentTarget.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="search"
|
||||
prefix={prefixIcon ?? <Outlined.Search />}
|
||||
allowClear={allowClear}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from 'react';
|
||||
import cn from '../../utils/cn';
|
||||
import {
|
||||
COUNT_BASE,
|
||||
assignRef,
|
||||
focusFieldFromShell,
|
||||
resolveState,
|
||||
shellStyles,
|
||||
useFieldText,
|
||||
type InputStatus,
|
||||
} from './shared';
|
||||
|
||||
/**
|
||||
* Textarea — the multi-line form of the field (组件-Input输入框.md §2/§3).
|
||||
*
|
||||
* Same shell as `Input` (one border, one focus ring, same four states) with the
|
||||
* height ladder switched off: a textarea has no size档 — 14/22 type, 6px radius,
|
||||
* 8/12 padding, three rows tall by default.
|
||||
*
|
||||
* Vertical resize is left to the user, horizontal is not: a box that grows past
|
||||
* its column breaks the form's grid. Auto-grow is deliberately not built in —
|
||||
* §3 requires a ceiling for it, and no caller has needed one yet.
|
||||
*/
|
||||
export interface TextareaProps
|
||||
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'cols'> {
|
||||
/** §5.2 — same validation states as the single-line field. */
|
||||
status?: InputStatus;
|
||||
/** §4.4 — "current / limit", parked in the bottom-right corner INSIDE the box. */
|
||||
showCount?: boolean;
|
||||
/** Classes for the shell (width / min-height live here). */
|
||||
className?: string;
|
||||
/** Classes for the `<textarea>` itself. */
|
||||
textareaClassName?: string;
|
||||
}
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
||||
{
|
||||
status = 'default',
|
||||
showCount = false,
|
||||
className,
|
||||
textareaClassName,
|
||||
disabled,
|
||||
readOnly,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
maxLength,
|
||||
rows = 3,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const fieldRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const { text, track } = useFieldText(value, defaultValue);
|
||||
const state = resolveState(disabled, readOnly);
|
||||
|
||||
const setRefs = React.useCallback(
|
||||
(node: HTMLTextAreaElement | null) => {
|
||||
fieldRef.current = node;
|
||||
assignRef(ref, node);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
track(event.target.value);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const countVisible = showCount && maxLength !== undefined;
|
||||
const atLimit = maxLength !== undefined && text.length >= maxLength;
|
||||
|
||||
return (
|
||||
<div
|
||||
// `h-auto` drops the single-line height: the row count decides how tall
|
||||
// this is, and the user may drag it taller.
|
||||
className={cn(shellStyles({ size: 'medium', status, state }), 'h-auto', className)}
|
||||
onPointerDown={(event) => {
|
||||
if (!disabled) focusFieldFromShell(event, fieldRef.current);
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={setRefs}
|
||||
rows={rows}
|
||||
className={cn(
|
||||
'input-no-zoom w-full resize-y border-0 bg-transparent px-3 py-2 text-inherit outline-none placeholder:text-text-3 disabled:cursor-not-allowed disabled:text-btn-disabled-text disabled:placeholder:text-btn-disabled-text',
|
||||
// Room for the counter so the last line never runs under it.
|
||||
countVisible && 'pb-7',
|
||||
textareaClassName,
|
||||
)}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
maxLength={maxLength}
|
||||
aria-invalid={status === 'error' || undefined}
|
||||
{...props}
|
||||
/>
|
||||
{countVisible && (
|
||||
// `bg-inherit` so scrolled text passes UNDER the counter, not through it.
|
||||
<span
|
||||
className={cn(
|
||||
COUNT_BASE,
|
||||
'pointer-events-none absolute bottom-1.5 right-3 rounded bg-inherit px-1',
|
||||
atLimit ? 'text-danger' : 'text-text-3',
|
||||
)}
|
||||
>
|
||||
{text.length} / {maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export { Input } from './Input';
|
||||
export type { InputProps } from './Input';
|
||||
export { Textarea } from './Textarea';
|
||||
export type { TextareaProps } from './Textarea';
|
||||
export { PasswordInput } from './PasswordInput';
|
||||
export type { PasswordInputProps } from './PasswordInput';
|
||||
export { SearchInput } from './SearchInput';
|
||||
export type { SearchInputProps } from './SearchInput';
|
||||
export type { InputSize, InputStatus } from './shared';
|
||||
@@ -0,0 +1,204 @@
|
||||
import * as React from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
/**
|
||||
* Shared internals of the input family (组件-Input输入框.md v1).
|
||||
*
|
||||
* Input / Textarea / Password / Search are ONE base with different slots filled
|
||||
* (§2), so the shell — border, radius, height ladder, the four state colors and
|
||||
* the focus ring — is defined once here and every form imports it.
|
||||
*/
|
||||
|
||||
/** §3 — three sizes, same 24/32/40 ladder as the button, so a row lines up. */
|
||||
export type InputSize = 'small' | 'medium' | 'large';
|
||||
|
||||
/** §5.2 — validation status. `error` blocks submit, `warning` does not. */
|
||||
export type InputStatus = 'default' | 'error' | 'warning';
|
||||
|
||||
/** Which of the three shells the field is in — derived from `disabled` / `readOnly`. */
|
||||
type InputState = 'editable' | 'readonly' | 'disabled';
|
||||
|
||||
/**
|
||||
* §5 — the shell draws EVERYTHING the eye reads as "the input": border, fill,
|
||||
* radius, height and the focus ring. The `<input>` inside is transparent and
|
||||
* borderless, which is what lets prefix / suffix / addon share one outline.
|
||||
*
|
||||
* The state chain is one gray ramp (§5.1): base border → deep on hover → deep +
|
||||
* a 2px gray ring on focus. No brand color anywhere — focus only answers "where
|
||||
* is the caret", and the color budget is saved for validation.
|
||||
*/
|
||||
export const shellStyles = cva(
|
||||
'group relative flex w-full items-stretch overflow-hidden border transition-colors',
|
||||
{
|
||||
variants: {
|
||||
// Height + radius + type size per §3. Font size references the PRIMITIVE
|
||||
// scale vars on purpose: the semantic --text-body remaps 14→16 under
|
||||
// 768px for READING text, while a control's own mobile rule is the iOS
|
||||
// anti-zoom one, which `.input-no-zoom` applies to the field only (§6).
|
||||
size: {
|
||||
small:
|
||||
'input-touch-hit h-6 rounded text-[length:var(--font-size-3)] leading-[var(--line-height-3)]',
|
||||
medium:
|
||||
'input-touch-hit h-8 rounded-md text-[length:var(--font-size-3)] leading-[var(--line-height-3)]',
|
||||
large:
|
||||
'input-touch-hit h-10 rounded-lg text-[length:var(--font-size-4)] leading-[var(--line-height-4)]',
|
||||
},
|
||||
// Validation border. The ring color rides along as a CSS var so the focus
|
||||
// ring switches to the matching tint without a second shadow token (§5.2).
|
||||
status: {
|
||||
default: '',
|
||||
error: 'border-danger [--shadow-focus-ring:var(--danger-tint)]',
|
||||
warning: 'border-warning [--shadow-focus-ring:var(--warning-tint)]',
|
||||
},
|
||||
state: {
|
||||
editable: 'bg-bg-page text-text-1',
|
||||
// §5.2 — readonly is "valid content you may copy but not change":
|
||||
// light fill, normal border, and NO focus ring (nothing to type into).
|
||||
readonly: 'bg-fill-1 text-text-1',
|
||||
// §5.2 — disabled reuses the button's three tokens verbatim.
|
||||
disabled:
|
||||
'cursor-not-allowed border-btn-disabled-border bg-btn-disabled-bg text-btn-disabled-text',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Only an editable field reacts to the pointer. `hover:` is compiled
|
||||
// inside a hover-capable media query app-wide, so touch goes straight
|
||||
// from default to focus (§6) without any variant of our own.
|
||||
{
|
||||
state: 'editable',
|
||||
status: 'default',
|
||||
class:
|
||||
'border-border-base hover:border-border-deep focus-within:border-border-deep focus-within:shadow-focus',
|
||||
},
|
||||
// A field that is saying something keeps saying it while hovered and
|
||||
// focused — the gray ramp must not overwrite the validation color.
|
||||
{
|
||||
state: 'editable',
|
||||
status: 'error',
|
||||
class: 'hover:border-danger focus-within:border-danger focus-within:shadow-focus',
|
||||
},
|
||||
{
|
||||
state: 'editable',
|
||||
status: 'warning',
|
||||
class: 'hover:border-warning focus-within:border-warning focus-within:shadow-focus',
|
||||
},
|
||||
{ state: 'readonly', status: 'default', class: 'border-border-base' },
|
||||
],
|
||||
defaultVariants: { size: 'medium', status: 'default', state: 'editable' },
|
||||
},
|
||||
);
|
||||
|
||||
/** §3/§4.2 — inner row: 8/12/12 horizontal padding minus the 1px border, and the 14/16/18 icon ladder. */
|
||||
export const CONTROL_ROW: Record<InputSize, string> = {
|
||||
small: 'flex min-w-0 flex-1 items-center gap-1 px-[7px] [&_svg]:size-3.5',
|
||||
medium: 'flex min-w-0 flex-1 items-center gap-2 px-[11px] [&_svg]:size-4',
|
||||
large: 'flex min-w-0 flex-1 items-center gap-2 px-[11px] [&_svg]:size-[18px]',
|
||||
};
|
||||
|
||||
/** §2 — addon shares the shell's outline; only a divider separates it from the field. */
|
||||
export const ADDON_PADDING: Record<InputSize, string> = {
|
||||
small: 'px-2',
|
||||
medium: 'px-3',
|
||||
large: 'px-3',
|
||||
};
|
||||
|
||||
export const ADDON_BASE =
|
||||
'flex shrink-0 items-center whitespace-nowrap bg-fill-1 text-text-2';
|
||||
|
||||
/**
|
||||
* The field itself: no chrome of its own (the shell owns it), inheriting the
|
||||
* shell's type size. `.input-no-zoom` is the iOS anti-zoom rule (§6);
|
||||
* `::-ms-*` are Edge's built-in reveal / clear buttons, which would double up
|
||||
* with ours.
|
||||
*/
|
||||
export const FIELD_BASE =
|
||||
'input-no-zoom min-w-0 flex-1 border-0 bg-transparent p-0 text-inherit outline-none placeholder:text-text-3 disabled:cursor-not-allowed disabled:text-btn-disabled-text disabled:placeholder:text-btn-disabled-text [&::-ms-clear]:hidden [&::-ms-reveal]:hidden';
|
||||
|
||||
/**
|
||||
* §4.2/§4.3 — a suffix ACTION (clear, reveal). `btn-touch-hit` gives it the
|
||||
* ≥44px hot zone on touch while the icon stays 14/16/18.
|
||||
*/
|
||||
export const FIELD_ACTION =
|
||||
'btn-touch-hit relative flex shrink-0 cursor-pointer items-center justify-center text-text-3 outline-none transition-colors hover:text-text-2';
|
||||
|
||||
/**
|
||||
* §4.3 — the clear button shows on hover or focus, and only with content in the
|
||||
* box. `invisible` (not conditional rendering) keeps the row from jittering.
|
||||
* Touch has no hover, so `group-hover:` compiles out there and focus alone
|
||||
* shows it — exactly what the mobile table asks for.
|
||||
*/
|
||||
export const CLEAR_VISIBILITY = 'invisible group-hover:visible group-focus-within:visible';
|
||||
|
||||
/** §4.4 — counter: hint color normally, danger once the limit is reached. */
|
||||
export const COUNT_BASE =
|
||||
'shrink-0 select-none tabular-nums text-[length:var(--font-size-1)] leading-[var(--line-height-1)]';
|
||||
|
||||
function toText(value: string | number | readonly string[] | undefined): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
return Array.isArray(value) ? value.join('') : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear (§4.3) and count (§4.4) both need to know what is in the box, which
|
||||
* controlled callers keep in their own state and uncontrolled ones do not keep
|
||||
* at all. This mirrors the value so both call sites read one variable.
|
||||
*/
|
||||
export function useFieldText(
|
||||
value: string | number | readonly string[] | undefined,
|
||||
defaultValue: string | number | readonly string[] | undefined,
|
||||
): { text: string; track: (next: string) => void } {
|
||||
const [innerText, setInnerText] = React.useState(() => toText(defaultValue));
|
||||
const isControlled = value !== undefined;
|
||||
const track = React.useCallback(
|
||||
(next: string) => {
|
||||
if (!isControlled) setInnerText(next);
|
||||
},
|
||||
[isControlled],
|
||||
);
|
||||
return { text: isControlled ? toText(value) : innerText, track };
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty the field the way the user would: go through the NATIVE value setter and
|
||||
* dispatch `input`, so React's own `onChange` fires and a controlled caller
|
||||
* updates its state — assigning `node.value` alone is swallowed by React's value
|
||||
* tracker. Focus stays in the box so the user can retype straight away (§4.3).
|
||||
*/
|
||||
export function clearField(node: HTMLInputElement | HTMLTextAreaElement | null): void {
|
||||
if (!node) return;
|
||||
const proto =
|
||||
node instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
const setValue = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||||
setValue?.call(node, '');
|
||||
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
node.focus();
|
||||
}
|
||||
|
||||
/** Keeps the caller's ref working while the component holds one of its own. */
|
||||
export function assignRef<T>(ref: React.ForwardedRef<T>, node: T | null): void {
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
}
|
||||
|
||||
/** `disabled` / `readOnly` decide the shell; both together read as disabled. */
|
||||
export function resolveState(disabled?: boolean, readOnly?: boolean): InputState {
|
||||
if (disabled) return 'disabled';
|
||||
if (readOnly) return 'readonly';
|
||||
return 'editable';
|
||||
}
|
||||
|
||||
/**
|
||||
* A tap on the padding — or on the invisible hot zone above/below the field
|
||||
* (§6) — should land in the box. Anything that handles its own click (the
|
||||
* field, the clear button, an addon the user may want to select) is skipped.
|
||||
*/
|
||||
export function focusFieldFromShell(
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
node: HTMLInputElement | HTMLTextAreaElement | null,
|
||||
): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!node || !target) return;
|
||||
if (target.closest('input, textarea, button, a, [data-input-addon]')) return;
|
||||
event.preventDefault();
|
||||
node.focus();
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import cn from '../../utils/cn';
|
||||
|
||||
/**
|
||||
* Modal — the centered, interrupting overlay (组件-Modal弹窗.md v1).
|
||||
*
|
||||
* One shell for every business dialog: header (56px, fixed) + body (the ONLY
|
||||
* scrolling area) + footer (fixed). 16px of side padding throughout; the body
|
||||
* adds none of its own top/bottom — the header's height and the footer's
|
||||
* padding already hold that gap open. Everything the spec pins down
|
||||
* — the four sizes and their step-down table, the 40% black mask with no blur,
|
||||
* the z-index tier, the 200/160ms curves, the three exits — lives here, so a
|
||||
* business page never restates it.
|
||||
*
|
||||
* Not this component: a confirm/deny prompt (二次确认 has its own component)
|
||||
* and anything that should stay beside the page content (Drawer).
|
||||
*/
|
||||
|
||||
/** §2 — four sizes; `fullscreen` is also what every size becomes on a phone (§7). */
|
||||
export type ModalSize = 'small' | 'medium' | 'large' | 'fullscreen';
|
||||
|
||||
/** Which of the three exits (§6) the user took. */
|
||||
export type ModalDismissSource = 'close-button' | 'overlay' | 'esc';
|
||||
|
||||
/**
|
||||
* §2/§3 — the width ladder, CSS only (no JS measuring).
|
||||
*
|
||||
* Mobile-first: the base state is the phone (< 576px) full-screen sheet, so the
|
||||
* card widths all start at `min-[576px]:`, then step UP at lg (1024) / xl (1280).
|
||||
* `min(<档位>, calc(100vw - 32px))` is the catch-all gutter rule — 16px of page
|
||||
* has to stay visible on each side, or the dialog reads as a pinned panel.
|
||||
*/
|
||||
const SIZE_WIDTH: Record<Exclude<ModalSize, 'fullscreen'>, string> = {
|
||||
small: 'min-[576px]:w-[min(400px,calc(100vw-32px))]',
|
||||
medium: 'min-[576px]:w-[min(400px,calc(100vw-32px))] lg:w-[min(600px,calc(100vw-32px))]',
|
||||
large:
|
||||
'min-[576px]:w-[min(400px,calc(100vw-32px))] lg:w-[min(600px,calc(100vw-32px))] xl:w-[min(960px,calc(100vw-32px))]',
|
||||
};
|
||||
|
||||
/** §5 — 40% black, no blur (a blurred backdrop reads as "I left the page"). */
|
||||
const OVERLAY_CLASS =
|
||||
'fixed inset-0 z-modal bg-black/40 data-[state=open]:animate-modal-overlay-in data-[state=closed]:animate-modal-overlay-out motion-reduce:animate-none';
|
||||
|
||||
/** Phone (< 576px) has no visible outside area — §7 says the mask is not drawn. */
|
||||
const OVERLAY_PHONE_CLASS = 'hidden min-[576px]:block';
|
||||
|
||||
/**
|
||||
* The card itself is the positioned layer — deliberately NOT wrapped in a
|
||||
* centring div: `Dialog.Portal` puts every child in its own `Presence`, and a
|
||||
* plain wrapper (no animation of its own) unmounts the whole subtree the
|
||||
* instant it closes, cutting the 160ms exit short.
|
||||
*/
|
||||
const CONTENT_BASE =
|
||||
'fixed z-modal flex flex-col overflow-hidden bg-bg-page text-text-1 outline-none data-[state=open]:animate-modal-content-in data-[state=closed]:animate-modal-content-out motion-reduce:animate-none';
|
||||
|
||||
/** §7 — phone: the dialog IS the screen. Square corners, no mask, no gutter. */
|
||||
const CONTENT_PHONE = 'inset-0 h-full w-full rounded-none';
|
||||
|
||||
/** §2/§4 — from the tablet档 up: a centred 16px-radius card, capped at 视窗高 - 64px. */
|
||||
const CONTENT_CARD =
|
||||
'min-[576px]:inset-auto min-[576px]:left-1/2 min-[576px]:top-1/2 min-[576px]:-translate-x-1/2 min-[576px]:-translate-y-1/2 min-[576px]:h-auto min-[576px]:max-h-[calc(100vh-64px)] min-[576px]:rounded-2xl min-[576px]:border min-[576px]:border-border-base min-[576px]:shadow-modal';
|
||||
|
||||
export interface ModalProps {
|
||||
/** Controlled visibility. Leave out (with `defaultOpen` / `trigger`) to let the dialog own it. */
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Element that opens the dialog; wired up as the dialog's trigger automatically. */
|
||||
trigger?: React.ReactNode;
|
||||
/** §2 — pick by how WIDE the content is, never by how long it is. */
|
||||
size?: ModalSize;
|
||||
/** Header title. Required: it is what the dialog is announced as. */
|
||||
title: React.ReactNode;
|
||||
/** Optional line under the title, inside the body. */
|
||||
description?: React.ReactNode;
|
||||
/** Body content — the only part that scrolls. */
|
||||
children?: React.ReactNode;
|
||||
/** Footer actions, primary rightmost (组件-Button按钮.md). Omit and the footer is not rendered. */
|
||||
footer?: React.ReactNode;
|
||||
/**
|
||||
* The header「×」(§4). Keep it for form dialogs — it is their only exit; drop it
|
||||
* when the footer already carries a 取消 button, so there are not two ways to say no.
|
||||
*/
|
||||
closable?: boolean;
|
||||
/** Accessible name of the「×」button. Text comes from the caller (library contract). */
|
||||
closeLabel?: string;
|
||||
/** §6 — clicking the mask closes. Turn off for a dialog that must be answered. */
|
||||
maskClosable?: boolean;
|
||||
/** §6 — Esc closes. */
|
||||
escClosable?: boolean;
|
||||
/** §6 — while a submit is in flight ALL three exits are disabled until it returns. */
|
||||
submitting?: boolean;
|
||||
/**
|
||||
* Runs before an exit closes the dialog; return `false` to keep it open.
|
||||
* That is how unsaved input gets its 二次确认 (§6) — the caller opens the
|
||||
* confirm and closes the dialog itself once the user gives it up.
|
||||
*/
|
||||
beforeClose?: (source: ModalDismissSource) => boolean | void;
|
||||
/** Portal target; defaults to `document.body`. */
|
||||
container?: HTMLElement | null;
|
||||
/** Extra classes on the card. */
|
||||
className?: string;
|
||||
/** Extra classes on the body — e.g. `p-0` for a preview that bleeds to the edge. */
|
||||
bodyClassName?: string;
|
||||
/** Extra classes on the footer. */
|
||||
footerClassName?: string;
|
||||
/** Escape hatch for the mask, e.g. `bg-transparent` when this opens ON TOP of another overlay (§5). */
|
||||
overlayClassName?: string;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
size = 'medium',
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
closable = true,
|
||||
closeLabel = 'Close',
|
||||
maskClosable = true,
|
||||
escClosable = true,
|
||||
submitting = false,
|
||||
beforeClose,
|
||||
container,
|
||||
className,
|
||||
bodyClassName,
|
||||
footerClassName,
|
||||
overlayClassName,
|
||||
}: ModalProps) {
|
||||
const isFullscreen = size === 'fullscreen';
|
||||
|
||||
/** True when this exit must NOT close the dialog (§6). */
|
||||
const blocked = React.useCallback(
|
||||
(source: ModalDismissSource) => submitting || beforeClose?.(source) === false,
|
||||
[submitting, beforeClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
|
||||
{trigger ? <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger> : null}
|
||||
<DialogPrimitive.Portal container={container ?? undefined}>
|
||||
{isFullscreen ? null : (
|
||||
<DialogPrimitive.Overlay className={cn(OVERLAY_CLASS, OVERLAY_PHONE_CLASS, overlayClassName)} />
|
||||
)}
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
CONTENT_BASE,
|
||||
CONTENT_PHONE,
|
||||
isFullscreen ? null : [CONTENT_CARD, SIZE_WIDTH[size]],
|
||||
className,
|
||||
)}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (!escClosable || blocked('esc')) event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (!maskClosable || blocked('overlay')) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{/* §4 — header and footer never scroll; only the body does. Same
|
||||
* arrangement at every size, 全屏档 and phone included: title left,
|
||||
*「×」right, actions in the footer (§2). */}
|
||||
<div className="flex h-14 shrink-0 items-center gap-3 px-4">
|
||||
<DialogPrimitive.Title className="min-w-0 flex-1 truncate text-h4 text-text-1">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
{closable ? (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={closeLabel}
|
||||
disabled={submitting}
|
||||
onClick={(event) => {
|
||||
if (blocked('close-button')) event.preventDefault();
|
||||
}}
|
||||
className="btn-touch-hit relative flex size-6 shrink-0 items-center justify-center rounded-md text-text-3 transition-colors hover:bg-fill-1 hover:text-text-1 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Outlined.Close className="size-4" />
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 text-body',
|
||||
bodyClassName,
|
||||
)}
|
||||
>
|
||||
{description ? (
|
||||
<DialogPrimitive.Description className="mb-3 text-body text-text-2">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer ? (
|
||||
<div
|
||||
className={cn(
|
||||
// §2/§7 — whenever the dialog owns the whole screen (any size on
|
||||
// a phone, the 全屏档 everywhere) the actions tile the row edge
|
||||
// to edge and clear the home-indicator safe area; on a card they
|
||||
// sit right-aligned at their natural width.
|
||||
'flex shrink-0 items-center justify-end gap-3 p-4 [&>*]:flex-1',
|
||||
'pb-[calc(16px+env(safe-area-inset-bottom))]',
|
||||
isFullscreen ? null : 'min-[576px]:pb-4 min-[576px]:[&>*]:flex-none',
|
||||
footerClassName,
|
||||
)}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the dialog it sits in — put it on the footer's 取消 button
|
||||
* (`<ModalClose asChild><Button …/></ModalClose>`). It does NOT run
|
||||
* `beforeClose`: that guard is for the three accidental exits, not for a button
|
||||
* the user aimed at.
|
||||
*/
|
||||
export const ModalClose = DialogPrimitive.Close;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Modal, ModalClose } from './Modal';
|
||||
export type { ModalProps, ModalSize, ModalDismissSource } from './Modal';
|
||||
@@ -274,7 +274,7 @@ export function Toaster({ className, closeLabel = 'Close' }: ToasterProps) {
|
||||
// Always top-center, 16px below the viewport edge, above everything —
|
||||
// a toast fired from inside a dialog has to be visible (§3). The exact
|
||||
// layer number lands with the Modal-era z-index table (§11.7).
|
||||
'pointer-events-none fixed inset-x-0 top-4 z-[9999] flex flex-col items-stretch max-md:px-4',
|
||||
'pointer-events-none fixed inset-x-0 top-4 z-toast flex flex-col items-stretch max-md:px-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -13,6 +13,13 @@ export * from './components/ErrorPage';
|
||||
// Empty-state / status illustrations. Inline SVG driven by the --illus-* palette
|
||||
// (blue ⇄ green + grey draft) — artwork only, so the contract above holds.
|
||||
export * from './components/Illustration';
|
||||
// Modal — the centered interrupting overlay (组件-Modal弹窗.md). Sizes, mask,
|
||||
// z-tier, motion and the three exits are baked in; every string is a prop.
|
||||
export * from './components/Modal';
|
||||
// Input family — one shell for every single-line field plus the multi-line one
|
||||
// (组件-Input输入框.md). Sizes, the gray focus chain, the four states and the
|
||||
// touch rules are baked in; every string (placeholder, a11y labels) is a prop.
|
||||
export * from './components/Input';
|
||||
// State page shell — illustration + copy + buttons for an area with no normal
|
||||
// content (组件-State状态页.md). Every string comes from the caller.
|
||||
export * from './components/StateView';
|
||||
|
||||
@@ -122,6 +122,28 @@
|
||||
* Carried as vars so dark mode can retune them without touching components. */
|
||||
--shadow-popup: 0 2px 16px -2px rgba(0, 23, 66, 0.10);
|
||||
--shadow-modal: 0 0 16px 0 rgba(3, 7, 117, 0.05);
|
||||
|
||||
/* ---------- Focus ring (组件-Input输入框.md §5.1) ----------
|
||||
* NOT a third elevation tier: it is a focus INDICATOR, it does not express
|
||||
* height, and every value in it comes from existing tokens (圆角与阴影规范
|
||||
* §4 exception clause).
|
||||
*
|
||||
* Only the COLOR is a variable — the ring geometry lives in the Tailwind
|
||||
* `shadow-focus` utility. A pre-composed `--shadow-focus: 0 0 0 2px
|
||||
* rgb(var(--shadow-focus-ring))` declared here would NOT work: var() inside a
|
||||
* custom property is substituted where that property is DECLARED, so the ring
|
||||
* color would freeze to this :root value and a field in error / warning could
|
||||
* never swap it. */
|
||||
--shadow-focus-ring: var(--fill-2);
|
||||
|
||||
/* ---------- Overlay stacking (组件-Modal弹窗.md §5) — four tiers, nothing else ----------
|
||||
* Each tier must be able to cover the one below it: a dropdown opens inside a
|
||||
* dialog, a toast shows over both, a tooltip beats everything. Carried as vars
|
||||
* so a host app that must sit under a legacy layer can retune them centrally. */
|
||||
--z-modal: 1000;
|
||||
--z-popover: 1100;
|
||||
--z-toast: 1200;
|
||||
--z-tooltip: 1300;
|
||||
}
|
||||
|
||||
/* Dark mode — neutral + semantic layers auto-flip (custom props resolve lazily
|
||||
@@ -242,3 +264,38 @@
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Input hit-area expansion (组件-Input输入框.md §6): the field keeps its 24/32/40
|
||||
* height while the tappable area grows to ≥44px on touch. Two strips above and
|
||||
* below — NOT one overlay like the button's: an overlay lying on the field would
|
||||
* swallow the tap that places the caret. The shell sets `relative` + this class
|
||||
* and focuses the field when a strip is tapped. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.input-touch-hit::before,
|
||||
.input-touch-hit::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: max(0px, calc((44px - 100%) / 2));
|
||||
}
|
||||
.input-touch-hit::before {
|
||||
bottom: 100%;
|
||||
}
|
||||
.input-touch-hit::after {
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS anti-zoom (组件-Input输入框.md §6): Safari zooms the whole page when a
|
||||
* field smaller than 16px takes focus. Every size档 therefore types at 16px on
|
||||
* touch / narrow screens — the 双判定 of 多端适配原则 §4. Element+class selector
|
||||
* on purpose: it must beat the size档's own `text-[length:…]` utility whatever
|
||||
* order the stylesheets end up in. */
|
||||
@media (max-width: 768px), (hover: none) and (pointer: coarse) {
|
||||
input.input-no-zoom,
|
||||
textarea.input-no-zoom {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,51 @@ module.exports = {
|
||||
boxShadow: {
|
||||
popup: 'var(--shadow-popup)',
|
||||
modal: 'var(--shadow-modal)',
|
||||
// `shadow-focus` is the control focus RING (组件-Input输入框.md §5.1),
|
||||
// not a third elevation tier — see the exception note in tokens.css.
|
||||
// The geometry is spelled out here rather than carried in a var so the
|
||||
// color resolves ON THE ELEMENT: a field in error / warning overrides
|
||||
// `--shadow-focus-ring` and the ring follows.
|
||||
focus: '0 0 0 2px rgb(var(--shadow-focus-ring))',
|
||||
},
|
||||
// Overlay stacking (组件-Modal弹窗.md §5 / design-token.cjs Z_INDEX) — the
|
||||
// only four layers there are. `z-modal` < `z-popover` < `z-toast` <
|
||||
// `z-tooltip`; anything else is a hand-rolled value and off-spec.
|
||||
zIndex: {
|
||||
modal: 'var(--z-modal)',
|
||||
popover: 'var(--z-popover)',
|
||||
toast: 'var(--z-toast)',
|
||||
tooltip: 'var(--z-tooltip)',
|
||||
},
|
||||
keyframes: {
|
||||
'modal-overlay-in': {
|
||||
from: { opacity: '0' },
|
||||
to: { opacity: '1' },
|
||||
},
|
||||
'modal-overlay-out': {
|
||||
from: { opacity: '1' },
|
||||
to: { opacity: '0' },
|
||||
},
|
||||
// `scale` (the standalone property), NOT `transform: scale()` — the card
|
||||
// is centred with `translate(-50%, -50%)` on the desktop档 and with
|
||||
// `inset: 0` on the phone档, so the animation must not own `transform`.
|
||||
'modal-content-in': {
|
||||
from: { opacity: '0', scale: '0.96' },
|
||||
to: { opacity: '1', scale: '1' },
|
||||
},
|
||||
'modal-content-out': {
|
||||
from: { opacity: '1' },
|
||||
to: { opacity: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
// Modal / dialog motion (组件-Modal弹窗.md §6): 200ms in (fade + 96%→100%),
|
||||
// 160ms out (fade ONLY — shrinking on exit reads as "it went back
|
||||
// somewhere"). Same curve both ways.
|
||||
'modal-overlay-in': 'modal-overlay-in 200ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
'modal-overlay-out': 'modal-overlay-out 160ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
'modal-content-in': 'modal-content-in 200ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
'modal-content-out': 'modal-content-out 160ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+6
@@ -6,6 +6,9 @@ settings:
|
||||
|
||||
catalogs:
|
||||
default:
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.0.2
|
||||
version: 1.1.20
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.1.0
|
||||
version: 1.3.0
|
||||
@@ -535,6 +538,9 @@ importers:
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: 'catalog:'
|
||||
version: 1.1.20(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.0(@types/react@18.3.31)(react@18.3.1)
|
||||
|
||||
@@ -15,6 +15,7 @@ catalog:
|
||||
'@types/react': ^18.2.11
|
||||
'@types/react-dom': ^18.2.4
|
||||
'@radix-ui/react-slot': ^1.1.0
|
||||
'@radix-ui/react-dialog': ^1.0.2
|
||||
bisheng-icons: ^0.2.30
|
||||
class-variance-authority: ^0.6.0
|
||||
clsx: ^1.2.1
|
||||
|
||||
Reference in New Issue
Block a user