mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-30 17:58:00 +08:00
Merge branch 'feat/2.8-common' into feat/cofco-902
Brings the design-system work and the rest of the 2.8 line onto the customer branch. Direction is one-way as always: general work flows down into the customization, never the reverse. The user pop menu is gone, per 2.8: clicking the avatar goes straight to the settings page, logout lives in its 账号信息 section, and approvals are a section there rather than their own dialog. MessageApprovalDialog and the retired dev gallery are deleted with it. 902's WeCom viewport hardening on that dialog goes too and is not needed — the settings page is a routed page, not a full-screen overlay, so the scaled-viewport overflow it worked around cannot arise. The font-size placement fix for the pop menu is likewise moot now. Conflicts otherwise resolved as "2.8's appearance, 902's behaviour", since 2.8 was replacing hand-rolled markup with spec components underneath features this branch had already built on top: - UnifiedPermissionControls — took the spec RadioCard, kept per-option disabling, which 2.8 has no equivalent for and COFCO uses to lock the private access mode. - PermissionDraftPickerDialog — took 2.8's conditional panes, kept the department-tree picker's two APIs; 2.8 still wires the flat user list, which this branch's SubjectSearchUser no longer accepts. - FileListRow — took 2.8's stopPropagation wrapper (rows toggle selection now) around 902's pending-upload approval actions. - SpaceDetail — both sides added disjoint props; kept both. - AddSourceDropdown — dropped the hardcoded MAX_SOURCES for 2.8's role-scoped info-source quota, which defaults to the same 200. - KnowledgeAiBottomDock — kept 902's DockControls; 2.8's change only nudged the single expand button this branch already replaced, to a margin it already has. - KnowledgeListPanel / KnowledgeSpaceSelect — took the spec SearchInput. - Three locale files — pure key additions on both sides, kept the union. Claude-Session: https://claude.ai/code/session_01GFB4nTgvifwbGSnaAVeRTB
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "client",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["dev"],
|
||||
"cwd": "src/frontend/client",
|
||||
"port": 4001
|
||||
},
|
||||
{
|
||||
"name": "platform",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["start", "--", "--host", "0.0.0.0"],
|
||||
"cwd": "src/frontend/platform",
|
||||
"port": 3001
|
||||
},
|
||||
{
|
||||
"name": "docs",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["dev:ui"],
|
||||
"cwd": "src/frontend",
|
||||
"port": 3000,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Component index table for /components/ — derived from the site, never hand-written.
|
||||
*
|
||||
* Everything in the table comes from rspress runtime `siteData`:
|
||||
* - `siteData.pages` -> the demo pages that actually exist (title, route, toc, frontmatter)
|
||||
* - `siteData.themeConfig.sidebar` -> which group each page belongs to, and drift in both directions
|
||||
*
|
||||
* plus `__UI_COMPONENTS__` (rspress.config.ts) -> what @bisheng/ui actually exports,
|
||||
* which is what the 「已迁库」 badge reads. A page states WHICH component it documents
|
||||
* (`component:` front matter) and the library states whether that component is in it,
|
||||
* so nobody has to remember to flip a badge on migration day. Identities are stable;
|
||||
* statuses are what drift.
|
||||
*
|
||||
* So adding a demo page = create the mdx + a `status` front matter line + a sidebar
|
||||
* entry; the row grows by itself. Nothing here can go stale, because there is no
|
||||
* copy of the truth to forget to update. Drift that IS possible (page without a
|
||||
* sidebar entry, sidebar entry without a page, a `component:` the library does not
|
||||
* export) is rendered as a warning row instead of silently disappearing.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { usePageData } from 'rspress/runtime';
|
||||
|
||||
/** Build stamp injected by rspress.config.ts — surfaced as a data attribute, not as page copy. */
|
||||
declare const __DOCS_BUILD__: { time: string; sha: string; branch: string };
|
||||
|
||||
/** What @bisheng/ui exports, injected by rspress.config.ts — drives the 已迁库 badge. */
|
||||
declare const __UI_COMPONENTS__: string[];
|
||||
|
||||
const SECTION = '/components/';
|
||||
|
||||
interface SitePage {
|
||||
title: string;
|
||||
routePath: string;
|
||||
frontmatter?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SidebarItem {
|
||||
text: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface SidebarGroup {
|
||||
text: string;
|
||||
items?: SidebarItem[];
|
||||
}
|
||||
|
||||
interface Row {
|
||||
title: string;
|
||||
route: string;
|
||||
group: string;
|
||||
status: string;
|
||||
/** True when the component this page documents is exported by @bisheng/ui. */
|
||||
migrated: boolean;
|
||||
note: string;
|
||||
/** Drift marker; empty when the page, the sidebar and the library agree. */
|
||||
warning: string;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
done: '✅ 已落地',
|
||||
draft: '🟨 未定稿',
|
||||
todo: '⬜ 待补',
|
||||
};
|
||||
|
||||
const cell: React.CSSProperties = { verticalAlign: 'top' };
|
||||
const dim: React.CSSProperties = { color: 'var(--rp-c-text-2)' };
|
||||
|
||||
function normalizeRoute(route: string): string {
|
||||
const clean = route.split(/[?#]/)[0];
|
||||
return clean.length > 1 && clean.endsWith('/') ? clean.slice(0, -1) : clean;
|
||||
}
|
||||
|
||||
export function ComponentIndex() {
|
||||
const { siteData } = usePageData();
|
||||
|
||||
const pages = (siteData.pages as unknown as SitePage[]).filter((page) => {
|
||||
const route = normalizeRoute(page.routePath);
|
||||
return route.startsWith(SECTION) && route !== normalizeRoute(SECTION);
|
||||
});
|
||||
|
||||
const sidebar = (siteData.themeConfig?.sidebar ?? {}) as Record<string, SidebarGroup[]>;
|
||||
const demoGroups = sidebar[SECTION] ?? [];
|
||||
|
||||
const groupOf = new Map<string, string>();
|
||||
const sidebarOrder: string[] = [];
|
||||
demoGroups.forEach((group) => {
|
||||
(group.items ?? []).forEach((item) => {
|
||||
const route = normalizeRoute(item.link);
|
||||
groupOf.set(route, group.text);
|
||||
sidebarOrder.push(route);
|
||||
});
|
||||
});
|
||||
|
||||
// Pages name the component they document; the library decides whether it is in.
|
||||
const inLibrary = new Set(typeof __UI_COMPONENTS__ === 'undefined' ? [] : __UI_COMPONENTS__);
|
||||
|
||||
const rows: Row[] = pages.map((page) => {
|
||||
const route = normalizeRoute(page.routePath);
|
||||
const fm = page.frontmatter ?? {};
|
||||
const status = typeof fm.status === 'string' ? fm.status : '';
|
||||
const component = typeof fm.component === 'string' ? fm.component : '';
|
||||
const warnings = [
|
||||
groupOf.has(route) ? '' : '页面存在,但没挂进侧边栏',
|
||||
// Declared an identity the library does not back: the component was renamed,
|
||||
// dropped from src/index.ts, or the front matter has a typo.
|
||||
component && !inLibrary.has(component) ? `front matter 声明的 ${component} 不在 @bisheng/ui 的导出里` : '',
|
||||
].filter(Boolean);
|
||||
return {
|
||||
title: page.title || route.replace(SECTION, ''),
|
||||
route,
|
||||
group: groupOf.get(route) ?? '',
|
||||
status: STATUS_LABEL[status] ?? (status || '⬜ 未标注'),
|
||||
migrated: component !== '' && inLibrary.has(component),
|
||||
note: typeof fm.statusNote === 'string' ? fm.statusNote : '',
|
||||
warning: warnings.join(';'),
|
||||
};
|
||||
});
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const ia = sidebarOrder.indexOf(a.route);
|
||||
const ib = sidebarOrder.indexOf(b.route);
|
||||
return (ia < 0 ? Number.MAX_SAFE_INTEGER : ia) - (ib < 0 ? Number.MAX_SAFE_INTEGER : ib);
|
||||
});
|
||||
|
||||
const existing = new Set(rows.map((row) => row.route));
|
||||
|
||||
// Sidebar entries whose page was renamed or never written.
|
||||
const brokenLinks = sidebarOrder.filter((route) => !existing.has(route));
|
||||
|
||||
const stamp =
|
||||
typeof __DOCS_BUILD__ === 'undefined'
|
||||
? undefined
|
||||
: `${__DOCS_BUILD__.time} · ${__DOCS_BUILD__.sha} (${__DOCS_BUILD__.branch})`;
|
||||
|
||||
return (
|
||||
// The build stamp is deliberately not rendered — it lives on the wrapper as a
|
||||
// data attribute, so devtools can tell how old a static deploy is without the
|
||||
// page carrying a maintenance note for readers.
|
||||
<div data-docs-build={stamp}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>组件</th>
|
||||
<th>分组</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.route}>
|
||||
<td style={cell}>
|
||||
<a href={row.route}>{row.title}</a>
|
||||
{row.warning && <div style={{ color: 'var(--rp-c-danger-1, #f53f3f)' }}>⚠ {row.warning}</div>}
|
||||
</td>
|
||||
<td style={{ ...cell, ...dim }}>{row.group || '—'}</td>
|
||||
<td style={cell}>
|
||||
{row.status}
|
||||
{row.migrated && <span style={dim}> · 已迁库</span>}
|
||||
{row.note && <span style={dim}> · {row.note}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{brokenLinks.length > 0 && (
|
||||
<p style={{ color: 'var(--rp-c-danger-1, #f53f3f)' }}>
|
||||
⚠ 侧边栏指向了不存在的页面:{brokenLinks.join('、')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import SettingsPage from "./SettingsPage";
|
||||
|
||||
const mockRefreshCount = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
jest.mock("recoil", () => ({
|
||||
...jest.requireActual("recoil"),
|
||||
useSetRecoilState: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("~/hooks", () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
usePrefersMobileLayout: () => false,
|
||||
}));
|
||||
|
||||
jest.mock("~/hooks/useNotificationCount", () => ({
|
||||
useNotificationCount: () => ({
|
||||
pendingApprovalCount: 0,
|
||||
refreshCount: mockRefreshCount,
|
||||
unreadCount: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("~/components/approval/ApprovalPane", () => ({
|
||||
ApprovalPane: () => <div>approval-pane</div>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/messageApproval/NotificationPane", () => ({
|
||||
NotificationPane: () => <div>notification-pane</div>,
|
||||
}));
|
||||
|
||||
jest.mock("~/components/Settings/sections/GeneralSection", () => ({
|
||||
GeneralSection: () => <div>general-section</div>,
|
||||
}));
|
||||
|
||||
jest.mock("./sections/AccountPane", () => ({
|
||||
AccountPane: () => <div>account-pane</div>,
|
||||
}));
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location">{`${location.pathname}${location.search}${location.hash}`}</div>;
|
||||
}
|
||||
|
||||
describe("SettingsPage history", () => {
|
||||
it("returns to the entry page after settings sidebar navigation", () => {
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{
|
||||
pathname: "/settings/account",
|
||||
state: {
|
||||
settingsOrigin: {
|
||||
historyIndex: null,
|
||||
path: "/knowledge/space/42?tab=files#recent",
|
||||
},
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="settings/:section?" element={<SettingsPage />} />
|
||||
<Route path="*" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "com_message_approval_notifications" }));
|
||||
expect(screen.getByText("notification-pane")).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "com_ui_go_back" }));
|
||||
expect(screen.getByTestId("location").textContent).toContain(
|
||||
"/knowledge/space/42?tab=files#recent",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Badge } from "@bisheng/ui";
|
||||
import { Outlined } from "bisheng-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
// store.mobileSystemMenuOpenState is a shared legacy atom (same usage as Subscription/knowledge pages).
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSetRecoilState } from "recoil";
|
||||
import { ApprovalPane } from "~/components/approval/ApprovalPane";
|
||||
import { NotificationPane } from "~/components/messageApproval/NotificationPane";
|
||||
import { useLocalize, usePrefersMobileLayout } from "~/hooks";
|
||||
import { useNotificationCount } from "~/hooks/useNotificationCount";
|
||||
import store from "~/store";
|
||||
import { cn } from "~/utils";
|
||||
import { AccountPane } from "./sections/AccountPane";
|
||||
import { GeneralSection } from "~/components/Settings/sections/GeneralSection";
|
||||
import {
|
||||
readSettingsRouteState,
|
||||
resolveSettingsExitTarget,
|
||||
type SettingsRouteState,
|
||||
} from "./settingsHistory";
|
||||
import {
|
||||
approvalTabOf,
|
||||
DEFAULT_SETTINGS_SECTION,
|
||||
isSettingsPageSection,
|
||||
SETTINGS_NAV_GROUPS,
|
||||
SETTINGS_NAV_ITEMS,
|
||||
type SettingsPageSection,
|
||||
} from "./settingsSections";
|
||||
|
||||
/**
|
||||
* 设置 page — replaces the old SettingsDialog + MessageApprovalDialog pair with one
|
||||
* routed page (/settings/:section). One flat nav: approval + notifications first,
|
||||
* personal settings after the divider.
|
||||
*
|
||||
* History model differs by layout:
|
||||
* - Desktop: sidebar moves REPLACE the entry — settings holds exactly one entry, so
|
||||
* back always leaves settings to wherever the user came from.
|
||||
* - Mobile: /settings (no section) is a menu landing; picking a module PUSHES, so
|
||||
* both the top back button and system back pop to the menu first, and backing out
|
||||
* of the menu leaves settings.
|
||||
*
|
||||
* Ownership stays strict (same as the retired dialog): an approval that still needs
|
||||
* handling lives only under 我的审批-待我处理, and 通知 only informs — nothing here
|
||||
* decrements the pending count except a real decision.
|
||||
*/
|
||||
export default function SettingsPage() {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isMobile = usePrefersMobileLayout();
|
||||
const setSystemMenuOpen = useSetRecoilState(store.mobileSystemMenuOpenState);
|
||||
const { section: rawSection } = useParams<{ section?: string }>();
|
||||
const { unreadCount, pendingApprovalCount, refreshCount } = useNotificationCount();
|
||||
|
||||
// Compact (<768px) approval master-detail state, owned here so the nav can reset it.
|
||||
const [compactView, setCompactView] = useState<"list" | "detail">("list");
|
||||
/** Set when a notification jumps into an approval detail. */
|
||||
const [deepLink, setDeepLink] = useState<{ taskId?: number | null; instanceId?: number | null } | null>(null);
|
||||
|
||||
/** Mobile-only menu landing: /settings with no section segment. */
|
||||
const isMenu = isMobile && rawSection == null;
|
||||
|
||||
/** The entry source is captured once; all navigation inside settings only carries it. */
|
||||
const settingsRouteState = readSettingsRouteState(location.state);
|
||||
const cameFromMenu = settingsRouteState.fromSettingsMenu === true;
|
||||
|
||||
const section: SettingsPageSection = isSettingsPageSection(rawSection)
|
||||
? rawSection
|
||||
: DEFAULT_SETTINGS_SECTION;
|
||||
|
||||
// Section changes can change both counts (decisions, read state) — keep the badges fresh.
|
||||
useEffect(() => {
|
||||
void refreshCount();
|
||||
}, [section, refreshCount]);
|
||||
|
||||
// Leaves settings for the route that opened it. Direct visits fall back to browser
|
||||
// history and then home when this is the first entry in the tab.
|
||||
const leaveSettings = () => {
|
||||
const target = resolveSettingsExitTarget(settingsRouteState, window.history.state?.idx);
|
||||
if (target.delta !== undefined) navigate(target.delta);
|
||||
else navigate(target.path, { replace: true });
|
||||
};
|
||||
|
||||
if (!isMenu && !isSettingsPageSection(rawSection)) {
|
||||
// Desktop has no menu screen — /settings and unknown sections land on the default
|
||||
// section. Mobile unknown sections land on the menu.
|
||||
return (
|
||||
<Navigate
|
||||
to={isMobile ? "/settings" : `/settings/${DEFAULT_SETTINGS_SECTION}`}
|
||||
replace
|
||||
state={settingsRouteState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop sidebar/deep-link moves REPLACE the history entry: settings keeps exactly
|
||||
// one entry, so 返回 (and the browser's own back) leads to whatever page the user
|
||||
// was on before opening settings, never through the sections they browsed here.
|
||||
const goToSection = (next: SettingsPageSection) => {
|
||||
setCompactView("list");
|
||||
setDeepLink(null);
|
||||
navigate(`/settings/${next}`, { replace: true, state: settingsRouteState });
|
||||
};
|
||||
|
||||
// Mobile menu → module PUSHES (flagged), so back — button or gesture — pops to the menu.
|
||||
const openSectionFromMenu = (next: SettingsPageSection) => {
|
||||
setCompactView("list");
|
||||
setDeepLink(null);
|
||||
navigate(`/settings/${next}`, {
|
||||
state: { ...settingsRouteState, fromSettingsMenu: true } satisfies SettingsRouteState,
|
||||
});
|
||||
};
|
||||
|
||||
// Mobile module back → the menu. Pop when the menu pushed this entry; otherwise
|
||||
// (deep link, external redirect) swap it for the menu so backing out still leaves.
|
||||
const backToMenu = () => {
|
||||
if (cameFromMenu && (window.history.state?.idx ?? 0) > 0) navigate(-1);
|
||||
else {
|
||||
const menuState: SettingsRouteState = settingsRouteState.settingsOrigin
|
||||
? { settingsOrigin: settingsRouteState.settingsOrigin }
|
||||
: {};
|
||||
navigate("/settings", { replace: true, state: menuState });
|
||||
}
|
||||
};
|
||||
|
||||
const approvalTab = approvalTabOf(section);
|
||||
const isApproval = approvalTab != null;
|
||||
const isNotifications = section === "notifications";
|
||||
|
||||
const navBadge = (key: SettingsPageSection) =>
|
||||
key === "my-tasks" ? pendingApprovalCount : key === "notifications" ? unreadCount : 0;
|
||||
|
||||
// Mobile menu landing — the grouped nav as a full screen. Rows mirror the desktop
|
||||
// sidebar's grouping but at touch size; the hamburger stays here (module screens
|
||||
// swap it for a back button).
|
||||
if (isMenu) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
||||
<div className="sticky top-0 z-[50] w-full shrink-0 bg-white pt-[calc(env(safe-area-inset-top,0px)+8px)]">
|
||||
<div className="relative flex h-11 min-h-11 w-full flex-row items-center justify-between px-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize("com_nav_open_sidebar")}
|
||||
onClick={() => setSystemMenuOpen(true)}
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center text-text-1"
|
||||
>
|
||||
<Outlined.SidebarMenu className="size-5" />
|
||||
</button>
|
||||
<span className="pointer-events-none absolute left-1/2 -translate-x-1/2 truncate text-[16px] font-medium leading-6 text-text-1">
|
||||
{localize("com_nav_settings")}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
<div className="scrollbar-os min-h-0 flex-1 overflow-y-auto px-3 pb-6 pt-1">
|
||||
{SETTINGS_NAV_GROUPS.map((group, groupIdx) => (
|
||||
<div key={group.labelKey} className="flex flex-col gap-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 pl-3 text-[13px] text-text-3",
|
||||
groupIdx === 0 ? "pt-2" : "pt-5",
|
||||
)}
|
||||
>
|
||||
{localize(group.labelKey)}
|
||||
</div>
|
||||
{group.items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="flex h-11 items-center justify-between gap-3 rounded-lg px-3 text-left text-[15px] leading-[22px] text-text-1 transition-colors coarse-pointer:active:bg-fill-2"
|
||||
onClick={() => openSectionFromMenu(item.key)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<ItemIcon className="size-5 shrink-0" />
|
||||
<span className="truncate">{localize(item.labelKey)}</span>
|
||||
</span>
|
||||
{/* 组件-Badge徽标.md §2 — the standalone number, in danger:
|
||||
these counts are things waiting on the user, not a
|
||||
neutral item count. 0 renders nothing on its own, and
|
||||
the count is shown as-is (the old hand-rolled badge
|
||||
collapsed anything past 99 into 「99+」; §3 dropped that
|
||||
— a four-digit unread count is a notification-policy
|
||||
problem, not something to hide behind a plus sign). */}
|
||||
<Badge color="danger" count={navBadge(item.key)} className="shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Every content pane carries the active section's name as its title (desktop only —
|
||||
// the mobile top bar + tabs already announce the section). Style and top spacing
|
||||
// mirror the nav's 设置 heading (text-base leading-8 at 16px from the top).
|
||||
const activeNavItem = SETTINGS_NAV_ITEMS.find((item) => item.key === section);
|
||||
const sectionTitle = activeNavItem ? localize(activeNavItem.labelKey) : "";
|
||||
const paneTitleClass = "text-base font-semibold leading-8 text-text-1";
|
||||
|
||||
const content = isApproval ? (
|
||||
// The title rides inside the list column (via listHeader), so the detail column
|
||||
// runs the full height of the content panel instead of starting below a header strip.
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[300px_minmax(0,1fr)]">
|
||||
<ApprovalPane
|
||||
open
|
||||
activeTab={approvalTab}
|
||||
target={deepLink ?? undefined}
|
||||
compactView={compactView}
|
||||
setCompactView={setCompactView}
|
||||
onPendingCountMaybeChanged={refreshCount}
|
||||
listHeader={
|
||||
<h2 className={cn("hidden shrink-0 px-3 pt-4 md:block", paneTitleClass)}>
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : isNotifications ? (
|
||||
// Same single-block shell as 账号信息 / 通用: one padded pane with a centered
|
||||
// 720px column holding title, search row and list together.
|
||||
<div className="flex min-h-0 flex-1 flex-col px-5 pb-3 pt-4">
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-[720px] flex-1 flex-col">
|
||||
<h2 className={cn("hidden shrink-0 pb-3 md:block", paneTitleClass)}>{sectionTitle}</h2>
|
||||
<NotificationPane
|
||||
open
|
||||
onOpenApprovalCenter={(approvalTarget) => {
|
||||
setDeepLink({ taskId: approvalTarget.taskId, instanceId: approvalTarget.instanceId });
|
||||
setCompactView("detail");
|
||||
// Replace keeps the module depth flat; carrying state preserves whether
|
||||
// the mobile menu pushed this entry (so its back button still pops).
|
||||
navigate(`/settings/${approvalTarget.tab === "my_requests" ? "my-requests" : "my-tasks"}`, {
|
||||
replace: true,
|
||||
state: settingsRouteState,
|
||||
});
|
||||
}}
|
||||
onUnreadMaybeChanged={refreshCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="scrollbar-os min-h-0 flex-1 overflow-y-auto px-5 pb-5 pt-4">
|
||||
<div className="mx-auto w-full max-w-[720px]">
|
||||
<h2 className={cn("hidden pb-3 md:block", paneTitleClass)}>{sectionTitle}</h2>
|
||||
{section === "account" && <AccountPane />}
|
||||
{section === "general" && <GeneralSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
||||
{/* Mobile module top bar — back leads to the settings menu, title names the module. */}
|
||||
{isMobile ? (
|
||||
<div className="sticky top-0 z-[50] w-full shrink-0 bg-white pt-[calc(env(safe-area-inset-top,0px)+8px)]">
|
||||
<div className="relative flex h-11 min-h-11 w-full flex-row items-center justify-between px-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize("com_ui_go_back")}
|
||||
onClick={backToMenu}
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center text-text-1"
|
||||
>
|
||||
<Outlined.ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<span className="pointer-events-none absolute left-1/2 max-w-[60%] -translate-x-1/2 truncate text-[16px] font-medium leading-6 text-text-1">
|
||||
{sectionTitle}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
{/* Desktop: vertical nav on the left — padding mirrors the home (Seedmind) sidebar:
|
||||
panel pt-4 px-3 pb-3, title pl-3 leading-8, 12px gap before the item groups.
|
||||
Group captions reuse the home sidebar's date-label style. */}
|
||||
<nav className="hidden w-[200px] shrink-0 flex-col border-r border-fill-2 px-3 pb-3 pt-4 md:flex">
|
||||
<div className="flex items-center gap-1 pl-2">
|
||||
{/* Leaves settings entirely: section switches replace their history entry, so
|
||||
one step back lands on the page the user opened settings from. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize("com_ui_go_back")}
|
||||
onClick={leaveSettings}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-2 transition-colors hover:bg-fill-2 hover:text-text-1"
|
||||
>
|
||||
<Outlined.ArrowLeft className="size-4" />
|
||||
</button>
|
||||
<span aria-hidden className="h-3.5 w-px shrink-0 bg-border-base" />
|
||||
<h1 className="ml-1 text-base font-semibold leading-8 text-text-1">
|
||||
{localize("com_nav_settings")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col pt-3">
|
||||
{SETTINGS_NAV_GROUPS.map((group, groupIdx) => (
|
||||
<div key={group.labelKey} className="flex flex-col gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 pl-3 text-[12px] text-text-3",
|
||||
groupIdx === 0 ? "pt-0" : "pt-4",
|
||||
)}
|
||||
>
|
||||
{localize(group.labelKey)}
|
||||
</div>
|
||||
{group.items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
// 32px row: 22px content line + 5px vertical padding, matching the home sidebar rows.
|
||||
"flex items-center justify-between gap-2 rounded-lg px-3 py-[5px] text-left text-[14px] leading-[22px] transition-colors",
|
||||
section === item.key
|
||||
? "bg-fill-2 font-medium text-text-1"
|
||||
: "text-text-2 hover:bg-fill-1",
|
||||
)}
|
||||
onClick={() => goToSection(item.key)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ItemIcon className="size-4 shrink-0" />
|
||||
<span className="truncate">{localize(item.labelKey)}</span>
|
||||
</span>
|
||||
<Badge color="danger" count={navBadge(item.key)} className="shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">{content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Outlined } from "bisheng-icons";
|
||||
import { useState } from "react";
|
||||
import { AccountSection } from "~/components/Settings/sections/AccountSection";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { useAuthContext, useLocalize } from "~/hooks";
|
||||
|
||||
/**
|
||||
* 账号信息 section of the settings page: basic info + security (AccountSection)
|
||||
* plus the sign-out action, which moved here from the retired avatar pop menu.
|
||||
*/
|
||||
export function AccountPane() {
|
||||
const localize = useLocalize();
|
||||
const { user, logout } = useAuthContext();
|
||||
const displayName = user?.username || "admin";
|
||||
const [avatarUrl, setAvatarUrl] = useState<string>(user?.avatar || "");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<AccountSection
|
||||
username={displayName}
|
||||
avatarUrl={avatarUrl || user?.avatar || ""}
|
||||
onAvatarUpdated={setAvatarUrl}
|
||||
/>
|
||||
|
||||
<section className="flex flex-col gap-4 border-t border-fill-2 pt-5">
|
||||
<Button color="danger" variant="filled" className="w-fit" onClick={() => logout()}>
|
||||
<Outlined.LogOut />
|
||||
{localize("com_nav_log_out")}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
createSettingsRouteState,
|
||||
readSettingsRouteState,
|
||||
resolveSettingsExitTarget,
|
||||
} from "./settingsHistory";
|
||||
|
||||
describe("settings history", () => {
|
||||
it("captures the exact page that opened settings", () => {
|
||||
expect(
|
||||
createSettingsRouteState(
|
||||
{ pathname: "/knowledge/space/42", search: "?tab=files", hash: "#recent" },
|
||||
7,
|
||||
),
|
||||
).toEqual({
|
||||
settingsOrigin: {
|
||||
historyIndex: 7,
|
||||
path: "/knowledge/space/42?tab=files#recent",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps only valid settings-owned route state", () => {
|
||||
expect(
|
||||
readSettingsRouteState({
|
||||
fromSettingsMenu: true,
|
||||
ignored: "value",
|
||||
settingsOrigin: { historyIndex: 4, path: "/c/123?mode=daily" },
|
||||
}),
|
||||
).toEqual({
|
||||
fromSettingsMenu: true,
|
||||
settingsOrigin: { historyIndex: 4, path: "/c/123?mode=daily" },
|
||||
});
|
||||
|
||||
expect(
|
||||
readSettingsRouteState({ settingsOrigin: { historyIndex: 1, path: "/settings/general" } }),
|
||||
).toEqual({});
|
||||
expect(
|
||||
readSettingsRouteState({ settingsOrigin: { historyIndex: 1, path: "https://example.com" } }),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("jumps over any settings-only entries to the source history position", () => {
|
||||
expect(
|
||||
resolveSettingsExitTarget(
|
||||
{ settingsOrigin: { historyIndex: 3, path: "/knowledge" } },
|
||||
6,
|
||||
),
|
||||
).toEqual({ delta: -3 });
|
||||
});
|
||||
|
||||
it("uses the captured path when the browser history index cannot be trusted", () => {
|
||||
expect(
|
||||
resolveSettingsExitTarget(
|
||||
{ settingsOrigin: { historyIndex: null, path: "/channel/8?tab=articles" } },
|
||||
undefined,
|
||||
),
|
||||
).toEqual({ path: "/channel/8?tab=articles" });
|
||||
});
|
||||
|
||||
it("preserves the direct-visit fallback", () => {
|
||||
expect(resolveSettingsExitTarget({}, 2)).toEqual({ delta: -1 });
|
||||
expect(resolveSettingsExitTarget({}, 0)).toEqual({ path: "/" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Location } from "react-router-dom";
|
||||
|
||||
export interface SettingsOrigin {
|
||||
historyIndex: number | null;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SettingsRouteState {
|
||||
fromSettingsMenu?: true;
|
||||
settingsOrigin?: SettingsOrigin;
|
||||
}
|
||||
|
||||
export type SettingsExitTarget =
|
||||
| { delta: number; path?: never }
|
||||
| { delta?: never; path: string };
|
||||
|
||||
type SourceLocation = Pick<Location, "hash" | "pathname" | "search">;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isSafeOriginPath(path: unknown): path is string {
|
||||
return (
|
||||
typeof path === "string" &&
|
||||
path.startsWith("/") &&
|
||||
!path.startsWith("//") &&
|
||||
!/^\/settings(?:[/?#]|$)/.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function readHistoryIndex(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/** Capture the route that opened settings once; settings navigation must only carry it forward. */
|
||||
export function createSettingsRouteState(
|
||||
location: SourceLocation,
|
||||
historyIndex: unknown,
|
||||
): SettingsRouteState {
|
||||
return {
|
||||
settingsOrigin: {
|
||||
historyIndex: readHistoryIndex(historyIndex),
|
||||
path: `${location.pathname}${location.search}${location.hash}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Read only the state owned by the settings route and discard malformed deep-link state. */
|
||||
export function readSettingsRouteState(value: unknown): SettingsRouteState {
|
||||
if (!isRecord(value)) return {};
|
||||
|
||||
const state: SettingsRouteState = {};
|
||||
if (value.fromSettingsMenu === true) state.fromSettingsMenu = true;
|
||||
|
||||
if (isRecord(value.settingsOrigin) && isSafeOriginPath(value.settingsOrigin.path)) {
|
||||
state.settingsOrigin = {
|
||||
historyIndex: readHistoryIndex(value.settingsOrigin.historyIndex),
|
||||
path: value.settingsOrigin.path,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer returning to the exact source history entry so its in-memory route state survives.
|
||||
* The saved path is the deterministic fallback if browser history was replaced or reloaded.
|
||||
*/
|
||||
export function resolveSettingsExitTarget(
|
||||
state: SettingsRouteState,
|
||||
currentHistoryIndex: unknown,
|
||||
): SettingsExitTarget {
|
||||
const currentIndex = readHistoryIndex(currentHistoryIndex);
|
||||
const origin = state.settingsOrigin;
|
||||
|
||||
if (origin) {
|
||||
if (
|
||||
origin.historyIndex !== null &&
|
||||
currentIndex !== null &&
|
||||
currentIndex > origin.historyIndex
|
||||
) {
|
||||
return { delta: origin.historyIndex - currentIndex };
|
||||
}
|
||||
return { path: origin.path };
|
||||
}
|
||||
|
||||
return currentIndex !== null && currentIndex > 0 ? { delta: -1 } : { path: "/" };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Outlined } from "bisheng-icons";
|
||||
import type { ComponentType } from "react";
|
||||
import type { ApprovalCenterTab } from "~/api/approval";
|
||||
|
||||
/** Sections in two labeled groups: personal settings first, then approval + notifications. */
|
||||
export type SettingsPageSection =
|
||||
| "my-tasks"
|
||||
| "my-requests"
|
||||
| "notifications"
|
||||
| "account"
|
||||
| "general";
|
||||
|
||||
export interface SettingsNavItem {
|
||||
key: SettingsPageSection;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export interface SettingsNavGroup {
|
||||
/** Group caption above the items — same style as the home sidebar's date labels. */
|
||||
labelKey: string;
|
||||
items: SettingsNavItem[];
|
||||
}
|
||||
|
||||
export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
|
||||
{
|
||||
labelKey: "com_settings_group_personal",
|
||||
items: [
|
||||
{ key: "account", labelKey: "com_account_info_title", icon: Outlined.PeopleEdit },
|
||||
{ key: "general", labelKey: "com_settings_general", icon: Outlined.Setting },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "com_settings_group_messages",
|
||||
items: [
|
||||
{ key: "my-tasks", labelKey: "com_approval_my_approval", icon: Outlined.ApprovalTodo },
|
||||
{ key: "my-requests", labelKey: "com_approval_my_requests", icon: Outlined.ApprovalSubmitted },
|
||||
{ key: "notifications", labelKey: "com_message_approval_notifications", icon: Outlined.Bell },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = SETTINGS_NAV_GROUPS.flatMap(
|
||||
(group) => group.items,
|
||||
);
|
||||
|
||||
export const DEFAULT_SETTINGS_SECTION: SettingsPageSection = "account";
|
||||
|
||||
export function isSettingsPageSection(value: unknown): value is SettingsPageSection {
|
||||
return SETTINGS_NAV_ITEMS.some((item) => item.key === value);
|
||||
}
|
||||
|
||||
/** The two approval sections map onto the approval-center tabs the pane understands. */
|
||||
export function approvalTabOf(section: SettingsPageSection): ApprovalCenterTab | null {
|
||||
if (section === "my-tasks") return "my_tasks";
|
||||
if (section === "my-requests") return "my_requests";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the DESKTOP avatar entry lands: whatever is actually waiting on the user
|
||||
* wins, otherwise the plain settings landing. Counts are already loaded at click
|
||||
* time, so this never flashes a wrong section. Mobile ignores this and always
|
||||
* lands on the /settings menu screen.
|
||||
*/
|
||||
export function settingsLandingPath(pendingApprovalCount: number, unreadCount: number): string {
|
||||
if (pendingApprovalCount > 0) return "/settings/my-tasks";
|
||||
if (unreadCount > 0) return "/settings/notifications";
|
||||
return `/settings/${DEFAULT_SETTINGS_SECTION}`;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
status: done
|
||||
component: Badge
|
||||
---
|
||||
|
||||
# 徽标 Badge
|
||||
|
||||
只回答两个问题:**有没有新的**、**有多少**。它贴在入口或文字旁边,本身不承载内容——要说「这是什么 / 什么状态」(包括列表里那种小圆点 + 一个词的状态)用[标签](/components/tag),判别表在「文档 → 组件规范 → 徽标 Badge」§1。组件库 `@bisheng/ui` 导出 **`Badge`**。规范全文见「文档 → 组件规范 → 徽标 Badge」。
|
||||
|
||||
**壳把规范写死的部分**:只有一档尺寸(数字 16px、圆点 6px),不随宿主放大;数字圆角一律 full——**一位数是正圆,两位起拉成胶囊**,挂角款和行内款同一个值;数字**原样显示、不折算**(没有 99+),位数多了胶囊自己变宽;等宽数字,9→10 不抖;挂角款自带 1px 页面底色描边,压在彩色图标或头像上也看得清;传 0 什么都不渲染(`showZero` 例外);徽标不可点、无悬停、无焦点、出现消失不加转场——点击全归宿主。这些没有 prop。
|
||||
|
||||
## 挂角:数字与红点
|
||||
|
||||
给徽标传 `children`,它就挂在这个宿主的右上角;不传就是行内的独立数字(见下一节)。挂角款默认 `danger` 实底白字——**红底白字全站只表示「等你处理」**,报数量的地方别用它。`dot` 只说「有新的」,不说几个。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Badge, Button } from '@bisheng/ui';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 32 }}>
|
||||
<Badge count={3}>
|
||||
<Outlined.Bell size={24} />
|
||||
</Badge>
|
||||
<Badge count={128}>
|
||||
<Outlined.Bell size={24} />
|
||||
</Badge>
|
||||
<Badge dot>
|
||||
<Outlined.Bell size={24} />
|
||||
</Badge>
|
||||
<Badge count={5}>
|
||||
<Button variant="outlined">待审批</Button>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 行内红点
|
||||
|
||||
行里没有可挂的东西(没有图标、没有头像)时,红点不传 `children`,自己占一列——通知列表的未读标记就是这个。红点**恒为 danger**,`color` 在这一款上没有意义:红点的全部意思就是「有新的」。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Badge } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 8, maxWidth: 420 }}>
|
||||
{[
|
||||
{ text: '@admin 通过了你对「应急管理」的审批申请', unread: true },
|
||||
{ text: '@admin 通过了你对「测试」的审批申请', unread: false },
|
||||
].map((item) => (
|
||||
<div key={item.text} style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{item.text}</span>
|
||||
{item.unread && <Badge dot className="mt-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 独立数字
|
||||
|
||||
不传 `children` 就是行内款:页签、菜单项后面报条目数。它默认 `brand`(品牌 5% 透明底 + 品牌字),因为它只是**告诉你有多少**,不催人做事。[标签页](/components/tabs) 的计数徽标画的就是这个。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Badge, Tabs } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 24, justifyItems: 'start' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
全部频道 <Badge count={12} />
|
||||
</span>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'all', label: '全部', badge: 12 },
|
||||
{ key: 'mine', label: '我的', badge: 3 },
|
||||
{ key: 'archived', label: '已归档' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 0 不显示
|
||||
|
||||
计数原样传进来即可,**不必自己判空**:0 和不传都不渲染。只有「0 本身就是结果」(筛选结果数)才显式开 `showZero`——未读数为 0 时还挂个灰圈,是在提醒一件不存在的事。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Badge } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 32 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
未读 <Badge count={0} />(不渲染)
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
筛选结果 <Badge count={0} showZero />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 圆形宿主与微调
|
||||
|
||||
宿主是头像或圆形图标按钮时传 `circle`,徽标中心收到圆周上,不再飘在圆外的空白里。宿主的图形没有撑满自己的盒子时,用 `offset={[x, y]}` 按 px 微调,正数向右 / 向下。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Badge } from '@bisheng/ui';
|
||||
|
||||
const Avatar = () => (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: 'rgb(var(--brand-500))',
|
||||
color: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
张
|
||||
</span>
|
||||
);
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 32 }}>
|
||||
<Badge dot circle>
|
||||
<Avatar />
|
||||
</Badge>
|
||||
<Badge count={9} circle>
|
||||
<Avatar />
|
||||
</Badge>
|
||||
<Badge count={9} circle offset={[-2, 2]}>
|
||||
<Avatar />
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 禁用
|
||||
|
||||
宿主点不了,红点还亮着就是在催人做一件做不了的事。宿主 disabled 时给徽标传 `disabled`,它一起变灰。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Badge, Button } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 32 }}>
|
||||
<Badge count={5}>
|
||||
<Button variant="outlined">待审批</Button>
|
||||
</Badge>
|
||||
<Badge count={5} disabled>
|
||||
<Button variant="outlined" disabled>
|
||||
待审批
|
||||
</Button>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 无障碍
|
||||
|
||||
挂角徽标对读屏是**装饰**(`aria-hidden`):数字念在宿主上更完整,宿主自己写合并描述,如 `aria-label="通知,3 条未读"`。
|
||||
|
||||
## API
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `children` | `ReactNode` | — | 宿主。**传了就是挂角款,不传就是行内的独立数字**——两者只差一个宿主,没有第二个开关 |
|
||||
| `count` | `number` | — | 有多少。原样显示,不折算、不截断;`0` 与不传都不渲染 |
|
||||
| `dot` | `boolean` | `false` | 只说「有新的」,不说几个。有宿主就挂角,没宿主就行内独立占一列;恒为 danger 色。`count` 显示时它不生效 |
|
||||
| `color` | `'danger' \| 'brand'` | 红点与挂角数字 `danger` / 独立数字 `brand` | 按语义选,不按位置选:红底白字只表示「等你处理」 |
|
||||
| `showZero` | `boolean` | `false` | 让 `0` 显示出来。只给「0 本身就是结果」的场合(筛选结果数) |
|
||||
| `circle` | `boolean` | `false` | 宿主是圆的(头像、圆形图标按钮),徽标中心收到圆周上 |
|
||||
| `offset` | `[number, number]` | — | 挂角款按 px 微调,正数向右 / 向下 |
|
||||
| `disabled` | `boolean` | `false` | 随宿主一起变灰 |
|
||||
| `className` | `string` | — | 挂角款落在外层包裹,其余落在徽标本体 |
|
||||
| `badgeClassName` | `string` | — | 任何款式下都落在徽标本体 |
|
||||
|
||||
以下几件事组件已经替业务页决定了,不开 prop:尺寸恒为 16px / 6px(徽标不随宿主长大)、数字不做 `99+` 折算、徽标不可点也不响应悬停与焦点(点击全归宿主)、出现消失不加转场。要「点徽标清未读」,把它接在宿主上。
|
||||
|
||||
**状态点不在这里**:一行「运行中 / 失败」说的是这个对象**是什么状态**,是贴在它身上的属性,归 [标签](/components/tag) 的 `dot` 款。
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
status: done
|
||||
component: Breadcrumb
|
||||
---
|
||||
|
||||
# 面包屑 Breadcrumb
|
||||
|
||||
页面在结构里的位置——从根级一路到当前页,回答「我在哪一层、怎么往上走」。组件库 `@bisheng/ui` 导出名 **`Breadcrumb`**,实现取自 client 知识空间的下级文件夹页(原 `KnowledgeBreadcrumb`),规则按规范抽成通用件。规范全文见「文档 → 组件规范 → 面包屑 Breadcrumb」。
|
||||
|
||||
**业务页只传完整层级数组**,根级在前、当前页在末。截断、折叠、窄屏阈值、`nav` / `aria-current` 全在组件里,页面一句都不用重写:
|
||||
|
||||
- 名称超过 96px 截断,悬停出完整名称——**只在真的截断时才挂 Tooltip**(比对 `scrollWidth`),短名称一个浮层都不建。
|
||||
- 超过 4 级折叠成 `根级 · … · 上一级 · 当前页`,`…` 固定在第 2 位。
|
||||
- 只有一级时**整个组件不渲染**(`return null`)——空面包屑占一行却不给信息,页面该整体上移。
|
||||
- 当前页不可点、无 hover,颜色深一档标明「你在这」。
|
||||
|
||||
## 默认状态
|
||||
|
||||
四级及以内全部展开。父级第三档文字色、hover 转品牌色,分隔符是 bisheng-icons 的右向箭头(不是文本 `>`),当前页深一档且不可点。
|
||||
|
||||
```tsx
|
||||
import { Breadcrumb } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
<Breadcrumb
|
||||
expandLabel={(n) => `点击展开省略的 ${n} 层`}
|
||||
items={[
|
||||
{ key: 'space', title: '产品知识库', onClick: () => alert('打开:产品知识库') },
|
||||
{ key: 'a', title: '研发中心', onClick: () => alert('打开:研发中心') },
|
||||
{ key: 'b', title: '接口文档' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 名称过长
|
||||
|
||||
父级名称最大 96px(12px 字号下约 8 个中文字、16 个英文字符)——**按宽度卡而不是按字数卡**,中英混排不用换算。当前页不受这条限制,只受页面宽度限制;两者超宽都是截断 + 悬停出完整名称。
|
||||
|
||||
把鼠标停在被截断的那两级上看 Tooltip。
|
||||
|
||||
```tsx
|
||||
import { Breadcrumb } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ padding: '12px 0', maxWidth: 420 }}>
|
||||
<Breadcrumb
|
||||
expandLabel={(n) => `点击展开省略的 ${n} 层`}
|
||||
items={[
|
||||
{ key: 'space', title: '集团法务合规知识库', onClick: () => {} },
|
||||
{ key: 'a', title: '2026 年度合同模板归档', onClick: () => {} },
|
||||
{ key: 'b', title: '境外子公司采购框架协议(中英文对照版)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 层级过多
|
||||
|
||||
超过 4 级折叠:保留**根级 + 末两级**,中间各级收进 `…`,折叠后可见项恒为 4 个。`…` 常态是裸的,hover 才出 24×24 的浅灰容器,菜单打开期间容器保持同色——常驻底色会破坏面包屑行的轻量感。
|
||||
|
||||
点 `…` 打开单列纵向菜单:一行一级,从上到下=从上级到下级,整行可点,菜单里给**完整名称**(只受菜单 240px 上限约束)。点菜单外或按 Esc 关闭,方向键可在行间移动。
|
||||
|
||||
```tsx
|
||||
import { Breadcrumb } from '@bisheng/ui';
|
||||
|
||||
const chain = [
|
||||
'产品知识库', '研发中心', '平台组', '前端', '组件库', '设计规范', '面包屑',
|
||||
].map((title, i) => ({
|
||||
key: String(i),
|
||||
title,
|
||||
onClick: () => alert(`打开:${title}`),
|
||||
}));
|
||||
|
||||
export default () => (
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
<Breadcrumb expandLabel={(n) => `点击展开省略的 ${n} 层`} items={chain} />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 窄屏
|
||||
|
||||
窄屏(小于 576px)折叠阈值降到**超过 3 级即折叠**,可见项为 `根级 · … · 当前页`;`…` 与各层级的触达热区扩到 44×44,视觉尺寸不变。触屏没有 hover,看完整名称一律走 `…` 菜单。
|
||||
|
||||
把浏览器窗口拉窄到 576px 以内,上面「层级过多」的 demo 会自动少留一级——组件内部用的是与《多端适配原则》同一套断点,业务页不用自己判断。
|
||||
|
||||
## 规格
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 字号 / 行高 | `text-caption`(12px)/ 24px |
|
||||
| 项与分隔符的间距 | 左右各 2px(`gap-0.5`) |
|
||||
| 分隔符 | `Outlined.Right` 16px,`text-text-4`,`aria-hidden`、不可点 |
|
||||
| 父级 | 常态 `text-text-3`,hover 转品牌色,最大宽度 96px |
|
||||
| 当前页 | `text-text-1`,无 hover,`aria-current="page"`,不渲染成链接 |
|
||||
| `…` 触发器 | 24×24、圆角 4px,hover / 菜单打开时底色 `fill-2`;触屏热区 44×44 |
|
||||
| 菜单 | 居中对齐、下方 4px;宽 120–240px,最大高 320px 内滚动;行高 32px、左右 12px、整行可点 |
|
||||
|
||||
## API
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `items` | `BreadcrumbItem[]` | — | 完整层级,根级在前、当前页在末;少于 2 项不渲染 |
|
||||
| `maxItems` | `number` | `4` | 超过这个级数才折叠 |
|
||||
| `itemsAfterCollapse` | `number` | `2` | 折叠后末尾保留几级(含当前页) |
|
||||
| `expandLabel` | `(hiddenCount: number) => string` | — | `…` 的 Tooltip 与 `aria-label`,参数是被折叠的层级数 |
|
||||
| `ariaLabel` | `string` | `'breadcrumb'` | 外层 `nav` 的 `aria-label` |
|
||||
| `className` | `string` | — | 外层 `nav` 的附加类名 |
|
||||
|
||||
`BreadcrumbItem`:`{ key?: string; title: string; onClick?: () => void; href?: string }`。给了 `href` 就渲染成真链接(可中键、可「在新标签页打开」),否则渲染成按钮。
|
||||
|
||||
`expandLabel` 是函数而不是字符串,因为**层级数只有组件自己知道,文案只有业务方知道**——组件库不持有任何 i18n key,所有文案一律由调用方传入。
|
||||
|
||||
> **依赖说明**:`…` 菜单基于 Radix Dropdown Menu;截断补全与 `…` 的提示用的是组件库的 [`Tooltip`](/components/tooltip)(`disabled={!截断}`,只在真的截断时才挂浮层;长相与别处的 tooltip 完全一致,箭头照带)。
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
status: done
|
||||
component: Checkbox
|
||||
---
|
||||
|
||||
# 复选框 Checkbox
|
||||
|
||||
一组选项里**选多个**,或单独一个表示「勾选 / 同意」;结果**随表单提交才生效**——拨完立即生效的独立设置是开关的活。组件库 `@bisheng/ui` 导出 **`Checkbox`**(基础款)、**`CheckboxGroup`**(排布壳)与 **`CheckboxCard`**(卡片式)。规范全文见「文档 → 组件规范 → 复选框 Checkbox」。
|
||||
|
||||
**壳把规范写死的部分**:方块 14 / 16 / 18 三档、圆角固定 4px、方块与文字间距 8px;未选灰描边 → 悬停加深 → 选中品牌色底 + 白勾(随蓝⇄绿主题);禁用 = 浅灰填充底 + 行文字变灰 + 「禁止」光标三信号;聚焦环只在键盘 Tab 时出现;触屏整行热区 ≥44px。这些没有 prop。
|
||||
|
||||
## 基础用法
|
||||
|
||||
`label` 就是热区的一部分——点字即点框。选项文案写名词或短语,不写疑问句。
|
||||
|
||||
```tsx
|
||||
import { Checkbox, CheckboxGroup } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<CheckboxGroup direction="vertical">
|
||||
<Checkbox label="接收邮件通知" defaultChecked />
|
||||
<Checkbox label="接收短信通知" />
|
||||
</CheckboxGroup>
|
||||
);
|
||||
```
|
||||
|
||||
## 三档尺寸
|
||||
|
||||
方块跟控件三档走(14 / 16 / 18,文字 14 / 14 / 16),与同档按钮、输入框同排天然对齐;medium 是默认。
|
||||
|
||||
```tsx
|
||||
import { Checkbox, CheckboxGroup } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<CheckboxGroup direction="vertical">
|
||||
<Checkbox size="small" label="small · 表格行内、紧凑列表" defaultChecked />
|
||||
<Checkbox size="medium" label="medium · 默认,绝大多数表单" defaultChecked />
|
||||
<Checkbox size="large" label="large · 登录页、大表单" defaultChecked />
|
||||
</CheckboxGroup>
|
||||
);
|
||||
```
|
||||
|
||||
## 复选框组与次要说明
|
||||
|
||||
横排项间距 16px、竖排 8px;选项多或文案长就竖排,别让横排折行(窄屏下横排自动转竖排)。说明是一行次要文字,从文字位起排。
|
||||
|
||||
```tsx
|
||||
import { Checkbox, CheckboxGroup } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 24 }}>
|
||||
<CheckboxGroup>
|
||||
<Checkbox label="按天" />
|
||||
<Checkbox label="按周" defaultChecked />
|
||||
<Checkbox label="按月" />
|
||||
</CheckboxGroup>
|
||||
<CheckboxGroup direction="vertical">
|
||||
<Checkbox label="站内信" description="登录后在消息中心查看" defaultChecked />
|
||||
<Checkbox label="邮件" description="发送到账号绑定的邮箱" />
|
||||
</CheckboxGroup>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 全选与半选
|
||||
|
||||
半选**只给全选框用**:子项部分勾选时全选框显示半选。它只是样式(`aria-checked="mixed"`),点击行为仍是「全不选 → 全选」。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Checkbox, CheckboxGroup } from '@bisheng/ui';
|
||||
|
||||
const OPTIONS = ['知识库 A', '知识库 B', '知识库 C'];
|
||||
|
||||
export default () => {
|
||||
const [selected, setSelected] = React.useState(['知识库 A']);
|
||||
const all = selected.length === OPTIONS.length;
|
||||
const some = selected.length > 0 && !all;
|
||||
return (
|
||||
<CheckboxGroup direction="vertical">
|
||||
<Checkbox
|
||||
label="全选"
|
||||
checked={some ? 'indeterminate' : all}
|
||||
onCheckedChange={() => setSelected(all ? [] : OPTIONS)}
|
||||
/>
|
||||
{OPTIONS.map((name) => (
|
||||
<Checkbox
|
||||
key={name}
|
||||
label={name}
|
||||
checked={selected.includes(name)}
|
||||
onCheckedChange={(next) =>
|
||||
setSelected(next === true ? [...selected, name] : selected.filter((n) => n !== name))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</CheckboxGroup>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 卡片式
|
||||
|
||||
选项带标题 + 描述、点击区域要大的场景(套餐 / 方案选择):基础款外面套一层卡壳,整卡可点。选中 = 控件品牌色 + 整卡统一选中浅底 + 淡品牌色描边(品牌 100 档)——只提到淡档不用主档,成组卡片挨排时边线不抢。
|
||||
|
||||
```tsx
|
||||
import { CheckboxCard } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8, maxWidth: 560 }}>
|
||||
<CheckboxCard label="文档解析" description="PDF / Word / Excel 抽取" defaultChecked />
|
||||
<CheckboxCard label="网页抓取" description="按 URL 定时抓取入库" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 状态
|
||||
|
||||
禁用靠三个信号一起说话:方块换浅灰填充底、文字变灰、光标变「禁止」——只把方块降不透明度不算禁用。要让人看得出禁用原因时配 Tooltip。组校验错误提示放组下方、用危险色,由调用方渲染(同输入框)。
|
||||
|
||||
```tsx
|
||||
import { Checkbox, CheckboxGroup } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<CheckboxGroup direction="vertical">
|
||||
<Checkbox label="禁用未选" disabled />
|
||||
<Checkbox label="禁用已选" disabled defaultChecked />
|
||||
<Checkbox label="正常已选,对比用" defaultChecked />
|
||||
</CheckboxGroup>
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
outline: false
|
||||
---
|
||||
|
||||
import { ComponentIndex } from '@docs-site/ComponentIndex';
|
||||
|
||||
# 组件总览
|
||||
|
||||
组件库的实时 demo 页。每个组件页用 `@rspress/plugin-preview` 直接渲染**真实组件**——已迁入组件库的从 `@bisheng/ui` 导入(如 Button),尚未迁库的从 `src/frontend/client/src/components/ui/` 导入。所见即业务页所得,组件代码一改,这里同步变。
|
||||
|
||||
规范正文(定义、取值、状态矩阵、迁移台账)在顶部导航「文档」;组件页以演示为主,只穿插最要紧的使用规则。
|
||||
|
||||
## 组件清单
|
||||
|
||||
{/*
|
||||
本页只留读者内容:一段导语 + 自生成的组件清单。
|
||||
|
||||
下表不是手写的:组件名、链接、分组由 client/docs-site/ComponentIndex.tsx 在构建时从
|
||||
siteData.pages + 侧边栏配置算出,只有「状态」一列读各页 front matter;页面漏挂侧边栏、
|
||||
侧边栏指向不存在的页,都会在表下直接报出来。永远不要手写这张表。
|
||||
|
||||
作者向的内容(新建 demo 页的步骤、front matter 约定、demo 书写约定)在
|
||||
《文档撰写规范》§5.1 / §5.2(元-文档撰写规范.md,route.exclude 排除在站点外)。
|
||||
*/}
|
||||
|
||||
<ComponentIndex />
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
status: done
|
||||
component: Radio
|
||||
---
|
||||
|
||||
# 单选框 Radio
|
||||
|
||||
一组**全部摊开可见**的选项里**只选一个**,结果随表单提交才生效;选项 2~7 个用单选框,再多摊不开换下拉选择。**选中后点已选项不会取消**——需要「一个都不选」的合法状态,就明确加一项「无 / 不需要」。组件库 `@bisheng/ui` 导出 **`RadioGroup`**、**`Radio`** 与 **`RadioCard`**(卡片式)。规范全文见「文档 → 组件规范 → 单选框 Radio」。
|
||||
|
||||
**壳把规范写死的部分**:圆圈与复选框方块同一套 14 / 16 / 18 阶梯,选中品牌色实心 + 白色内点(直径 = 外圈 − 8);按钮组形态走按钮的 24 / 32 / 40 高度阶梯;方向键在组内移动、Tab 只进出组一次;触屏整行热区 ≥44px。这些没有 prop。
|
||||
|
||||
## 基础用法
|
||||
|
||||
选项文案写并列同构的名词或短语(「按天 / 按周 / 按月」),读者扫一遍就能比出差别。有安全的默认值就预选它;没有就一项都不预选。
|
||||
|
||||
```tsx
|
||||
import { Radio, RadioGroup } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<RadioGroup defaultValue="week">
|
||||
<Radio value="day">按天</Radio>
|
||||
<Radio value="week">按周</Radio>
|
||||
<Radio value="month">按月</Radio>
|
||||
</RadioGroup>
|
||||
);
|
||||
```
|
||||
|
||||
## 三档尺寸
|
||||
|
||||
圆圈 14 / 16 / 18,内点 6 / 8 / 10;`size` 写在 `RadioGroup` 上,整组统一。
|
||||
|
||||
```tsx
|
||||
import { Radio, RadioGroup } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<RadioGroup size="small" defaultValue="a">
|
||||
<Radio value="a">small</Radio>
|
||||
<Radio value="b">表格行内</Radio>
|
||||
</RadioGroup>
|
||||
<RadioGroup size="medium" defaultValue="a">
|
||||
<Radio value="a">medium</Radio>
|
||||
<Radio value="b">默认</Radio>
|
||||
</RadioGroup>
|
||||
<RadioGroup size="large" defaultValue="a">
|
||||
<Radio value="a">large</Radio>
|
||||
<Radio value="b">登录页、大表单</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 竖排与次要说明
|
||||
|
||||
文案长就竖排(横排 16px、竖排 8px 间距,窄屏自动转竖排);说明是一行次要文字,规则同复选框。
|
||||
|
||||
```tsx
|
||||
import { Radio, RadioGroup } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<RadioGroup direction="vertical" defaultValue="auto">
|
||||
<Radio value="auto" description="按内容自动选择合适的切分策略">智能切分</Radio>
|
||||
<Radio value="custom" description="手动指定分隔符与块大小">自定义切分</Radio>
|
||||
</RadioGroup>
|
||||
);
|
||||
```
|
||||
|
||||
## 按钮组
|
||||
|
||||
筛选条、视图切换这类**高频切换**用按钮组——大热区 + 状态高亮;它是单选的另一张皮,不是一排按钮。选中项品牌色文字 + 品牌浅底 + 整圈淡品牌描边(100 档同卡片式,结构 antd 同构),**不做实心**——切换器不和主按钮抢视线。选项文字 2~4 个字,放不下就回到圆点或下拉。
|
||||
|
||||
```tsx
|
||||
import { Radio, RadioGroup } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 16, justifyItems: 'start' }}>
|
||||
<RadioGroup variant="button" size="small" defaultValue="list">
|
||||
<Radio value="list">列表</Radio>
|
||||
<Radio value="card">卡片</Radio>
|
||||
</RadioGroup>
|
||||
<RadioGroup variant="button" defaultValue="7d">
|
||||
<Radio value="1d">今天</Radio>
|
||||
<Radio value="7d">近 7 天</Radio>
|
||||
<Radio value="30d">近 30 天</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 卡片式
|
||||
|
||||
带标题 + 描述的方案选择,卡壳同复选框卡片式(圆角 12、最小高 48、选中浅底 + 淡品牌描边),仅选中互斥。
|
||||
|
||||
```tsx
|
||||
import { RadioCard, RadioGroup } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<RadioGroup
|
||||
defaultValue="private"
|
||||
style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8, maxWidth: 560 }}
|
||||
>
|
||||
<RadioCard value="private" label="私有" description="仅自己与被授权成员可见" />
|
||||
<RadioCard value="shared" label="共享" description="空间内所有成员可见" />
|
||||
</RadioGroup>
|
||||
);
|
||||
```
|
||||
|
||||
## 状态
|
||||
|
||||
禁用与按钮 disabled 同一套 token;整组禁用写在 `RadioGroup`,单项禁用写在 `Radio`。组校验错误提示放组下方、用危险色,由调用方渲染;圆圈本身不变红。
|
||||
|
||||
```tsx
|
||||
import { Radio, RadioGroup } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<RadioGroup defaultValue="a">
|
||||
<Radio value="a">正常已选</Radio>
|
||||
<Radio value="b" disabled>单项禁用</Radio>
|
||||
</RadioGroup>
|
||||
<RadioGroup defaultValue="a" disabled>
|
||||
<Radio value="a">整组禁用(已选)</Radio>
|
||||
<Radio value="b">整组禁用</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
status: done
|
||||
component: Segmented
|
||||
---
|
||||
|
||||
# 分段控制器 Segmented
|
||||
|
||||
让**同一份内容换个看法**——列表还是卡片、按日还是按周,点完**立刻生效**。它本质是个即时生效的单选控件(`role="radiogroup"`,方向键在段间移动并即时选中)。切完「你在哪」变了的,是[标签页](/components/tabs);要随表单提交才生效的,用单选框。组件库 `@bisheng/ui` 导出 **`Segmented`**。规范全文见「文档 → 组件规范 → 分段控制器 Segmented」。
|
||||
|
||||
**壳把规范写死的部分**:灰底槽(fill-2)+ 白色浮块,浮块不加投影、200ms 滑到选中段;**选中不用品牌色**——它只回答「当前在哪个看法」;三档高度 28/32/36,槽内边距 3px、浮块圆角嵌套同心;各段等宽取最长段;选中字重 500、未选中 400;总有一段被选中,不存在「都不选」;触屏热区 ≥44px。这些没有 prop。段数守住 **2~5 段**,超出就不是「几个看法」而是「一堆选项」了。
|
||||
|
||||
## 基础用法
|
||||
|
||||
`options` 传字符串数组即可;不传 `defaultValue` 默认选中第一段。文案 2~4 个字、各段等长最好——一段特别长会把整个控件撑得松垮。
|
||||
|
||||
```tsx
|
||||
import { Segmented } from '@bisheng/ui';
|
||||
|
||||
export default () => <Segmented options={['日', '周', '月']} aria-label="统计粒度" />;
|
||||
```
|
||||
|
||||
## 三档尺寸
|
||||
|
||||
默认 `medium`(32px),与旁边的按钮、输入框同高对齐;small / large 为 28 / 36,比控件阶梯收敛半档——整块灰底的视觉分量比按钮重,档差小一点更协调。
|
||||
|
||||
```tsx
|
||||
import { Segmented } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 16, justifyItems: 'start' }}>
|
||||
<Segmented size="small" options={['日', '周', '月']} aria-label="small 档" />
|
||||
<Segmented options={['日', '周', '月']} aria-label="medium 档" />
|
||||
<Segmented size="large" options={['日', '周', '月']} aria-label="large 档" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## block 撑满
|
||||
|
||||
`block` 撑满父容器、各段平分。窄屏下优先用它——悬空的小控件在手机上难点中。
|
||||
|
||||
```tsx
|
||||
import { Segmented } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<Segmented block options={['全部', '进行中', '已完成']} aria-label="任务筛选" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## icon + 文本
|
||||
|
||||
文案短且 icon 能加速识别时可以加前缀 icon,随档 14/16/18px。**同一个控件里只用一种形态**——文本段和纯 icon 段混在一起,读的人要在两套语言间来回翻译。
|
||||
|
||||
```tsx
|
||||
import { Segmented } from '@bisheng/ui';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
|
||||
export default () => (
|
||||
<Segmented
|
||||
aria-label="视图切换"
|
||||
options={[
|
||||
{ value: 'list', label: '列表', icon: <Outlined.List /> },
|
||||
{ value: 'card', label: '卡片', icon: <Outlined.ViewGridCard /> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## 纯 icon
|
||||
|
||||
空间极窄且 icon 语义业内公认时才用;**每段必须配 `tooltip`** 说明含义(同时兜底为无障碍名称)——icon 语义再公认也有第一次见的人。
|
||||
|
||||
```tsx
|
||||
import { Segmented } from '@bisheng/ui';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
|
||||
export default () => (
|
||||
<Segmented
|
||||
aria-label="视图切换"
|
||||
options={[
|
||||
{ value: 'list', icon: <Outlined.List />, tooltip: '列表视图' },
|
||||
{ value: 'card', icon: <Outlined.ViewGridCard />, tooltip: '卡片视图' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## 禁用
|
||||
|
||||
单段禁用:该段灰字 + 禁止光标,其余段照常可点。整体禁用:全部灰字,浮块保持在当前段——禁用也要能看出停在哪个看法上。
|
||||
|
||||
```tsx
|
||||
import { Segmented } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 16, justifyItems: 'start' }}>
|
||||
<Segmented
|
||||
aria-label="单段禁用"
|
||||
options={[
|
||||
{ value: 'day', label: '日' },
|
||||
{ value: 'week', label: '周', disabled: true },
|
||||
{ value: 'month', label: '月' },
|
||||
]}
|
||||
/>
|
||||
<Segmented disabled defaultValue="week" options={['日', '周', '月']} aria-label="整体禁用" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 切换即生效
|
||||
|
||||
选完立刻切换对应的展现,内容区即时替换、不加转场。`value` + `onChange` 受控。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Segmented } from '@bisheng/ui';
|
||||
|
||||
export default () => {
|
||||
const [view, setView] = React.useState('list');
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12, justifyItems: 'start' }}>
|
||||
<Segmented
|
||||
aria-label="视图切换"
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ value: 'list', label: '列表' },
|
||||
{ value: 'card', label: '卡片' },
|
||||
]}
|
||||
/>
|
||||
{view === 'list' ? (
|
||||
<div style={{ display: 'grid', gap: 4, width: 240 }}>
|
||||
{['知识库 A', '知识库 B', '知识库 C'].map((name) => (
|
||||
<div key={name} className="rounded border border-border-base px-3 py-1.5 text-body text-text-2">{name}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{['知识库 A', '知识库 B', '知识库 C'].map((name) => (
|
||||
<div key={name} className="flex h-20 w-24 items-center justify-center rounded-md border border-border-base text-body text-text-2">{name}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
status: done
|
||||
component: Switch
|
||||
---
|
||||
|
||||
# 开关 Switch
|
||||
|
||||
拨一个**独立设置的开 / 关**,拨动**立即生效**——不需要提交按钮,也不需要确认;要攒着随表单一起提交的,用复选框。label 描述「开着的是什么」(如「消息通知」),不写疑问句。拨一下就有破坏性后果的动作不用开关,用按钮 + 二次确认。组件库 `@bisheng/ui` 导出 **`Switch`**。规范全文见「文档 → 组件规范 → 开关 Switch」。
|
||||
|
||||
**壳把规范写死的部分**:两档 22×38 / 18×32,胶囊轨道 + 正圆滑块 + 2px 内边距;开启品牌色轨道(随蓝⇄绿主题)、关闭中性灰轨道,悬停各加深一档;禁用保持当前侧别、整体降 40% 不透明度;loading 期间锁拨;聚焦环只在键盘 Tab 时出现;触屏热区 ≥44px。这些没有 prop。
|
||||
|
||||
## 基础用法
|
||||
|
||||
默认档高 22px 与正文行高同值,和 14px 文字同排天然居中。
|
||||
|
||||
```tsx
|
||||
import { Switch } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<Switch defaultChecked aria-label="消息通知" />
|
||||
<Switch aria-label="声音提醒" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 两档尺寸
|
||||
|
||||
`small`(18×32)用于表格行内、紧凑列表;触屏高频场景直接升 default。
|
||||
|
||||
```tsx
|
||||
import { Switch } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<Switch defaultChecked aria-label="default 档" />
|
||||
<Switch size="small" defaultChecked aria-label="small 档" />
|
||||
<Switch size="small" aria-label="small 档关闭" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 框内文字
|
||||
|
||||
可选的强调,默认不放;要放就最多 2 个字或一个 icon,显示在滑块让出的那一侧。`small` 档装不下,不渲染框内文字。
|
||||
|
||||
```tsx
|
||||
import { Switch } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<Switch defaultChecked checkedChildren="开" unCheckedChildren="关" aria-label="带框内文字" />
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" aria-label="带框内文字,关闭" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 加载与失败回弹
|
||||
|
||||
拨动后要等服务端确认的场景:期间 `loading` 锁住不可拨——连拨会让最终状态和服务端结果对不上。**失败要回弹**:开关回到原状态,并用轻提示说明原因;界面不能停在一个没生效的假状态上。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Switch, Toaster, toast } from '@bisheng/ui';
|
||||
|
||||
export default () => {
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const flip = (next) => {
|
||||
setChecked(next);
|
||||
setLoading(true);
|
||||
// Pretend the server rejects the change: roll back + explain.
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
setChecked(!next);
|
||||
toast.error('保存失败,已恢复原状态');
|
||||
}, 1200);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
<Switch checked={checked} loading={loading} onCheckedChange={flip} aria-label="演示失败回弹" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 禁用
|
||||
|
||||
保持当前开 / 关的颜色、整体降 40% 不透明度——开关的禁用要能看出**停在哪一侧**,所以不改成统一灰底。
|
||||
|
||||
```tsx
|
||||
import { Switch } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<Switch disabled defaultChecked aria-label="禁用,停在开" />
|
||||
<Switch disabled aria-label="禁用,停在关" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 设置行
|
||||
|
||||
label 放开关左侧,设置列表里开关靠行尾右对齐;整行可点、触屏热区 ≥44px 由调用方的行布局保证。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Switch } from '@bisheng/ui';
|
||||
|
||||
const Row = ({ label, hint, ...props }) => {
|
||||
const id = React.useId();
|
||||
return (
|
||||
<label htmlFor={id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, minHeight: 44, cursor: 'pointer' }}>
|
||||
<span style={{ display: 'grid' }}>
|
||||
<span className="text-body text-text-1">{label}</span>
|
||||
{hint && <span className="text-caption text-text-3">{hint}</span>}
|
||||
</span>
|
||||
<Switch id={id} {...props} />
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', maxWidth: 360 }}>
|
||||
<Row label="消息通知" hint="有新回复时推送" defaultChecked />
|
||||
<Row label="声音提醒" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,222 @@
|
||||
---
|
||||
status: done
|
||||
component: Tabs
|
||||
---
|
||||
|
||||
# 标签页 Tabs
|
||||
|
||||
把**同一层级的几组内容**收进同一块区域,点谁看谁——切的是「内容区」,属于导航。只是同一份内容换个看法(列表还是卡片)的,用[分段控制器](/components/segmented);要随表单提交才生效的,用单选框。组件库 `@bisheng/ui` 导出 **`Tabs`**。规范全文见「文档 → 组件规范 → 标签页 Tabs」。
|
||||
|
||||
**壳把规范写死的部分**:只有线型一种画法——标签行 + 2px 指示条 + 底部 1px 通栏分隔线(分隔线可经 `divider={false}` 关掉,见下);选中态两种配色经 `variant` 二选一:`brand`(默认,随蓝⇄绿主题)/ `neutral`(墨色,见下),不开放任意配色;三档高度 24/32/40;页签间距 24px、首个页签与内容区左缘对齐;未选中字重 400、选中 500(宽度按加粗态预留,切换零位移);指示条 200ms 滑动、内容区即时替换不加转场;溢出横向滚动 + 两端渐隐,选中页签自动保持可见;触屏热区 ≥44px。这些没有 prop。键盘走 WAI-ARIA tabs pattern 的 **automatic 模式**:左右方向键移动焦点即激活,Home/End 跳两端。
|
||||
|
||||
## 基础用法
|
||||
|
||||
`items` 传页签数组,`children` 是各自的内容面板;不传 `activeKey` 即非受控,默认选中第一个可用页签。页签最少 2 个——只有 1 个就不该分页签。
|
||||
|
||||
```tsx
|
||||
import { Tabs } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'detail', label: '详情', children: <p className="py-4 text-body text-text-2">应用的基础信息。</p> },
|
||||
{ key: 'version', label: '版本', children: <p className="py-4 text-body text-text-2">历史版本列表。</p> },
|
||||
{ key: 'perm', label: '权限', children: <p className="py-4 text-body text-text-2">谁能看、谁能改。</p> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## 三档尺寸
|
||||
|
||||
默认 `medium`。档位暗示层级:页头用 `large`,页内容器用 `medium`,弹窗、抽屉里的次级分区用 `small`——别让弹窗里的页签和页头一样大。
|
||||
|
||||
```tsx
|
||||
import { Tabs } from '@bisheng/ui';
|
||||
|
||||
const items = [
|
||||
{ key: 'a', label: '详情' },
|
||||
{ key: 'b', label: '版本' },
|
||||
{ key: 'c', label: '权限' },
|
||||
];
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 24 }}>
|
||||
<Tabs size="small" items={items} />
|
||||
<Tabs items={items} />
|
||||
<Tabs size="large" items={items} />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 墨色款
|
||||
|
||||
`variant="neutral"`:选中态不用品牌色,文字与指示条都用墨色(text-1),选中靠**加粗 + 指示条**表达。用在品牌色会打架的界面——页面上已有品牌色主按钮/链接群,或页签只是弱导航不想抢焦点时。默认款仍是品牌色。
|
||||
|
||||
```tsx
|
||||
import { Tabs } from '@bisheng/ui';
|
||||
|
||||
const items = [
|
||||
{ key: 'a', label: '详情' },
|
||||
{ key: 'b', label: '版本' },
|
||||
{ key: 'c', label: '权限' },
|
||||
];
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 24 }}>
|
||||
<Tabs items={items} />
|
||||
<Tabs variant="neutral" items={items} />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 无分隔线
|
||||
|
||||
`divider={false}` 去掉标签行下方那条通栏灰线,指示条与页签保持原样。**只在外层容器已经画了这条边时用**——卡片描边、分区横线紧挨着页签下沿时,两条 1px 细线隔着 1px 并排,看起来像渲染出错。容器没画边就别关,页签会飘在内容上方失去归属。
|
||||
|
||||
```tsx
|
||||
import { Tabs } from '@bisheng/ui';
|
||||
|
||||
const items = [
|
||||
{ key: 'a', label: '详情' },
|
||||
{ key: 'b', label: '版本' },
|
||||
{ key: 'c', label: '权限' },
|
||||
];
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 24 }}>
|
||||
<Tabs items={items} />
|
||||
<Tabs divider={false} items={items} />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 前缀图标
|
||||
|
||||
icon 随档 14/16/18px,与文字间距 8px(small 档 4px)。同组页签**要么都带 icon 要么都不带**,一半有一半没有的组读起来最累。icon 走 `currentColor` 跟随文字色,所以与配色款正交——墨色款下图标同样变墨色,无需额外处理。
|
||||
|
||||
```tsx
|
||||
import { Tabs } from '@bisheng/ui';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
|
||||
const items = [
|
||||
{ key: 'list', label: '列表', icon: <Outlined.List /> },
|
||||
{ key: 'calendar', label: '日历', icon: <Outlined.Calendar /> },
|
||||
];
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 24 }}>
|
||||
<Tabs items={items} />
|
||||
<Tabs variant="neutral" items={items} />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 数字徽标
|
||||
|
||||
`badge` 在文字右侧挂一个数字(未读数、条目数)。传 `0` 或不传都不渲染,所以业务侧可以把计数原样传进来,不必自己判空。徽标**不跟随选中态变色**——它报的是「有多少」,不是「你在哪一页」,选中与未选中一律同色;但它跟随配色款,墨色款下自然是墨色。数字不截断,位数多了页签跟着变宽。
|
||||
|
||||
```tsx
|
||||
import { Tabs } from '@bisheng/ui';
|
||||
|
||||
const items = [
|
||||
{ key: 'all', label: '全部', badge: 499 },
|
||||
{ key: 'agri', label: '农业', badge: 499 },
|
||||
{ key: 'read', label: '已读' },
|
||||
];
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 24 }}>
|
||||
<Tabs items={items} />
|
||||
<Tabs variant="neutral" items={items} />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 禁用页签
|
||||
|
||||
禁用是「暂时不可用」:灰字 + 禁止光标,键盘焦点直接跳过。永远点不了的入口别长期挂着——未开放的功能页签直接不渲染。
|
||||
|
||||
```tsx
|
||||
import { Tabs } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'detail', label: '详情' },
|
||||
{ key: 'version', label: '版本', disabled: true },
|
||||
{ key: 'perm', label: '权限' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## 附加操作区
|
||||
|
||||
标签行右端可以放和这块内容相关的**轻操作**(刷新、筛选),与页签垂直居中;重操作进内容区。
|
||||
|
||||
```tsx
|
||||
import { Tabs, Button, Tooltip } from '@bisheng/ui';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
|
||||
export default () => (
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'run', label: '运行日志', children: <p className="py-4 text-body text-text-2">近 24 小时的运行记录。</p> },
|
||||
{ key: 'error', label: '错误日志', children: <p className="py-4 text-body text-text-2">只看失败的。</p> },
|
||||
]}
|
||||
extra={
|
||||
<Tooltip content="刷新">
|
||||
<Button size="small" variant="text" color="default" iconOnly aria-label="刷新" icon={<Outlined.Refresh />} />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## 溢出横滚
|
||||
|
||||
页签装不下时**横向滚动 + 两端渐隐**,不换行、不出第二行;选中的页签自动滚到可视区内。页签超过 7 个,先想想能不能合并归类。
|
||||
|
||||
```tsx
|
||||
import { Tabs } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<Tabs
|
||||
defaultActiveKey="t5"
|
||||
items={['详情', '版本', '权限', '成员', '日志', '监控', '告警', '设置'].map((label, i) => ({
|
||||
key: `t${i}`,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 受控用法
|
||||
|
||||
`activeKey` + `onChange` 受控,适合页签状态要进路由/URL 的场景;此时可以不传 `children`,把 Tabs 当纯页签条用,内容区由页面自己按路由渲染。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tabs } from '@bisheng/ui';
|
||||
|
||||
export default () => {
|
||||
const [key, setKey] = React.useState('detail');
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Tabs
|
||||
activeKey={key}
|
||||
onChange={setKey}
|
||||
items={[
|
||||
{ key: 'detail', label: '详情' },
|
||||
{ key: 'version', label: '版本' },
|
||||
{ key: 'perm', label: '权限' },
|
||||
]}
|
||||
/>
|
||||
<span className="text-caption text-text-3">当前页签:{key}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,237 @@
|
||||
---
|
||||
status: done
|
||||
component: Tag
|
||||
---
|
||||
|
||||
# 标签 Tag
|
||||
|
||||
给一个对象**贴一个词**:它是什么类型、处在什么状态、属于哪个分类。贴的是「属性」,不是操作——要触发动作用[按钮](/components/button),要说「有多少 / 有没有新的」用[徽标](/components/badge)。组件库 `@bisheng/ui` 导出 **`Tag`**。规范全文见「文档 → 组件规范 → 标签 Tag」。
|
||||
|
||||
**壳把规范写死的部分**:只有**浅底深字**一种画法,没有描边款、实底款、灰底款;圆角 4px(带头像时切胶囊);两档高度 20 / 24,字号 12、字重 400——选中态也不加粗(加粗会让标签变宽跳位);展示型**没有悬停态**(变色暗示可点,点了没反应就是骗了一次);可关闭的 × 点了立即移除、不弹二次确认;可选中标签固定灰底起步、选中变品牌 7% 底,且不与语义色组合;状态点跟字同色、**不做呼吸动画**;触屏上可选中标签统一升到 24px、热区 ≥44px。这些没有 prop。产品口径:**一个对象最多贴 3 个**,多出来的收进详情。
|
||||
|
||||
## 语义色
|
||||
|
||||
按「贴什么」选色,**一种语义全站只用一个色**:「已完成」到哪都是绿的,不许这页绿那页蓝。普通分类词一律 `default` 灰——颜色没有语义就只剩装饰,读者会去猜「蓝比绿高级吗」。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tag } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Tag>知识库</Tag>
|
||||
<Tag color="brand">推荐</Tag>
|
||||
<Tag color="success">已完成</Tag>
|
||||
<Tag color="warning">待处理</Tag>
|
||||
<Tag color="danger">已驳回</Tag>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 两处固定例外
|
||||
|
||||
`approving`(审批中蓝)与 `skill`(技能紫)是色板里仅有的两个**不随蓝⇄绿主题切换**的标签色(见「文档 → 设计规范 → 色彩 Color」§4)。它们不是 `brand` 的别名:`brand` 会跟着主题变绿,而这两个不会。**业务不得再新增固定色**——需要新的固定色是改规范,不是加一个 prop。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tag } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Tag color="approving">审批中</Tag>
|
||||
<Tag color="skill">技能</Tag>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 两档尺寸
|
||||
|
||||
默认 `medium`(24px):卡片、详情页头、筛选面板。`small`(20px)给表格单元格、列表行内、输入框内的已选项——32px 输入框里放 20px 标签,上下各留 6px 才不顶边。**同一组标签用同一档**:一行里 20 和 24 混着放,参差比什么都显眼。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tag } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 12, justifyItems: 'start' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Tag size="small">知识库</Tag>
|
||||
<Tag size="small" color="success">已完成</Tag>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Tag>知识库</Tag>
|
||||
<Tag color="success">已完成</Tag>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 状态点
|
||||
|
||||
列表里每行都要标状态、要一眼扫过去时,用 `dot`:一个小圆点顶在词前面,**颜色跟字走**,所以选好 `color` 就够了,不必再决定一次点是什么色。点随档 4 / 6px。列表这种场景用 `small`——知识空间的文件列表画的就是这个。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tag } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'grid', gap: 12, justifyItems: 'start' }}>
|
||||
<Tag size="small" dot>未启动</Tag>
|
||||
<Tag size="small" dot color="brand">解析中</Tag>
|
||||
<Tag size="small" dot color="success">已完成</Tag>
|
||||
<Tag size="small" dot color="warning">待处理</Tag>
|
||||
<Tag size="small" dot color="danger">失败</Tag>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 可关闭
|
||||
|
||||
已选项、筛选条件、已上传文件用它。点 × **立即移除、不弹确认**——移除已选项本来就可逆,再选一次就回来了;真不可逆的(删除标签定义)是按钮走二次确认。这类场景**一律 default 灰**:它们是用户自己挑的,不需要颜色再说一遍。`closeLabel` 是 × 的无障碍名称,文案由调用方给。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tag } from '@bisheng/ui';
|
||||
|
||||
export default () => {
|
||||
const [items, setItems] = React.useState(['产品手册', '接口文档', '常见问题']);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, minHeight: 24 }}>
|
||||
{items.map((item) => (
|
||||
<Tag
|
||||
key={item}
|
||||
closable
|
||||
closeLabel={`移除 ${item}`}
|
||||
onClose={() => setItems((prev) => prev.filter((i) => i !== item))}
|
||||
>
|
||||
{item}
|
||||
</Tag>
|
||||
))}
|
||||
{items.length === 0 && <span style={{ fontSize: 12, color: 'rgb(var(--text-3))' }}>都移除了,刷新页面重来</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 可选中
|
||||
|
||||
筛选面板、兴趣挑选用它:点一下切换,**切换即生效**,可多选。选中态已经占用了颜色这一层信号,所以可选中标签**不与语义色组合**——传了 `color` 也不生效。键盘 Space / Enter 同样切换。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tag } from '@bisheng/ui';
|
||||
|
||||
const OPTIONS = ['运行中', '已完成', '已停止', '草稿'];
|
||||
|
||||
export default () => {
|
||||
const [checked, setChecked] = React.useState(['运行中']);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{OPTIONS.map((option) => (
|
||||
<Tag
|
||||
key={option}
|
||||
checkable
|
||||
checked={checked.includes(option)}
|
||||
onChange={(next) =>
|
||||
setChecked((prev) => (next ? [...prev, option] : prev.filter((i) => i !== option)))
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 前缀 icon 与头像
|
||||
|
||||
`icon` 跟随档位 12 / 14px,颜色跟文字走;**同一组标签要么都带 icon 要么都不带**。前缀位只有一个:`dot` / `icon` / `avatar` 三选一,优先级 `avatar` > `dot` > `icon`。`avatar` 把标签切成胶囊——头像是圆的,方角包圆像漏了一角——并固定用灰底:一张脸加一个语义色是在一个小方块里说两件事。两者二选一,别同时挂。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tag } from '@bisheng/ui';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
|
||||
const Face = () => (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgb(var(--brand-500))',
|
||||
color: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
张
|
||||
</span>
|
||||
);
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||||
<Tag icon={<Outlined.Skill />} color="skill">技能</Tag>
|
||||
<Tag icon={<Outlined.Check />} color="success">已通过</Tag>
|
||||
<Tag avatar={<Face />}>张三</Tag>
|
||||
<Tag avatar={<Face />} closable closeLabel="移除 张三">张三</Tag>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 定宽截断
|
||||
|
||||
默认不限宽、不截断。只有定宽的列(表格)才传 `maxWidth`:文字省略号截断,**并且只在真的溢出时**才挂 Tooltip 显示全文。让标签换行去撑高表格行,整张表的节奏就全乱了。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tag } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Tag maxWidth={80}>短标签</Tag>
|
||||
<Tag maxWidth={80}>很长很长的标签文字</Tag>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 禁用
|
||||
|
||||
展示型与可关闭标签变灰、× 点不动;可选中标签停止切换,**已选中的禁用项保留品牌字色**——不然用户看不出自己之前挑了哪些。
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tag } from '@bisheng/ui';
|
||||
|
||||
export default () => (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||||
<Tag disabled>知识库</Tag>
|
||||
<Tag color="success" disabled>已完成</Tag>
|
||||
<Tag closable disabled closeLabel="移除 产品手册">产品手册</Tag>
|
||||
<Tag checkable disabled>未选中</Tag>
|
||||
<Tag checkable defaultChecked disabled>已选中</Tag>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `children` | `ReactNode` | — | 标签的词,2~6 个字,名词或状态词。文案由调用方给 |
|
||||
| `size` | `'small' \| 'medium'` | `'medium'` | 20 / 24px 两档。同一组标签用同一档 |
|
||||
| `color` | `'default' \| 'brand' \| 'success' \| 'warning' \| 'danger' \| 'approving' \| 'skill'` | `'default'` | 后两个是仅有的固定例外色(审批中蓝 / 技能紫),不随蓝⇄绿主题切换。`checkable` 与 `avatar` 下不生效 |
|
||||
| `dot` | `boolean` | `false` | 词前面加一个状态点,随档 4 / 6px,颜色跟字走。优先于 `icon`,`avatar` 存在时不生效 |
|
||||
| `icon` | `ReactNode` | — | 前缀 icon,随档 12 / 14px,跟文字色走。`dot` / `avatar` 存在时不生效 |
|
||||
| `avatar` | `ReactNode` | — | 前缀头像,14 / 16px 裁圆;标签跟着切胶囊并固定灰底 |
|
||||
| `closable` | `boolean` | `false` | 右侧 × 移除自己。**在 `checkable` 上不生效**(button 套 button 是非法 HTML) |
|
||||
| `onClose` | `(e: MouseEvent) => void` | — | 点 × 触发,立即移除、不弹二次确认 |
|
||||
| `closeLabel` | `string` | `'Remove'` | × 的无障碍名称,如「移除 产品手册」 |
|
||||
| `checkable` | `boolean` | `false` | 点一下切换选中。渲染为 `<button aria-pressed>` |
|
||||
| `checked` / `defaultChecked` / `onChange` | `boolean` / `boolean` / `(checked: boolean) => void` | — / `false` / — | 受控 / 非受控选中态 |
|
||||
| `maxWidth` | `number` | — | 定宽列才传:省略号截断,**且只在真的溢出时**才挂 Tooltip |
|
||||
| `disabled` | `boolean` | `false` | 变灰并停止交互;选中的禁用项保留品牌字色 |
|
||||
| `className` | `string` | — | 落在标签本体 |
|
||||
|
||||
以下几件事组件已经替业务页决定了,不开 prop:只有浅底深字一款(没有描边 / 实底 / 灰底)、圆角 4px(带头像时胶囊)、字重恒为 400、展示型不响应悬停、可选中固定灰底起步且选中恒为品牌 7% 底、状态点跟字同色且不做动画、触屏上可选中标签升到 medium 并扩出 ≥44px 热区。标签之间的间距由使用方的容器给,组件不带外边距。
|
||||
@@ -0,0 +1,119 @@
|
||||
# 徽标 Badge
|
||||
|
||||
> 设计系统 · 徽标 v1 · 2026-08-28 建档
|
||||
> 与 [00-总纲.md](00-总纲.md)、[01-设计规范.md](01-设计规范.md) 配套;颜色见 [基础-色彩规范.mdx](基础-色彩规范.mdx)、字号见 [基础-字体规范.mdx](基础-字体规范.mdx)(caption-sm 即为徽标数字而设)、圆角见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)。姊妹篇:[组件-Tag标签.md](组件-Tag标签.md);页签里的计数款画法原定于 [组件-Tabs标签页.md](组件-Tabs标签页.md) §4,本文归口后以本文为准。
|
||||
> 调研来源(不进展示层):antd Badge(count 20 / small 14、dot 6px、status 五态、overflowCount 99、showZero 默认关、Ribbon)、Arco Badge(count 20、字重 500、dot 6px、maxCount 99、status 五态 + 12 色)、TDesign Badge(circle / round 两形、dot 6px、maxCount 99)、Apple HIG(角标只放数字、只用于「有新东西」、看过即清)。设计师拍板(2026-08-28):收数字 / 红点 / 独立数字三类;**数字不折算、原样显示**(不做 99+,与 Tabs §4 一致)。**状态点不在本文**——建档时曾按业内惯例收在这里,落地后(2026-08-28)归口 [组件-Tag标签.md](组件-Tag标签.md):它说的是「这个对象是什么状态」,是属性,不是「有没有新的」。
|
||||
> 代码现状(2026-08-28 只读扫描,client/src):`pages/settings/SettingsPage.tsx` 内 `NavCountBadge` 手写 16px 红底白字、`> 99 → 99+`;`components/ui/Badge.tsx` 实为标签(归口 Tag 规范)。
|
||||
|
||||
## 1. 什么时候用
|
||||
|
||||
徽标回答两个问题:**有没有新的**、**有多少**。它贴在入口或文字旁边,本身不承载内容。
|
||||
|
||||
- 入口上有未读、待办、新消息,用徽标挂在右上角。
|
||||
- 页签、菜单项后面要报条目数,用独立数字。
|
||||
- 列表每行要标「这条没看过」,用红点:挂在行内的头像上;行里没有可挂的东西就单独占一列。
|
||||
- 要说「这是什么 / 什么状态」——包括列表里那种小圆点 + 一个词——用标签,见 [组件-Tag标签.md](组件-Tag标签.md)。
|
||||
- 徽标只报事实,不做装饰:没有新东西就不显示,看过就清零——长期挂着的红点等于没有红点。
|
||||
|
||||
| 场景 | 用什么 |
|
||||
|---|---|
|
||||
| 只想说「有新的」 | 红点 |
|
||||
| 要说「有几个」 | 数字 |
|
||||
| 列表每行都要标状态、要一眼扫过去 | 标签的状态点款(small 档):点 + 一个词,行高不变、颜色好扫 |
|
||||
| 单个对象的状态要醒目 | 标签:有色底,面积大,见得远 |
|
||||
|
||||
后两行都落在[标签](组件-Tag标签.md):一句话分界——**徽标数「有几个」,标签说「是什么」**。「失败」不是一个数量。
|
||||
|
||||
## 2. 类型 Type
|
||||
|
||||
| 类型 | 长什么样 | 挂在哪 |
|
||||
|---|---|---|
|
||||
| **数字** `count` | 16px 高的胶囊,有色底 + 数字 | 宿主右上角 |
|
||||
| **红点** `dot` | 6px 实心圆 | 宿主右上角;没有可挂的宿主时(列表行没有图标 / 头像)单独立在行内,此时行本身就是宿主 |
|
||||
| **独立数字** | 同数字款,不挂角 | 文字右侧 |
|
||||
|
||||
数字与独立数字按**语义**分两个色,不按位置分:
|
||||
|
||||
| color | 底 / 字 | 什么意思 |
|
||||
|---|---|---|
|
||||
| `danger`(挂角**默认**) | danger 实底 / 白字 | **要你处理**:未读消息、待审批、待办 |
|
||||
| `brand`(独立**默认**) | 品牌 5% 透明底 / 品牌字(随蓝⇄绿主题) | **只是告诉你有多少**:子频道条目数、筛选结果数 |
|
||||
|
||||
- 一个入口的未读数用 danger,页签后的条目数用 brand,两者不互换:红底白字全站只表示「等你处理」,用多了就没人理了。
|
||||
- 独立数字跟随所在组件的配色款(如 Tabs 墨色款下为墨色),规则见宿主组件文档。
|
||||
|
||||
## 3. 尺寸 Size
|
||||
|
||||
徽标只有一档,不随宿主尺寸变。
|
||||
|
||||
| 类型 | 尺寸 | 字号 / 字重 | 圆角 | 内边距 |
|
||||
|---|---|---|---|---|
|
||||
| 数字 / 独立数字 | 高 16px、最小宽 16px | caption-sm 10px / 500,等宽数字 | full:一位数是正圆,两位起拉成胶囊(两款同值) | 左右 4px |
|
||||
| 红点 | 6 × 6px | — | full | — |
|
||||
|
||||
- 数字**不折算、不截断**:传多少显示多少,位数多了胶囊跟着变宽。一个入口攒到四位数未读,问题在通知策略,不在徽标。
|
||||
- 数字用等宽数字(tabular-nums):从 9 跳到 10 时不抖。
|
||||
- 挂角的数字和红点外围加 **1px 页面底色描边**(bg-page):宿主是彩色图标或头像时,徽标才不会糊在上面。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 1 位数是正圆,2 位起拉成胶囊 | 所有数字都塞进固定宽的圆 | 圆里挤两位数,数字被压扁看不清 |
|
||||
| 徽标固定 16px | 大按钮上放大徽标 | 徽标是附属信息,跟宿主一起长大就抢了宿主的戏 |
|
||||
|
||||
## 4. 内容形态
|
||||
|
||||
- **数字款只放数字**,不放文字、不放图标;「New」「HOT」这种词是标签的事,见 [组件-Tag标签.md](组件-Tag标签.md)。
|
||||
- **0 不显示**:数字款与独立数字传 0 或不传都不渲染,业务把计数原样传进来即可,不必自己判空。唯一例外是「0 本身就是结果」(筛选结果数),此时业务显式开 `showZero`。
|
||||
- **状态点不在本文**:列表行里那种「点 + 一个词」归 [组件-Tag标签.md](组件-Tag标签.md) §5 的 `dot` 款,五态语义色、不做动画的规矩也写在那边。同一个状态在列表里是绿点标签,在详情页就是绿标签,本来就是同一个组件的两个档。
|
||||
|
||||
## 5. 位置与状态 State
|
||||
|
||||
- **挂角位置**:徽标中心落在宿主右上角(向右上各出 50%);宿主是圆形(头像、圆形图标按钮)时向内收,让徽标中心落在圆周上。业务可传 `offset` 微调,单位 px。
|
||||
- **宿主不可用时徽标一起变灰**(同宿主的 disabled 字色):入口点不了,红点还亮着是在催人做做不了的事。
|
||||
- 徽标本身**不可点、无悬停、无焦点**:点击落到宿主上。要「点徽标清未读」,由宿主处理。
|
||||
- **出现 / 消失不加动画**:数字变化即时替换。
|
||||
- 深色模式:danger 实底走功能色深色阶,品牌透明底随品牌深色阶;业务不写 `dark:` 覆盖。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 用户看过后未读数立即清零 | 未读数一直挂到用户手动关 | 徽标的价值在「变化」,不变的红点几天后就成了背景 |
|
||||
| 一个入口一个徽标 | 图标上挂红点、文字后再挂数字 | 同一件事说两遍,用户会以为是两件事 |
|
||||
|
||||
## 6. 移动端适配
|
||||
|
||||
跨组件通则见 [基础-多端适配原则.md](基础-多端适配原则.md),这里只写徽标自己的细则。
|
||||
|
||||
| 项 | 触屏 / 窄屏规则 |
|
||||
|---|---|
|
||||
| 尺寸 | 不变,仍 16px / 6px——徽标不可点,没有热区问题 |
|
||||
| 位置 | 底部导航栏的徽标同样挂图标右上角,不挂在文字上 |
|
||||
| 独立数字 | 窄屏页签内照常显示,页签溢出横滚规则见 [组件-Tabs标签页.md](组件-Tabs标签页.md) §6 |
|
||||
|
||||
<!-- site-hide -->
|
||||
## 给实现窗口
|
||||
|
||||
1. 组件位置 `packages/ui/src/components/Badge/`(2026-08-28 已落地),props:`count` / `dot`、`color: danger | brand`、`showZero`、`circle`(圆形宿主)、`offset: [x, y]`、`disabled`。挂角款用 `relative inline-flex` 包宿主 + `absolute` 徽标;不传 `children` 即行内款(数字或红点),渲染为 `inline-flex`。默认色**按形态定、不按位置定**:红点恒为 danger(红点就是「有新的」),只有独立数字默认 brand——品牌 5% 透明底摊在 6px 的圆点上等于没有。**没有 `standalone` prop**:挂角与独立只差一个宿主,调用点的形状已经说清楚了,再加一个布尔量只会多出「传了 standalone 又传了 children」这种没有答案的组合。
|
||||
2. 颜色全走 token:danger `bg-danger text-white`;brand `bg-blue-500/5 text-blue-500`(同 Tabs 现行 `badge` 画法,随蓝⇄绿主题);描边 `ring-1 ring-bg-page`;禁用 `bg-text-4`。
|
||||
3. 尺寸:`h-4 min-w-4 px-1 text-caption-sm font-medium tabular-nums leading-none`;数字款与独立数字同为 `rounded-full`;红点 `h-1.5 w-1.5 rounded-full`。
|
||||
4. 无障碍:挂角徽标对读屏是文字信息,宿主加 `aria-label="通知,3 条未读"` 之类的合并描述,徽标本体 `aria-hidden`。
|
||||
5. 迁移映射(本次只读扫描,2026-08-28,范围 `client/src`,排除 node_modules):
|
||||
- **已迁(2026-08-28)**:`pages/settings/SettingsPage.tsx` 的 `NavCountBadge`(桌面 + 移动两处导航行)→ `<Badge color="danger" count={n} />`。**去掉了 99+ 折算**,字号 11 → caption-sm 10,`#f53f3f` → danger token;圆角仍是正圆(这块页面正是设计师拍下「两款同为 full」的地方)。
|
||||
- **已迁(2026-08-28)**:`components/messageApproval/NotificationRow.tsx` 的未读点(`size-2 bg-[#f53f3f]`)→ `<Badge dot />`,8 → 6px;`layouts/UserPopMenu.tsx` 头像右上角的红点(`absolute size-2.5 ring-2 ring-white`)→ `<Badge dot circle>{avatar}</Badge>`,10 → 6px,2px 白描边 → 1px 页面底色描边(深色模式下自动跟着走,原来那圈白在深色里是道亮边)。
|
||||
- Tabs 组件内置的 `badge`(16px、原圆角 6、`bg-primary/5`、caption-sm/500)→ **已改**为渲染 `<Badge count={item.badge} />`,配色仍由 Tabs 的 `variant` 传入(墨色款不能长出品牌色);圆角随本次统一从 6px 变为 full,Tabs §4 同步改写。
|
||||
- 其它手写红点 / 数字(侧栏未读、通知铃铛)未扫描,迁移窗口用 `rounded-full` + `bg-[#f5` / `bg-red` grep 后补附录。
|
||||
6. 站点接线(元规范 §5):本文 + `components/badge.mdx` demo 页均已注册进 `rspress.config.ts` 侧栏,front matter `component: Badge` 已写(组件已进库);00-总纲 §四 与 01-设计规范 §0 索引行随建档更新。client 侧仍有同名的 `components/ui/Badge.tsx`(实为标签),迁移顺序见 Tag 规范给实现窗口第 6 条。
|
||||
|
||||
## 待决策清单
|
||||
|
||||
- 挂角描边 1px bg-page 为本次建档取值(antd 用 1px 白色 box-shadow),待验收,尤其是深色模式下 `#121212` 描边是否够用。
|
||||
- Ribbon 缎带、文字徽标(`text` 挂角)本期不做——Apple HIG 口径:角标只放数字。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-28 | **圆角统一为 full**(设计师在设置页导航上拍板):独立数字原沿用 Tabs 的 6px,与挂角数字的正圆并存两个值;现在一律 full——一位数正圆、两位起胶囊。§3 尺寸表、§给实现窗口 3 同步,待决策清单第 1 条结案;Tabs §4 的画法描述随之改写(其计数徽标本就由本组件渲染) | 待 committer 窗口提交 |
|
||||
| 2026-08-28 | 红点补上**无宿主**的行内形态:列表行没有图标 / 头像可挂时,红点自己占一列(通知列表就是这个),行本身即宿主;默认色改为按形态定——红点恒 danger,只有独立数字默认 brand。§1 / §2 / §给实现窗口 1 同步;client 三处手写徽标(设置导航计数 ×2、通知未读点、头像红点)随之迁入组件 | 待 committer 窗口提交 |
|
||||
| 2026-08-28 | **状态点移出本文**,归口 [组件-Tag标签.md](组件-Tag标签.md) §5 `dot` 款(设计师拍板):状态点说的是「这个对象是什么状态」,与徽标的「有没有新的 / 有多少」不是一件事,而它和标签本来就共用一套语义色、在真实页面(知识空间文件列表)里也一直是带色底的标签形态。本文剩数字 / 红点 / 独立数字三类;§1 判别表、§2 类型表、§3 尺寸表、§4 内容形态、§给实现窗口 1/2/4 同步;组件与 demo 页同步删 `status` / `text` prop | 待 committer 窗口提交 |
|
||||
| 2026-08-28 | 组件落地 `packages/ui/src/components/Badge/`:四类型 + 两色 + `showZero` / `circle` / `offset` / `disabled` 全部按本文实现;挂角与独立数字改由「有没有 `children`」区分,`standalone` prop 不做(§给实现窗口 1);Tabs 的计数徽标改由本组件渲染,画法零变化;demo 页 `components/badge.mdx` 上线 | 待 committer 窗口提交 |
|
||||
| 2026-08-28 | 建档 v1:四类型(数字 / 红点 / 状态点 / 独立数字;状态点当日晚些移出,见上);数字按语义分 danger 实底(要你处理,挂角默认)与 brand 5% 透明底(只报数量,独立默认);尺寸单档 16px、caption-sm/500 等宽数字、红点 6px、挂角 1px bg-page 描边;**数字不折算不做 99+**、0 不显示(`showZero` 例外);状态点五态不做动画;徽标不可点、无动画、随宿主禁用。归口 [组件-Tabs标签页.md](组件-Tabs标签页.md) §4 的计数徽标;与 [组件-Tag标签.md](组件-Tag标签.md) 同日建档 | 待 committer 窗口提交 |
|
||||
@@ -0,0 +1,121 @@
|
||||
# 复选框 Checkbox
|
||||
|
||||
> 设计系统 · 复选框 v1 · 2026-08-24 建档
|
||||
> 与 [00-总纲.md](00-总纲.md)、[01-设计规范.md](01-设计规范.md) 配套;字号见 [基础-字体规范.mdx](基础-字体规范.mdx)、颜色见 [基础-色彩规范.mdx](基础-色彩规范.mdx)、圆角见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)、移动端通则见 [基础-多端适配原则.md](基础-多端适配原则.md)、文案见 [基础-文案规范.md](基础-文案规范.md)。
|
||||
> 调研来源(不进展示层):三组件分工与「随表单提交 vs 立即生效」的分界取 Apple HIG;方块尺寸业内 antd 16px / Arco 14px,半选只给全选场景、点击行为「全不选 → 全选」为两家共识;卡片式先例为 TDesign / Arco 的卡片选择。设计师拍板(2026-08-24):选中态用品牌色、尺寸跟控件三档(14/16/18)、要卡片式形态;聚焦环沿用输入框灰环(未单独拍板,替代路线见待决策清单)。禁用画法应设计师「禁用与常态区分不明显」反馈(2026-08-24,指 knowledge 列表现状的纯 opacity-50 路线)补强:灰填充底 + 文字同变灰为 antd / Arco 共识(antd 底 rgba(0,0,0,0.04)、字 rgba(0,0,0,0.25)),本文取按钮 disabled 同套 token。卡片式画法(2026-08-24 设计师点名作规范参考)归并自 client 现状 `AccessModeSelector`(knowledge/create 页「私有 / 共享」选择):保留圆点、选中浅底不变描边、圆角 12、最小高 48,替换了初稿的「无圆点 + 品牌描边」路线;其中「不变描边」2026-08-25 再拍板改为淡品牌描边(品牌 100 档),见 §2。
|
||||
|
||||
## 1. 什么时候用
|
||||
|
||||
复选框用来在一组选项里**选多个**,或单独一个表示「勾选 / 同意」;勾选结果**随表单提交才生效**。
|
||||
|
||||
三个选择类组件按两个问题分工,这张表是三份文档共用的判定入口:
|
||||
|
||||
| 问题 | 答案 | 用 |
|
||||
|---|---|---|
|
||||
| 选几个? | 一组里选多个 | 复选框 |
|
||||
| 选几个? | 一组里只选一个 | 单选框,见 [组件-Radio单选框.md](组件-Radio单选框.md) |
|
||||
| 什么时候生效? | 拨动立即生效的独立设置 | 开关,见 [组件-Switch开关.md](组件-Switch开关.md) |
|
||||
| 什么时候生效? | 不是立即生效的设置项 | 不用开关:选项摊得开按上两行选,摊不开用选择器 Select(规范待建,见 [00-总纲.md](00-总纲.md) 进度看板) |
|
||||
|
||||
- 结果要跟表单一起提交的,用复选框;拨完立即生效的,用开关——不混用。
|
||||
- 单独一个复选框只用于「同意协议」这类勾选声明;「开 / 关某功能」哪怕只有一个,也是开关的活。
|
||||
|
||||
## 2. 类型 Type
|
||||
|
||||
| 类型 | 什么时候用 | 长相 |
|
||||
|---|---|---|
|
||||
| 基础复选框 | 绝大多数多选 | 方块 + 右侧文字 |
|
||||
| 全选 | 列表 / 表格的批量选择头部 | 同基础款,多一个半选态(见 §5) |
|
||||
| 复选框组 Group | 一组并列选项 | 横排或竖排的一组基础款 |
|
||||
| 卡片式 | 选项带标题 + 描述、点击区域要大(套餐 / 方案选择) | 描边卡片,整卡可点 |
|
||||
|
||||
- Group 横排项间距 16px、竖排 8px;选项多或文案长就竖排,别让横排折行。
|
||||
- 卡片式是「基础款外面套一层卡壳」:方块 / 圆点保留在左,后跟标签(中字重、正文色)与次要说明(次要文字色),说明放不下就省略号 + Tooltip 补全;整卡可点。
|
||||
- 卡片选中 = 控件本身品牌色 + 整卡统一选中浅底 + **淡品牌色描边(品牌 100 档)**——灰描边配品牌浅底被设计师判为不搭(2026-08-25,推翻初稿「描边保持灰防相邻边线打架」案);描边只提到 100 档、不用 500 主档,成组卡片挨排时边线才不抢。100 档同时是下限:再淡到 50 档就和 7% 选中浅底融为一体,描边名存实亡。
|
||||
- 卡片圆角 12px(容器档)、最小高 48px、水平内边距 12px、卡间距 8px;悬停加浅灰底;禁用同 §5 三信号,整卡「禁止」光标。
|
||||
|
||||
## 3. 尺寸 Size
|
||||
|
||||
方块跟控件三档走,medium 是默认;与同档按钮 / 输入框同排时行高同一套、天然对齐(见 [组件-Button按钮.md](组件-Button按钮.md) §3)。
|
||||
|
||||
| size | 方块 | 字号 / 行高 | 什么时候用 |
|
||||
|---|---|---|---|
|
||||
| `small` | 14×14 | 14 / 22 | 表格行内、紧凑列表 |
|
||||
| `medium`(**默认**) | 16×16 | 14 / 22 | 绝大多数表单 |
|
||||
| `large` | 18×18 | 16 / 24 | 登录页、大表单 |
|
||||
|
||||
- 方块圆角固定 4px(控件档最小档)——方块本身只有 14~18px,圆角再随档放大就圆过头了。
|
||||
- 方块与文字间距 8px;文字在点击热区内,点字即点框。
|
||||
|
||||
## 4. 内容形态
|
||||
|
||||
- 选项文案写名词或短语,说清「选中的是什么」,不加句号,见 [基础-文案规范.md](基础-文案规范.md)。
|
||||
- 选项可带一行次要说明(次要文字色),从文字位起排,不顶到方块下方。
|
||||
- 禁用的选项要让人看得出原因时,配 Tooltip 说明,见 [组件-Tooltip文字提示.md](组件-Tooltip文字提示.md)。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 选项写「接收邮件通知」 | 选项写「是否接收邮件通知?」 | 复选框本身就在问,文案再问一遍是重复 |
|
||||
| 点文字也能勾选 | 只有方块可点 | 十几像素的方块单独点太费劲,文字就是热区的一部分 |
|
||||
|
||||
## 5. 状态 State
|
||||
|
||||
未选 → 悬停走灰色渐进链(同输入框),选中是语义时刻、用品牌色——聚焦只回答「光标在哪」所以不用主题色,选中回答「用户定了什么」,两条规则不矛盾。
|
||||
|
||||
| 状态 | 样式 |
|
||||
|---|---|
|
||||
| 未选 default | 白底 + 灰描边(border-base) |
|
||||
| 悬停 hover | 描边加深(border-deep) |
|
||||
| 选中 checked | 品牌色底 + 白色对勾,随蓝⇄绿主题切换 |
|
||||
| 半选 indeterminate | 品牌色底 + 白色横线 |
|
||||
| 禁用 disabled | 浅灰填充底 + 灰描边,**行文字同转禁用字色**,「禁止」光标——三色与按钮 disabled 同一套(见 [组件-Button按钮.md](组件-Button按钮.md) §6);选中禁用为浅灰底 + 灰勾 |
|
||||
| 聚焦 focus | 键盘 Tab 到才出现:2px 灰色阴影环,同输入框聚焦环(见 [组件-Input输入框.md](组件-Input输入框.md) §5) |
|
||||
|
||||
- 半选只给「全选」用:子项部分勾选时全选框显示半选;它只是样式,点击行为仍是「全不选 → 全选」。
|
||||
- 禁用靠三个信号一起说话:方块换浅灰填充底、文字变灰、光标变「禁止」。只把方块降不透明度不算禁用——白底方块半透明前后几乎看不出差别。
|
||||
- 组校验错误的提示文字放组下方、用危险色,规则同输入框错误提示(见 [组件-Input输入框.md](组件-Input输入框.md) §5);方块本身不变红。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 只有全选框出现半选 | 普通选项也用半选 | 半选表示「下级部分选中」,出现在没有下级的选项上没人读得懂 |
|
||||
| 选中用品牌色 | 选中用灰色 | 灰色的「选中」和禁用分不开,选没选要一眼能看出来 |
|
||||
| 禁用 = 灰填充底 + 文字变灰 | 禁用 = 方块降 50% 不透明度 | 白底方块半透明前后没差别,能不能点要一眼能看出来 |
|
||||
|
||||
## 6. 移动端适配
|
||||
|
||||
跨组件通则见 [基础-多端适配原则.md](基础-多端适配原则.md),这里只写复选框自己的细则。
|
||||
|
||||
| 项 | 触屏 / 窄屏规则 |
|
||||
|---|---|
|
||||
| 悬停 hover | 触屏没有悬停,关掉 hover 态 |
|
||||
| 触达 | 整行(方块 + 文字)热区高度 ≥44px;独立方块用透明热区扩到 ≥44×44px(同按钮口径) |
|
||||
| 排列 | 窄屏 Group 一律竖排 |
|
||||
| 卡片式 | 卡片本身满足触达,无额外规则 |
|
||||
|
||||
<!-- site-hide -->
|
||||
## 落地(给实现窗口)
|
||||
|
||||
**2026-08-25 已落地**:`@bisheng/ui` 导出 `Checkbox` / `CheckboxGroup` / `CheckboxCard`(`src/components/Checkbox/`,与 Radio 共享 `src/components/Selection/shared.ts`),demo 页 `docs/components/checkbox.mdx`,侧栏已注册。client 现状(`ui/Checkbox.tsx` 及原生手拼)迁移仍待迁移窗口。下面是当时给实现窗口的口径,已按此实现:
|
||||
|
||||
1. cva `variants: { size }`(方块 14 / 16 / 18,文字 14 / 14 / 16),`Checkbox` / `CheckboxGroup` / 卡片式同一基座;`indeterminate` 只是样式属性,DOM 上置 `aria-checked="mixed"`。
|
||||
2. 颜色全走 token,禁裸 hex:未选描边 `border-border-base`、hover `border-border-deep`;选中底用品牌色类(`bg-blue-500`,已重定向 `--brand-*`);对勾 / 横线用白;disabled 复用按钮三 token;聚焦环复用 `shadow-focus`(环色变量 `--shadow-focus-ring`,默认 gray-2),出现时机用 `:focus-visible` 判定键盘。
|
||||
3. 卡片式参考实现:`client/src/components/permission/UnifiedPermissionControls.tsx` 的 `AccessModeSelector`(radix RadioGroup + label 卡壳,两列 grid `gap-2`、≤560px 单列,说明用 TruncatedTooltip)。选中浅底即统一选中底 `bg-blue-500/[0.07]`、选中描边 `border-blue-100`(2026-08-25 拍板,替换现状的灰描边)、hover `bg-fill-1`;归并差异:disabled 现为 `opacity-60`,改按 §5 三信号(控件禁用画法 + 文字禁用色 + 禁止光标)。
|
||||
4. 触屏热区参考 `.btn-touch-hit` / `.input-touch-hit` 的写法:整行 label 撑高到 44px,独立方块用伪元素扩。
|
||||
5. 现状参考(2026-08-24 设计师点名,knowledge 列表视图):底座 `client/src/components/ui/Checkbox.tsx`(radix,16px、圆角 `rounded-md`=6px、未选描边 border-primary、disabled 仅 `opacity-50`、`focus-visible:outline-none` 无聚焦环、图标 lucide Check / Minus),knowledge 处经 `pages/knowledge/SpaceDetail/selectionCheckboxStyles.ts` 覆写为未选 border-deep、选中 / 半选 border-primary(工具栏全选 / 列表行 / 卡片三处共用)。与本规范的归并差异:圆角 6→4;disabled 的 `opacity-50` 改按钮三 token + 文字禁用色(本次补强的动因);补 `:focus-visible` 灰环;图标换 bisheng-icons(勾 / 横线缺位则照 Input eye 图标先例由调用方传入,待核)。选中行浅底 `--brand-500/0.07` 已与统一选中浅底一致,无需动。全量扫描(其余原生 `<input type="checkbox">` 手拼)仍待迁移窗口。
|
||||
|
||||
## 待决策清单
|
||||
|
||||
- 聚焦环颜色现沿用输入框灰环;按钮的聚焦环是「随按钮颜色」路线,三个选择类组件要不要改成品牌淡环,待有真实场景后设计师再议(Radio / Switch 两份文档同此项,归口在这里)。
|
||||
- ~~卡片式「带方块 + 全选联动」变体的具体样式,等真实场景出现再定。~~ **2026-08-24 结案**:卡片式定稿为一律保留方块 / 圆点(归并自 `AccessModeSelector` 现状),不再有「无方块」变体。
|
||||
- 组内校验(如「最少选一项」)的触发时机与文案归口未来的 Form 规范。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-24 | 建档 v1:三组件分工判定表(判定入口归口本文 §1);类型四种(基础 / 全选 / Group / 卡片式);尺寸跟控件三档 14/16/18、圆角固定 4px;状态六态,选中态品牌色 + 半选只给全选;聚焦环沿用输入框灰环;移动端整行热区 ≥44px。rspress 侧栏注册与 demo 页待实现窗口(`rspress.config.ts` 不在本窗口可改范围) | 待 committer 窗口提交 |
|
||||
| 2026-08-24 | §1 判定表补一行(设计师批注):不是立即生效的设置项不用开关——摊得开按前两行选,摊不开用 Select。其余规则未增未减未改 | 待 committer 窗口提交 |
|
||||
| 2026-08-24 | **禁用态补强**(设计师反馈「禁用与常态区分不明显」,源头是 knowledge 列表现状的纯 `opacity-50`):§5 禁用行明确「浅灰填充底 + 行文字转禁用字色 + 禁止光标」三信号,新增规则句与 ✅/❌ 一行「不用纯降不透明度」;落地区第 5 条回填 knowledge 现状参考(`ui/Checkbox.tsx` + `selectionCheckboxStyles.ts`)与归并差异清单。禁用取值仍全引按钮三 token,未新增 token | 待 committer 窗口提交 |
|
||||
| 2026-08-25 | **卡片式选中描边改淡品牌色**(设计师看知识空间创建页实物:灰描边配品牌浅底不搭):§2 选中态改「浅底 + 品牌 100 档描边」,推翻初稿「描边保持灰」案;300 → 200 → 100 三轮试淡后拍板 100,并定为下限(50 档与选中浅底融掉)。卡壳共用,CheckboxCard / RadioCard 同变 | 本次窗口 |
|
||||
| 2026-08-25 | **组件落地**:`@bisheng/ui` 新增 `Checkbox` / `CheckboxGroup` / `CheckboxCard`(radix checkbox 基座,半选 `aria-checked="mixed"`),demo 页 `components/checkbox.mdx` + 侧栏注册。全按本文实现,展示层规则未增未减未改;组校验错误提示照 Input 先例由调用方渲染 | 本次窗口 |
|
||||
| 2026-08-24 | **卡片式定稿**(设计师点名 knowledge/create 页 `AccessModeSelector` 作规范参考):§2 改写为「保留方块 / 圆点 + 选中浅底不变描边 + 圆角 12 / 最小高 48 / 内边距 12 / 卡间距 8」,替换初稿「无方块 + 品牌描边 + 圆角 8」路线;待决策清单「带方块变体」结案;落地区第 3 条回填参考实现与归并差异(disabled opacity-60 → 三信号) | 待 committer 窗口提交 |
|
||||
@@ -0,0 +1,153 @@
|
||||
# 加载 Loading
|
||||
|
||||
> 设计系统 · 加载 v1 · 2026-08-28 建档
|
||||
> 与 [00-总纲.md](00-总纲.md)、[01-设计规范.md](01-设计规范.md) 配套;颜色见 [基础-色彩规范.mdx](基础-色彩规范.mdx)、字号见 [基础-字体规范.mdx](基础-字体规范.mdx)、图标见 [基础-图标规范.mdx](基础-图标规范.mdx)、插画见 [基础-插画规范.md](基础-插画规范.md)、文案见 [基础-文案规范.md](基础-文案规范.md)。姊妹篇:[组件-State状态页.md](组件-State状态页.md)(加载失败的整块区域反馈归那边 §2.2)、[组件-Button按钮.md](组件-Button按钮.md)(按钮内 loading 归那边 §6)。
|
||||
> 调研来源(不进展示层):antd Spin(small / default / large 三档 14 / 20 / 32,`delay` 防闪,可包裹内容加遮罩,`fullscreen`,`description`)、Arco Spin(默认 20,`delay`、`dot`、`block`、包裹遮罩)、TDesign Loading(16 / 20 / 24 三档,`delay`、`text`、`fullscreen`、`attach`)、Apple HIG progress indicators(指示器持续在动、位置固定、说明文字讲清在等什么、小型 activity indicator 不配文字)、Carbon loading pattern(骨架屏只给容器与数据组件,加载指示器分全屏 / 内联两级)。Spin 类组件都不管失败;「加载更多」三态(加载中 / 失败重试 / 没有更多)是 antd List、Arco List 的做法。
|
||||
> 设计师拍板(2026-08-28):本期收**区域加载 + 列表尾部加载**两种形态,骨架屏不在本期;图形**两档分工**——区域用品牌 12 齿 spinner、列表尾部用 16px 单色圆环;失败**分两级**——区域失败走《状态页》服务异常,列表尾部失败一行「加载失败,点击重试」;延迟**区域 300ms、列表尾部不延迟**。
|
||||
> 代码现状(2026-08-28 只读扫描,client/src):区域首屏加载 5 处写法一致(`LoadingIcon` 80px + 「正在加载…」14px text-3);列表尾部两套并存——`components/InfiniteScroll.tsx`(lucide `Loader2` 16px + 14px text-3,末尾 `text-gray-300`)与 ChannelSquare / KnowledgeSquare / SpaceDetail `LoadMore`(40px 高、纯文字 12px text-4 三态,无 spinner、失败不可重试);explore 页一处裸 hex `#a9aeb8`。区域失败只有一行「加载失败」,无插画无重试。映射见给实现窗口。
|
||||
|
||||
## 1. 什么时候用
|
||||
|
||||
加载告诉用户「东西在路上,等一下」。它只回答「在不在等」,不回答「等到了几成」——BISHENG 的接口没有进度可报,不做进度条。
|
||||
|
||||
- 一块区域第一次拿数据、或切换来源重新拿数据,用**区域加载**(§2.1)。
|
||||
- 列表往下滚要拿下一页,用**列表尾部加载**(§2.2)。
|
||||
- 点了按钮在等接口,用按钮自己的 loading 态,见 [组件-Button按钮.md](组件-Button按钮.md) §6;拨了开关在等确认,见 [组件-Switch开关.md](组件-Switch开关.md) §4。**别在按钮旁边另放一个 spinner**。
|
||||
- 拿回来是零条,换状态页,见 [组件-State状态页.md](组件-State状态页.md) §1——**加载中不是状态页,状态页也不是加载中**。
|
||||
- 整块区域打不开(首屏请求失败),走状态页的服务异常,不在这里画。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 请求期间显示加载,回来零条再换「暂无数据」 | 请求期间就显示「暂无数据」 | 数据明明在路上,先说「没有」再闪回来,用户会以为坏了 |
|
||||
| 一块区域同一时刻只有一个加载指示 | 区域 spinner 和列表尾部 spinner 同时转 | 两个在转等于没告诉用户到底在等什么 |
|
||||
|
||||
## 2. 类型 Type
|
||||
|
||||
两种形态,按「等的是整块区域还是下一页」选。
|
||||
|
||||
### 2.1 区域加载
|
||||
|
||||
整块区域还没有内容,spinner 在区域正中,下面一行说明文字。
|
||||
|
||||
- 图形用**品牌 spinner**(12 齿,跟品牌色走,客户可用自己的 logo 动画替换),尺寸随容器分档(§3)。
|
||||
- 说明文字必带,写「正在加载…」或更具体的「正在加载文件…」;位置在 spinner 下方,间距 16px。
|
||||
- 区域内已有内容、只是要刷新时(切换筛选、切换来源),**保留旧内容 + 上方盖 spinner**:旧内容降到 40% 不透明、不可点,spinner 居中。整块清空再转会让页面跳一下。
|
||||
- 整页级(应用启动、路由切换)也是区域加载,容器就是视口。
|
||||
|
||||
### 2.2 列表尾部加载
|
||||
|
||||
列表滚到底自动拿下一页,尾部一条 40px 高的状态行,四态见 §5。
|
||||
|
||||
- 图形用 **16px 单色圆环**(图标规范的 `Outlined.Loading`),颜色 text-3,左侧带文字「正在加载…」,间距 8px。
|
||||
- 自动触发:状态行进入视口(提前 200px)就发请求,用户不用点。
|
||||
- 状态行在列表末尾、跨满整行(网格布局也跨满所有列),水平居中。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 下一页在路上时列表不动,尾部一行小 spinner | 下一页在路上时整个列表换成大 spinner | 已经在看的内容被换掉,用户找不回刚才的位置 |
|
||||
| 列表尾部用灰色小圆环 | 列表尾部也用品牌大 spinner | 尾部行是配角,品牌色会把视线从内容上拉走 |
|
||||
|
||||
## 3. 尺寸 Size
|
||||
|
||||
区域加载的 spinner 按容器分三档,**看容器有多大,不看在等什么**(口径同 [组件-State状态页.md](组件-State状态页.md) §3)。列表尾部固定一档。
|
||||
|
||||
| 档位 | spinner | 说明文字 | 用在哪 |
|
||||
|---|---|---|---|
|
||||
| **页面级** | 80px | `text-body`(14/22)text-3 | 整块内容区、页面主体、视口 |
|
||||
| **面板级** | 40px | `text-body`(14/22)text-3 | 卡片内、侧栏、抽屉与弹窗主体、AI dock |
|
||||
| **内联级** | 16px | `text-body`(14/22)text-3,无文字也可 | 表格单元格、行内、下拉面板;列表尾部固定此档 |
|
||||
|
||||
- 内联级用单色圆环,不用品牌 spinner——16px 的 12 齿糊成一团。
|
||||
- 说明文字与 spinner 的间距:页面级 / 面板级上下排 16px,内联级左右排 8px。
|
||||
- 一个容器里只出现一档。
|
||||
|
||||
## 4. 内容形态
|
||||
|
||||
- 说明文字只写「在等什么」,不写「请稍候」「请耐心等待」——用户已经在等了。
|
||||
- 用「正在 + 动词 + 宾语」:「正在加载…」「正在加载文件…」「正在生成…」,末尾统一用省略号「…」(一个字符),不用三个句点「...」。写法跟《文案规范》。
|
||||
- 内联级可以不带文字:按钮内、开关内、表格单元格这些位置本身就说明了在等什么。
|
||||
- 加载不带取消按钮:能取消的是「生成」「上传」这类任务,它们各自的组件负责取消。
|
||||
- 加载超过 10 秒仍没回来,说明文字不变、spinner 不停——**指示器一停,用户就认为卡死了**。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 「正在加载文件…」 | 「Loading...」「请耐心等待」 | 前者说了在等什么;英文和「耐心」都是在让用户自己猜 |
|
||||
| spinner 一直转到结果回来 | 超时后 spinner 停住不动 | 停住的 spinner 和死机没有区别 |
|
||||
|
||||
## 5. 状态 State
|
||||
|
||||
区域加载三态:加载中、加载完成、加载失败。列表尾部多一态——**全部加载完成**:下一页拿回来是空的,告诉用户「到底了」,别再往下滚。
|
||||
|
||||
| 状态 | 区域加载 | 列表尾部加载 |
|
||||
|---|---|---|
|
||||
| **加载中** | 品牌 spinner + 「正在加载…」;刷新时旧内容 40% 不透明 | 16px 圆环 + 「正在加载…」,`text-body` text-3 |
|
||||
| **加载完成** | 内容出现,spinner 消失;零条换状态页 | 新一页接在列表后面,状态行留空、等下一次滚到底再触发 |
|
||||
| **全部加载完成** | — | 一行「已展示全部内容」,`text-caption`(12/20)text-4,不可点、不再触发请求 |
|
||||
| **加载失败** | 整块换状态页服务异常(插画 + 「加载失败,请刷新重试」+ 重试按钮),见 [组件-State状态页.md](组件-State状态页.md) §2.2 | 一行「加载失败,点击重试」,`text-body` text-3,整行可点;点击原地重新请求,行内切回加载中 |
|
||||
|
||||
- **区域加载延迟 300ms 出现**:300ms 内回来的请求不显示 spinner,省掉一次闪烁。列表尾部**不延迟**——状态行本来就在视口外,滚进来才看得见。
|
||||
- 加载中至少停留 300ms 再切走:刚出现就消失的 spinner 也是闪烁。
|
||||
- 列表尾部失败**不弹轻提示**:失败信息就在用户正在看的位置,再弹一条是重复。失败行悬停变 text-1,触屏无悬停。
|
||||
- 「已展示全部内容」只要列表有内容就显示,**内容不足一屏、从没触发过加载更多也显示**(设计师 2026-08-28 定)——它是这张列表的句号,不看滚没滚过。零条走状态页。它比加载中和失败行小一号、浅一档(12px text-4)——这是一句「可以不看了」,不该比内容还显眼。
|
||||
- 请求发出去了再切换来源,旧请求的结果**丢弃**,不允许旧数据盖住新数据。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 尾部失败:「加载失败,点击重试」整行可点 | 尾部失败:只写「加载失败」 | 用户不知道还能不能往下看,只能刷新整页重来 |
|
||||
| 首屏失败换状态页给重试按钮 | 首屏失败居中一行「加载失败」 | 一行灰字撑不起整块区域,也没告诉用户下一步 |
|
||||
|
||||
## 6. 动效
|
||||
|
||||
- 品牌 spinner 与圆环都是匀速旋转,一圈 1s,线性、不缓动——缓动会让转速看起来忽快忽慢。
|
||||
- 出现与消失都用 0.2s 淡入淡出,不做缩放。
|
||||
- 刷新时旧内容降到 40% 不透明的过渡 0.2s。
|
||||
- 系统开启「减弱动态效果」时,spinner 改为不透明度 40%⇄100% 的呼吸,不旋转。
|
||||
|
||||
## 7. 移动端适配
|
||||
|
||||
跨组件通则见 [基础-多端适配原则.md](基础-多端适配原则.md),这里只写加载自己的细则。
|
||||
|
||||
| 项 | 触屏 / 窄屏规则 |
|
||||
|---|---|
|
||||
| 尺寸 | 不变;视口高度不足 480px 时页面级降为面板级 40px |
|
||||
| 列表尾部失败行 | 热区整行、高度 44px(40px 行 + 上下各 2px 透明热区),同按钮口径 |
|
||||
| 悬停 hover | 触屏没有悬停,关掉失败行的 hover 态 |
|
||||
| 下拉刷新 | 本期不做;顶部刷新仍用区域加载的「旧内容 40% + spinner」 |
|
||||
|
||||
## 8. 无障碍
|
||||
|
||||
- 加载中的区域标 `aria-busy="true"`,spinner 标 `role="status"` + 说明文字作为可读文本;内联级没有文字时给 `aria-label="正在加载"`。
|
||||
- 列表尾部的失败行是按钮(`<button>`),键盘可达、Enter / Space 触发重试。
|
||||
- 状态切换(加载中 → 失败 / 全部加载完)用 `aria-live="polite"` 播报,不用 assertive——加载不是紧急事件。
|
||||
|
||||
<!-- site-hide -->
|
||||
## 给实现窗口
|
||||
|
||||
1. 组件位置 `packages/ui/src/components/Loading/`,导出两个:`Loading`(区域加载)与 `LoadMore`(列表尾部)。
|
||||
- `Loading`:`size: page | panel | inline`(80 / 40 / 16)、`text?: string`(内联级可空)、`delay?: number`(默认 300,内联默认 0)、`spinning?: boolean`(包裹模式,`children` 存在时旧内容 `opacity-40 pointer-events-none`)、`minDuration` 固定 300ms 不暴露。page / panel 渲染品牌 spinner(复用 client `ui/icon/Loading` 的 `LoadingIcon` 逻辑:BRAND_CONFIG 自定义 → `<img>`,否则内联 12 齿 SVG + `bs-tick-spinner` 遮罩动画,`text-primary`);inline 渲染 `Outlined.Loading` + `animate-spin`,`text-text-3`。
|
||||
- `LoadMore`:`status: idle | loading | error | done`、`onLoad`、`onRetry`、`loadingText` / `errorText` / `doneText`(文案由调用方传,组件库不含文案)、`rootMargin`(默认 `200px`)。自带 IntersectionObserver 哨兵,root 取最近的可滚祖先(沿用 `SpaceDetail/LoadMore.tsx` 的 `findScrollableAncestor`,不能只看 viewport);`status === 'loading' | 'done' | 'error'` 时不触发 `onLoad`。error 渲染 `<button class="h-10 w-full text-body text-text-3 hover:text-text-1">`,done 渲染 `<div class="h-10 text-caption text-text-4">`,容器加 `col-span-full`。
|
||||
2. 颜色全走 token:文字 `text-text-3` / `text-text-4` / hover `text-text-1`,品牌 spinner `text-primary`,遮罩用 `opacity-40` 不加底色;不写裸 hex(explore 页 `#a9aeb8` 收编为 `text-text-4`)。
|
||||
3. 动效:旋转 `animate-spin`(1s linear infinite);淡入淡出 `transition-opacity duration-200`;`motion-reduce:` 前缀下改 `animate-pulse`(opacity 呼吸)并去掉旋转。
|
||||
4. 无障碍按 §8:区域容器 `aria-busy`,spinner `role="status"`,LoadMore 状态行 `aria-live="polite"`。
|
||||
5. 迁移映射(2026-08-28 只读扫描,范围 `client/src`,排除 node_modules):
|
||||
- `components/InfiniteScroll.tsx`(ArticleList、ChannelPreviewDrawer 共 2 处引用)→ `LoadMore`;lucide `Loader2` → `Outlined.Loading`;末尾 `text-gray-300` → `text-text-4`;`emptyText` 语义即 `doneText`。
|
||||
- `pages/knowledge/SpaceDetail/LoadMore.tsx`(1 处引用)→ 库内 `LoadMore`,删除页面私有版本;SpaceDetail 的 `listBottomStatus` 三元分支收成 `status` 一个入参。
|
||||
- `pages/ChannelSquare.tsx`、`pages/knowledge/KnowledgeSquare.tsx`、`pages/apps/index.tsx`、`pages/apps/explore.tsx` 尾部手拼三态(`loadingMore ? … : loadMoreError ? … : !hasMorePage ? …`)→ `LoadMore`,失败态由此获得重试。
|
||||
- 区域首屏 5 处(ChannelSquare / KnowledgeSquare / SpaceDetail / apps index / apps explore)`LoadingIcon size-20 + span` → `<Loading size="page" text=… />`;同处的 `initialError` 一行「加载失败」→ 状态页服务异常(`StateView` 的 SystemMaintenance + 重试按钮),此项属状态页迁移,本组件只留指针。
|
||||
- `components/messageApproval/NotificationPane.tsx`、`components/permission/SubjectSearchUser.tsx`、`components/Chat/Input/KnowledgeListPanel.tsx` 各有一套滚动加载(onScroll 距底 10px 触发),**待迁**,迁时同样收成 `LoadMore`。
|
||||
- 其余 58 处 `animate-spin` 未逐一扫描(含按钮内、文件预览等),迁移窗口用 `animate-spin` grep 后补附录;按钮内的归《Button》,不动。
|
||||
6. 站点接线(元规范 §5):本文注册进 `rspress.config.ts` 侧栏 `/` 分组;demo 页 `components/loading.mdx` 待组件落地后建,front matter `component: Loading`;00-总纲 §四 与 01-设计规范 §0 索引行随建档更新。
|
||||
|
||||
## 待决策清单
|
||||
|
||||
- 骨架屏 Skeleton 不在本期:何时用骨架、何时用 spinner、请求多久没回来才显示骨架——等有第二个真实骨架场景再立文档(现 `ui/Skeleton.tsx` 8 处引用沿用)。本文只划清「加载中不用状态页」的边界,与《状态页》§11 那条对应。
|
||||
- 面板级 40px 为本次建档取值(介于页面级 80 与内联 16 之间、与列表尾部行高同值),未在真实卡片 / 抽屉里目检,待验收。
|
||||
- 区域刷新的「旧内容 40% 不透明」与《Modal 弹窗》遮罩黑 40% 数值相同但机制不同(这里是内容自身降透明、不加遮罩层),若在深色模式下对比不够再议加 fill 底。
|
||||
- 列表尾部失败是否在连续失败 3 次后改成「请稍后重试」并停止自动触发:本次不做限制,每次点击都重试。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-28 | §2.2 / §5:列表尾部由三态改四态,「全部加载完成(已展示全部内容)」从加载完成里拆出来单列一行(设计师指出);「内容不足一屏也显示」由待决策转为 §5 规则(设计师定);数值与文案未改 | 待 committer 窗口提交 |
|
||||
| 2026-08-28 | 建档 v1:先调研 antd / Arco / TDesign / Apple HIG / Carbon,设计师拍板四项——范围收区域加载 + 列表尾部加载(骨架屏不做)、图形两档分工(区域品牌 12 齿 spinner、列表尾部 16px 圆环)、失败分两级(区域走状态页服务异常、尾部一行可点重试)、延迟区域 300ms / 尾部 0。展示层 8 节:两形态、尺寸三档 80/40/16 看容器、文案「正在 + 动词」、三态表、动效 1s 线性 + 0.2s 淡入淡出 + 减弱动态改呼吸、移动端失败行 44px 热区、无障碍 aria-busy / status / polite。隐藏区:`Loading` + `LoadMore` 两个导出的 API、client 两套尾部加载与 5 处首屏加载的迁移映射 | 待 committer 窗口提交 |
|
||||
@@ -0,0 +1,106 @@
|
||||
# 单选框 Radio
|
||||
|
||||
> 设计系统 · 单选框 v1 · 2026-08-24 建档
|
||||
> 与 [00-总纲.md](00-总纲.md)、[01-设计规范.md](01-设计规范.md) 配套;字号见 [基础-字体规范.mdx](基础-字体规范.mdx)、颜色见 [基础-色彩规范.mdx](基础-色彩规范.mdx)、圆角见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)、移动端通则见 [基础-多端适配原则.md](基础-多端适配原则.md)、文案见 [基础-文案规范.md](基础-文案规范.md)。
|
||||
> 调研来源(不进展示层):「选中不可反选」「选项 2~7 个摊开、更多换下拉」为 antd / Arco / HIG 共识;按钮组形态先例为 antd Radio.Button / Arco Radio 按钮模式;选中画法取 Arco 的「品牌色实心 + 白点」路线(与复选框实心方块同构),未取 antd 的「白底 + 品牌描边」。设计师拍板(2026-08-24):选中态品牌色、尺寸跟控件三档、要按钮组形态。
|
||||
|
||||
## 1. 什么时候用
|
||||
|
||||
单选框用来在一组选项里**只选一个**,且选项**全部摊开可见**;结果随表单提交才生效。三个选择类组件怎么分工,见 [组件-Checkbox复选框.md](组件-Checkbox复选框.md) §1 的判定表。
|
||||
|
||||
- 选项 2~7 个用单选框;再多摊不开,换下拉选择(Select 规范待建,见总纲进度看板)。
|
||||
- **选中后点已选项不会取消**——需要「一个都不选」的合法状态,就明确加一项「无 / 不需要」。
|
||||
- 有安全的默认值就预选它;没有就一项都不预选,让用户明确表态。
|
||||
|
||||
## 2. 类型 Type
|
||||
|
||||
| 类型 | 什么时候用 | 长相 |
|
||||
|---|---|---|
|
||||
| 基础单选框 | 表单里的陈述式选项 | 圆圈 + 右侧文字 |
|
||||
| 单选组 Group | 一组互斥选项 | 横排或竖排的一组基础款 |
|
||||
| 按钮组 | 筛选条、视图切换这类高频切换 | 一排共享描边的按钮,选中项高亮 |
|
||||
| 卡片式 | 选项带标题 + 描述的方案选择 | 同复选框卡片式(见 [组件-Checkbox复选框.md](组件-Checkbox复选框.md) §2),仅选中互斥 |
|
||||
|
||||
- Group 间距同复选框:横排 16px、竖排 8px;文案长就竖排。
|
||||
- 按钮组是单选的另一张皮:各项自带 1px 描边、相邻项共享一条边(antd 同构,2026-08-25 拍板替换初稿「整组一条外描边 + 分隔线」案——那种结构选中项的边圈没法变色);圆角随尺寸档、只保留整组外侧两角。选中项整圈描边转淡品牌色(品牌 100 档,取值同卡片式;含与相邻项共享的边),底色仍是统一选中浅底、不做实心。选项文字 2~4 个字,放不下就回到圆点或下拉。
|
||||
|
||||
## 3. 尺寸 Size
|
||||
|
||||
圆圈与复选框方块同一套阶梯(14 / 16 / 18 随控件三档,文字 14 / 14 / 16,圈与文字间距 8px),见 [组件-Checkbox复选框.md](组件-Checkbox复选框.md) §3;选中白色内点直径 = 外圈 − 8(6 / 8 / 10)。
|
||||
|
||||
按钮组不走圆圈阶梯,走按钮的高度阶梯(见 [组件-Button按钮.md](组件-Button按钮.md) §3):高 24 / 32 / 40,水平内边距 8 / 16 / 16,圆角 4 / 6 / 8,与同排按钮、输入框天然对齐。
|
||||
|
||||
## 4. 内容形态
|
||||
|
||||
- 选项文案写并列同构的名词或短语(「按天 / 按周 / 按月」),读者扫一遍就能比出差别;不加句号。
|
||||
- 选项可带一行次要说明(次要文字色),规则同复选框 §4。
|
||||
- 文字在点击热区内,点字即点圈。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 「按天 / 按周 / 按月」 | 「按天 / 每一周汇总一次 / 月」 | 选项不同构,用户没法并排比较,选择变成阅读理解 |
|
||||
| 需要空选就加「无」选项 | 期望用户再点一下取消选中 | 单选框点已选项不反选,用户会以为组件坏了 |
|
||||
|
||||
## 5. 状态 State
|
||||
|
||||
| 状态 | 样式 |
|
||||
|---|---|
|
||||
| 未选 default | 白底 + 灰描边(border-base) |
|
||||
| 悬停 hover | 描边加深(border-deep) |
|
||||
| 选中 checked | 品牌色实心圆 + 白色内点,与复选框实心方块同构 |
|
||||
| 禁用 disabled | 浅灰底 + 灰描边,与按钮 disabled 同一套(见 [组件-Button按钮.md](组件-Button按钮.md) §6);选中禁用为浅灰底 + 灰内点 |
|
||||
| 聚焦 focus | 键盘 Tab 到才出现:2px 灰色阴影环,同输入框聚焦环(见 [组件-Input输入框.md](组件-Input输入框.md) §5) |
|
||||
|
||||
按钮组的状态换一套画法,不画圆点:
|
||||
|
||||
| 状态 | 样式 |
|
||||
|---|---|
|
||||
| 未选项 | 白底 + 灰字(正文色)+ 灰描边(border-base) |
|
||||
| 未选项悬停 | 加浅灰底,同文字按钮 hover |
|
||||
| 选中项 | 品牌色文字 + 品牌浅底 + **整圈淡品牌描边**(品牌 100 档,同卡片式;2026-08-25 拍板,结构参照 antd) |
|
||||
| 禁用项 | 与按钮 disabled 同一套(描边同转禁用灰) |
|
||||
|
||||
- 组校验错误提示放组下方、用危险色,同复选框 §5;圆圈本身不变红。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 筛选条用按钮组 | 筛选条摆一排圆点单选框 | 高频切换要的是大热区和状态高亮,圆点是给表单陈述用的 |
|
||||
| 按钮组选中项品牌浅底 | 选中项实心品牌底 | 按钮组是切换器不是主行动点,实心会和主按钮抢视线 |
|
||||
|
||||
## 6. 移动端适配
|
||||
|
||||
跨组件通则见 [基础-多端适配原则.md](基础-多端适配原则.md),这里只写单选框自己的细则。
|
||||
|
||||
| 项 | 触屏 / 窄屏规则 |
|
||||
|---|---|
|
||||
| 悬停 hover | 触屏没有悬停,关掉 hover 态 |
|
||||
| 触达 | 整行(圆圈 + 文字)热区高度 ≥44px;按钮组各项热区同按钮口径扩到 ≥44px |
|
||||
| 排列 | 窄屏 Group 一律竖排 |
|
||||
| 按钮组 | 窄屏放不下就换竖排圆点组或下拉,不做横向滚动——滚出视口的选项等于不存在 |
|
||||
|
||||
<!-- site-hide -->
|
||||
## 落地(给实现窗口)
|
||||
|
||||
**2026-08-25 已落地**:`@bisheng/ui` 导出 `RadioGroup` / `Radio` / `RadioCard`(`src/components/Radio/`,按钮组即 `RadioGroup variant="button"`,卡壳与 Checkbox 共用 `src/components/Selection/shared.ts`),demo 页 `docs/components/radio.mdx`,侧栏已注册。client 现状迁移仍待迁移窗口。下面是当时给实现窗口的口径,已按此实现:
|
||||
|
||||
1. cva `variants: { size }`(圆圈 14 / 16 / 18、内点 6 / 8 / 10,文字 14 / 14 / 16);`Radio` / `RadioGroup` 一套基座,按钮组作为 `RadioGroup` 的 `variant="button"`(高度、内边距、圆角直接引按钮三档取值),卡片式与复选框卡片共用壳。
|
||||
2. 颜色全走 token,禁裸 hex:描边 `border-border-base` / hover `border-border-deep`;选中实心 `bg-blue-500`(`--brand-*`)、内点白;按钮组选中项 `text-blue-500` + 统一选中浅底 `bg-blue-500/[0.07]`,未选项 hover 浅灰底与文字按钮同 token;disabled 复用按钮三 token;聚焦环复用 `shadow-focus` + `:focus-visible`。
|
||||
3. a11y:`role="radiogroup"` + 方向键在组内移动、Tab 只进出组一次(roving tabindex);按钮组同规则,别做成一排独立 button。
|
||||
4. 触屏热区同复选框:整行 label 撑高到 44px。
|
||||
5. 卡片式现状参考(2026-08-24 设计师点名,knowledge/create 页):`client/src/components/permission/UnifiedPermissionControls.tsx` 的 `AccessModeSelector`——卡壳画法已定稿进 [组件-Checkbox复选框.md](组件-Checkbox复选框.md) §2 与其落地区。其中**圆点本身**与本规范的归并差异:现状 16px、未选即 2px `border-deep` 粗描边、内点 4px 且取 `bg-fill-1`;规范为未选 1px `border-base`、hover 才 `border-deep`、选中品牌实心 + 纯白内点(直径 = 外圈 − 8,16px 档即 8px)。落地时圆点统一走本规范,卡壳照 Checkbox 文档。
|
||||
6. 现状扫描未做:client 内其余 radio 实现与「一排按钮手拼互斥选中」的写法,待迁移窗口扫描后补附录。
|
||||
|
||||
## 待决策清单
|
||||
|
||||
- 聚焦环颜色的品牌淡环替代路线,归口 [组件-Checkbox复选框.md](组件-Checkbox复选框.md) 待决策清单,不在此重复记。
|
||||
- 按钮组要不要出「实心选中」的强调变体(antd 有 solid 模式),等真实场景再议;v1 只做浅底。
|
||||
- 组校验的触发时机与文案归口未来的 Form 规范。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-24 | 建档 v1:适用判定(2~7 个摊开、选中不反选、空选加「无」项);类型四种(基础 / Group / 按钮组 / 卡片式引复选框);圆圈尺寸引复选框三档、内点 = 外圈 − 8,按钮组走按钮高度阶梯;选中态「品牌色实心 + 白点」定稿;按钮组选中项浅底不做实心;移动端不做横向滚动按钮组。rspress 侧栏注册与 demo 页待实现窗口 | 待 committer 窗口提交 |
|
||||
| 2026-08-25 | **按钮组描边改 antd 同构**(设计师点名 antd Radio.Button 描边样式作参考,底色保持浅底不做实心):§2 结构从「整组一条外描边 + divide 分隔线」改为「各项自带描边 + 相邻共享一条边」,§5 选中项加「整圈淡品牌描边」(100 档,取值向卡片式对齐)——旧结构下选中项的边圈没法变色,是这次换结构的动因;聚焦环随之从内嵌环回归标准 shadow-focus 外环 | 本次窗口 |
|
||||
| 2026-08-25 | **组件落地**:`@bisheng/ui` 新增 `RadioGroup` / `Radio` / `RadioCard`(radix radio-group 基座,roving tabindex 免费拿到;按钮组 = `variant="button"`),demo 页 `components/radio.mdx` + 侧栏注册。全按本文实现,展示层规则未增未减未改 | 本次窗口 |
|
||||
| 2026-08-24 | 落地区回填卡片式现状参考(设计师点名 knowledge/create 页 `AccessModeSelector`):卡壳归口 Checkbox 文档 §2(该文档同日定稿),本文记圆点画法的归并差异(现状 2px 粗边 / 4px 灰内点 → 规范 1px 边 / 纯白内点 = 外圈 − 8)。展示层规则未增未减未改 | 待 committer 窗口提交 |
|
||||
@@ -0,0 +1,108 @@
|
||||
# 分段控制器 Segmented
|
||||
|
||||
> 设计系统 · 分段控制器 v1 · 2026-08-26 建档
|
||||
> 与 [00-总纲.md](00-总纲.md)、[01-设计规范.md](01-设计规范.md) 配套;字号见 [基础-字体规范.mdx](基础-字体规范.mdx)、颜色见 [基础-色彩规范.mdx](基础-色彩规范.mdx)、圆角见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)、图标见 [基础-图标规范.mdx](基础-图标规范.mdx)、移动端通则见 [基础-多端适配原则.md](基础-多端适配原则.md)、文案见 [基础-文案规范.md](基础-文案规范.md)。姊妹篇:[组件-Tabs标签页.md](组件-Tabs标签页.md)。
|
||||
> 调研来源(不进展示层):antd Segmented(定位为单选控件;尺寸 24/32/40 与 BISHENG 控件阶梯同构;block 模式)、Apple HIG(iPhone 上 ≤5 段、各段内容长度接近、不混用文本与图标段)、Material 3(segmented buttons 2~5 段)。「白浮块 + 灰底槽」为 iOS / antd 主流画法。设计师拍板(2026-08-26):选中态用白浮块 + 灰底槽(不用品牌色);三档 28/32/36(同日调整:初稿沿用控件阶梯 24/32/40,定稿收敛半档);选中字重 500;与 Tabs 分写两份文档。
|
||||
|
||||
## 1. 什么时候用
|
||||
|
||||
分段控制器让**同一份内容换个看法**——列表还是卡片、按日还是按周、地图还是卫星图。它本质是个**即时生效的单选控件**:选项少、文案短、点完立刻切换。
|
||||
|
||||
判别口径(规则只写在这里,标签页那边引用):
|
||||
|
||||
| 你在切什么 | 用什么 |
|
||||
|---|---|
|
||||
| 切完后「你在哪」变了——换了一块内容区 | 标签页 Tabs,见 [组件-Tabs标签页.md](组件-Tabs标签页.md) |
|
||||
| 还在原地,同一份内容换展现方式 / 模式 / 粒度 | 分段控制器 Segmented |
|
||||
| 选完要随表单一起提交才生效 | 单选框 Radio |
|
||||
| 选项超过 5 个 | 换标签页或选择器,别硬塞 |
|
||||
|
||||
- 段数 **2~5 段**(Apple HIG / Material 的一致推荐区间),超出就不是「几个看法」而是「一堆选项」了。
|
||||
- 分段控制器**总有一段被选中**,不存在「都不选」的状态;允许空选的场景用单选框。
|
||||
|
||||
## 2. 长相与结构
|
||||
|
||||
灰底槽里一块白色浮块,点哪段滑到哪段。
|
||||
|
||||
- **底槽**:浅灰填充底(fill-2),无描边。
|
||||
- **浮块**:白色填充,盖在选中段上,切换时滑动过去;**不加投影**——《圆角与阴影规范》投影只有浮层两档,分段控制器是贴版面的控件,白底与灰槽的对比已经足够。
|
||||
- **槽内边距 3px**,浮块圆角 = 外圆角 − 3px(嵌套同心,见《圆角与阴影规范》使用原则)。
|
||||
- **各段等宽**,取最长一段的宽度——段宽参差会让浮块滑动时忽胖忽瘦;`block` 模式下撑满父容器、各段平分。
|
||||
|
||||
## 3. 尺寸 Size
|
||||
|
||||
三档,medium 是默认。medium 32px 落在控件阶梯上(同 [组件-Button按钮.md](组件-Button按钮.md) §3、[组件-Input输入框.md](组件-Input输入框.md) §3),与同排控件天然对齐;small / large 取 28 / 36,比控件阶梯的 24 / 40 各收敛半档——分段控制器是整块灰底,同高时视觉分量比按钮重,档差收窄后大小档都不抢版面。
|
||||
|
||||
| size | 高度 | 字号 / 行高 | 外圆角 | 浮块圆角 | 段水平内边距 | 什么时候用 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `small` | 28px | 14 / 22 | 4px | 1px | 8px | 表格工具条、卡片角落 |
|
||||
| `medium`(**默认**) | 32px | 14 / 22 | 6px | 3px | 12px | 绝大多数场景 |
|
||||
| `large` | 36px | 16 / 24 | 8px | 5px | 16px | 页头、大表单 |
|
||||
|
||||
- 字号跟《字体规范》、外圆角跟《圆角与阴影规范》控件档,随档取值不单独定义。
|
||||
- 和旁边的按钮、输入框同档搭配:32px 的搜索框旁边配 medium(等高对齐);small / large 与相邻控件差 4px 以内,同行混排时垂直居中即可,不追求像素等高。
|
||||
|
||||
## 4. 内容形态
|
||||
|
||||
段内容三种形态,**同一个控件里只用一种**——文本段和纯 icon 段混在一起,读的人要在两套语言间来回翻译(Apple HIG 同款禁令)。
|
||||
|
||||
| 形态 | 什么时候用 | 细则 |
|
||||
|---|---|---|
|
||||
| **纯文本** | 绝大多数场景 | 文案 2~4 个字、各段等长最好;跟《文案规范》 |
|
||||
| **icon + 文本** | 文案短且 icon 能加速识别(如「列表 / 卡片」) | icon 随档 14 / 16 / 18px、与文字间距 8px(small 档 4px),同《图标规范》 |
|
||||
| **纯 icon** | 空间极窄且 icon 语义业内公认(视图切换的列表 / 网格) | **每段必须配 Tooltip** 说明含义,见 [组件-Tooltip文字提示.md](组件-Tooltip文字提示.md) |
|
||||
|
||||
- 文案装不下**不加省略号**——改短文案,或者承认它不是分段控制器该干的活,换标签页。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 「日 / 周 / 月」三段等长 | 「今天 / 本周 / 最近三个月」 | 各段等宽取最长段,一段特别长会把整个控件撑得松垮 |
|
||||
| 纯 icon 段配 Tooltip | 纯 icon 段裸奔 | icon 语义再公认也有第一次见的人,没提示等于猜谜 |
|
||||
|
||||
## 5. 状态 State
|
||||
|
||||
| 状态 | 样式 |
|
||||
|---|---|
|
||||
| 选中 active | 白色浮块 + 主文字色(text-1) |
|
||||
| 未选中 default | 无底色 + 辅助文字色(text-3)——比常规控件的次要文字浅一档,让位给选中段 |
|
||||
| 悬停 hover | 未选中段文字加深为主文字色(text-1),底色不变 |
|
||||
| 单段禁用 disabled | 灰字(同按钮 disabled 文字色,见 [组件-Button按钮.md](组件-Button按钮.md) §6)+「禁止」光标,其余段照常可点 |
|
||||
| 整体禁用 | 全部段灰字 + 浮块保持在当前段,整体「禁止」光标 |
|
||||
| 键盘焦点 focus-visible | 控件外圈灰色聚焦环(同输入框的 2px gray-2 环口径,见 [组件-Input输入框.md](组件-Input输入框.md) §5) |
|
||||
|
||||
- **选中不用品牌色**——它只回答「当前在哪个看法」,和输入框聚焦一样不是语义时刻;品牌色留给列表选中、按钮这些真正要说话的地方(见《色彩规范》使用规则)。
|
||||
- 浮块滑动过渡 200ms;切换即生效,段对应的内容区即时替换、不加转场。
|
||||
- 字重:未选中 400,**选中 500**——浮块之外再补一层文字强调。段文案按 §4 控制在 2~4 个汉字,中文字形不随字重变宽,等宽段不会因选中加粗而跳动;西文/数字文案要留意这一点,长西文段慎用。
|
||||
|
||||
## 6. 移动端适配
|
||||
|
||||
跨组件通则见 [基础-多端适配原则.md](基础-多端适配原则.md),这里只写分段控制器自己的细则。
|
||||
|
||||
| 项 | 触屏 / 窄屏规则 |
|
||||
|---|---|
|
||||
| 悬停 hover | 触屏没有悬停,关掉 hover 态 |
|
||||
| 热区 | 高度不足 44px 的档位用透明热区扩到 ≥44px(同按钮口径,WCAG / Apple HIG 推荐值),视觉高度不变 |
|
||||
| 宽度 | 窄屏下优先用 `block` 撑满容器,各段平分——悬空的小控件在手机上难点中 |
|
||||
| 段数 | 手机上更要守住 ≤5 段;文案放不下就减段或换组件,不缩字号 |
|
||||
|
||||
<!-- site-hide -->
|
||||
## 给实现窗口
|
||||
|
||||
1. 组件位置建议 `packages/ui/src/components/Segmented/`,cva `variants: { size }`(高 28 / 32 / 36,外圆角 4 / 6 / 8,槽 padding 3px,浮块圆角 1 / 3 / 5,段水平 padding 8 / 12 / 16)。浮块单独一个绝对定位元素测量滑动(200ms ease),别靠每段自己换底色——那样没有滑动感。
|
||||
2. 无障碍走 `role="radiogroup"` + 每段 `role="radio"` / `aria-checked`(它语义上是单选,不是 tabs);方向键在段间移动并即时选中。
|
||||
3. 颜色全走 token:槽底 `bg-fill-2`、浮块白底、选中 `text-text-1`、未选中 `text-text-3`、disabled 复用按钮 disabled 文字 token;无裸 hex。
|
||||
4. 触屏热区复用 `btn-touch-hit` 口径;`block` 模式即宽度 100% + 各段 `flex-1`。
|
||||
5. 站点接线(元规范 §5):`rspress.config.ts` 侧栏注册本文 + `components/segmented.mdx` demo 页(ASCII 文件名);00-总纲 §四 与 01-设计规范 §0 索引行本次已随建档更新。
|
||||
6. 现状扫描未做:client 内现存分段样式(手拼 toggle 组、按钮组充当切换器等)的用量与迁移映射,待迁移窗口扫描后补附录。
|
||||
|
||||
## 待决策清单
|
||||
|
||||
- **浮块要不要一点轻投影**:iOS / antd 的浮块都带极轻投影,本文按「不加」定稿(守投影两档铁律);若设计师验收觉得浮起感不足,再议是否照聚焦环先例走《圆角与阴影规范》§4 例外条款单开一条。
|
||||
- 灰底槽 fill-2 放在非白底(浅灰卡片内)上对比会减弱,与输入框聚焦环同一问题;真出现灰底场景再一并议。
|
||||
- 「各段等宽取最长段」待真实页面验收;若出现文案长度差异大的合理场景,再议 hug 内容的紧凑变体。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-26 | 建档 v1:定位为即时生效的单选控件,与 Tabs / Radio 的判别表归本文 §1;白浮块 + 灰底槽(fill-2)、浮块不加投影、选中不用品牌色、选中字重 500;尺寸三档 28/32/36(medium 对齐控件阶梯,小/大档收敛半档)、槽内边距 3px 嵌套同心;内容三形态不混用、段数 2~5、纯 icon 必配 Tooltip;状态六态;移动端 block 撑满 + ≥44px 热区。与 [组件-Tabs标签页.md](组件-Tabs标签页.md) 同日建档 | 待 committer 窗口提交 |
|
||||
@@ -0,0 +1,95 @@
|
||||
# 开关 Switch
|
||||
|
||||
> 设计系统 · 开关 v1 · 2026-08-24 建档
|
||||
> 与 [00-总纲.md](00-总纲.md)、[01-设计规范.md](01-设计规范.md) 配套;颜色见 [基础-色彩规范.mdx](基础-色彩规范.mdx)、图标见 [基础-图标规范.mdx](基础-图标规范.mdx)、移动端通则见 [基础-多端适配原则.md](基础-多端适配原则.md)、文案见 [基础-文案规范.md](基础-文案规范.md)。
|
||||
> 调研来源(不进展示层):「立即生效、label 描述开着的是什么、不写疑问句」取 Apple HIG;默认档高度取 antd 的 22(恰与本站正文行高同值故沿用,未取 Arco 的 24×40);宽度未取 antd 的 2:1(22×44)——设计师看实物嫌宽(2026-08-25),按 client 工具菜单开关现状(20×34,宽高比 ≈1.7)等比收窄为 22×38;small 档同日弃用 antd 的 16×28,升为 18×32 保持同比例;loading 态与框内文字先例为 antd checkedChildren。设计师拍板(2026-08-24):开启态品牌色、两档 + loading + 允许框内文字;(2026-08-25):两档定 22×38 / 18×32。
|
||||
|
||||
## 1. 什么时候用
|
||||
|
||||
开关用来拨一个**独立设置的开 / 关**,拨动**立即生效**——不需要提交按钮,也不需要确认。三个选择类组件怎么分工,见 [组件-Checkbox复选框.md](组件-Checkbox复选框.md) §1 的判定表。
|
||||
|
||||
- 立即生效是开关对用户的承诺:拨完就能看到效果;要攒着随表单一起提交的,用复选框。
|
||||
- label 描述「开着的是什么」(如「消息通知」),不写疑问句、不带「是否」「开启」字样——状态由开关自己表达。
|
||||
- 拨一下就有破坏性后果的动作不用开关,用按钮 + 二次确认(见 [组件-Confirm二次确认.md](组件-Confirm二次确认.md))。
|
||||
|
||||
## 2. 尺寸 Size
|
||||
|
||||
两档,default 是默认。
|
||||
|
||||
| size | 轨道(高 × 最小宽) | 滑块 | 什么时候用 |
|
||||
|---|---|---|---|
|
||||
| `default`(**默认**) | 22 × 38 | 18 | 绝大多数设置项 |
|
||||
| `small` | 18 × 32 | 14 | 表格行内、紧凑列表 |
|
||||
|
||||
- 轨道胶囊圆角(full)、滑块正圆,滑块与轨道边缘留 2px 内边距。
|
||||
- 默认档高 22px 与正文行高同值,和 14px 文字同排天然居中,不用手调对齐。
|
||||
- 带框内文字时轨道随文字加宽,最小宽不变。
|
||||
|
||||
## 3. 内容形态
|
||||
|
||||
- label 放开关左侧(设置列表里开关靠行尾右对齐),文案跟 [基础-文案规范.md](基础-文案规范.md)。
|
||||
- 框内文字是可选的强调,默认不放;要放就最多 2 个字或一个 icon(如「开 / 关」、勾 / 叉),开启时显示在滑块左侧、关闭时在右侧——滑块让出的那一侧。
|
||||
- 框内文字 12px 白字;small 档不放框内文字,装不下。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| label 写「消息通知」 | label 写「是否开启消息通知?」 | 开关自己就是答案,疑问句让 label 和控件各说一遍 |
|
||||
| 框内 icon 用勾 / 叉 | 框内塞「已开启」三个字 | 轨道里只装得下 2 字,长文案会把开关撑成胶囊按钮 |
|
||||
|
||||
## 4. 状态 State
|
||||
|
||||
| 状态 | 样式 |
|
||||
|---|---|
|
||||
| 关 off | 中性灰轨道(gray-4)+ 白色滑块 |
|
||||
| 开 on | 品牌色轨道 + 白色滑块,随蓝⇄绿主题切换 |
|
||||
| 悬停 hover | 轨道加深一档:关为 gray-5,开为品牌色悬停档 |
|
||||
| 禁用 disabled | 保持当前开 / 关的颜色、整体降为 40% 不透明度,「禁止」光标——开关的禁用要能看出停在哪一侧,所以不改成统一灰底 |
|
||||
| 加载 loading | 滑块内出现 spinner、期间不可拨,用于拨动后要等服务端确认的场景 |
|
||||
| 聚焦 focus | 键盘 Tab 到才出现:2px 灰色阴影环,同输入框聚焦环(见 [组件-Input输入框.md](组件-Input输入框.md) §5) |
|
||||
|
||||
- **失败要回弹**:异步操作失败时开关回到原状态,并用轻提示说明原因(见 [组件-Toast轻提示.md](组件-Toast轻提示.md))——界面不能停在一个没生效的假状态上。
|
||||
- 开 / 关的颜色对比就是状态信号,不额外加对错色:开关没有错误态,出错走回弹 + 轻提示。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 拨动立即保存并生效 | 拨完还要点「保存」才生效 | 要提交的语义属于复选框,开关的承诺就是立即生效 |
|
||||
| 异步失败回弹 + 轻提示 | 失败后开关停在新位置 | 显示开了、实际没开,界面在骗人 |
|
||||
| 等结果时用 loading 锁住 | 等结果时允许连续快拨 | 连拨会让最终状态和服务端结果对不上 |
|
||||
|
||||
## 5. 移动端适配
|
||||
|
||||
跨组件通则见 [基础-多端适配原则.md](基础-多端适配原则.md),这里只写开关自己的细则。
|
||||
|
||||
| 项 | 触屏 / 窄屏规则 |
|
||||
|---|---|
|
||||
| 悬停 hover | 触屏没有悬停,关掉 hover 态 |
|
||||
| 触达 | 两档高度都不足 44px:设置行整行(label + 开关)可点、热区高度 ≥44px;独立出现的开关用透明热区扩到 ≥44×44px |
|
||||
| 尺寸 | 触屏高频场景 small 直接升 default(同输入框口径) |
|
||||
| 加载 / 禁用 / 聚焦 | 与桌面一致,无额外规则 |
|
||||
|
||||
<!-- site-hide -->
|
||||
## 落地(给实现窗口)
|
||||
|
||||
**2026-08-25 已落地**:`@bisheng/ui` 导出 `Switch`(`src/components/Switch/Switch.tsx`),demo 页 `docs/components/switch.mdx`,侧栏已注册;关闭轨道新立语义 token `--switch-off-bg` / `--switch-off-bg-hover`(tokens.css + tailwind-preset,gray-4 / gray-5,暗色随灰阶自动翻转)。client 现状迁移仍待迁移窗口。下面是当时给实现窗口的口径,已按此实现:
|
||||
|
||||
1. cva `variants: { size }`(22×38 / 18×32,滑块 18 / 14,内边距 2px);`checked` / `loading` / `disabled` 三个独立布尔位,loading 时强制不可拨;框内文字走 `checkedChildren` / `unCheckedChildren` 两插槽。
|
||||
2. 颜色全走 token,禁裸 hex:开启轨道 `bg-blue-500`(`--brand-*`,随主题)、hover 取品牌悬停档(与按钮 primary hover 同源);关闭轨道取 gray-4、hover gray-5——**经 semantic 层消费**(token 页规定组件不直接吃 primitive 灰阶),fill 系没有同值档位就照色彩规范惯例新立语义位,别硬凑;滑块白色用固定 `--white`(轨道有色,滑块不随暗色翻转)。
|
||||
3. loading spinner 用图标规范的 loading 图标,尺寸随滑块(14 / 10 上下取整到图标阶梯);滑块位移过渡 0.2s(antd motionDurationMid 先例),与既有动效对齐后可调。
|
||||
4. a11y:`role="switch"` + `aria-checked`;disabled 的 40% 不透明度对读屏不可见,同步置 `aria-disabled`。
|
||||
5. 聚焦环复用 `shadow-focus`(`--shadow-focus-ring`,默认 gray-2)+ `:focus-visible`;热区参考 `.btn-touch-hit` 写法,设置行整行 label 撑高。
|
||||
6. 现状扫描未做:client 内现存 switch 实现(`components/ui/Switch` 等)与「checkbox 拨完就调接口」的错位用法,待迁移窗口扫描后补附录。
|
||||
|
||||
## 待决策清单
|
||||
|
||||
- 白色框内文字落在 gray-4 关闭轨道上的对比度偏低(antd 同路线同病),若实测读不清:要么关侧文字换深灰、要么关闭轨道加深一档,待实现窗口出真机效果后设计师定。
|
||||
- 聚焦环颜色的品牌淡环替代路线,归口 [组件-Checkbox复选框.md](组件-Checkbox复选框.md) 待决策清单。
|
||||
- 暗色模式下关闭轨道与页面底色的对比(gray-4 翻转后)未验证,随色彩规范暗色批次一起看。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-25 | **small 档升为 18×32**(设计师拍板;原 antd 16×28 弃用):滑块 12 → 14 维持 2px 内边距规则,宽高比 ≈1.78 与默认档 ≈1.73 统一;§2 尺寸表、落地区第 1 条、头部调研来源注同步。spinner 仍 14 / 10 | 本次窗口 |
|
||||
| 2026-08-25 | **默认档收窄 22×44 → 22×38**(设计师看 demo 页实物嫌宽,点名 client 工具菜单开关 20×34 作比例参考,≈1.7:1 等比套到 22 高):§2 尺寸表、落地区第 1 条、头部调研来源注同步;small 16×28 比例本就 ≈1.75,不动。带框内文字仍随文字加宽、最小宽不变 | 本次窗口 |
|
||||
| 2026-08-25 | **组件落地**:`@bisheng/ui` 新增 `Switch`(radix switch 基座;`loading` / `checkedChildren` / `unCheckedChildren` 齐备,small 档不渲染框内文字),关闭轨道新立 `--switch-off-bg(-hover)` 语义 token(gray-4 / gray-5),demo 页 `components/switch.mdx` + 侧栏注册。展示层规则未增未减未改 | 本次窗口 |
|
||||
| 2026-08-24 | 建档 v1:定位「独立设置、立即生效」+ label 三条铁律(描述开着的是什么 / 不写疑问句 / 破坏性动作不用开关);尺寸两档 22×44 / 16×28(默认档高对齐正文行高 22);框内文字可选、≤2 字、small 档禁用;状态六态——开启品牌色、禁用降 40% 不透明保留侧别、loading 锁拨、失败回弹 + 轻提示;移动端整行热区 ≥44px、small 升档。rspress 侧栏注册与 demo 页待实现窗口 | 待 committer 窗口提交 |
|
||||
@@ -0,0 +1,128 @@
|
||||
# 标签页 Tabs
|
||||
|
||||
> 设计系统 · 标签页 v1 · 2026-08-26 建档
|
||||
> 与 [00-总纲.md](00-总纲.md)、[01-设计规范.md](01-设计规范.md) 配套;字号见 [基础-字体规范.mdx](基础-字体规范.mdx)、颜色见 [基础-色彩规范.mdx](基础-色彩规范.mdx)、图标见 [基础-图标规范.mdx](基础-图标规范.mdx)、移动端通则见 [基础-多端适配原则.md](基础-多端适配原则.md)、文案见 [基础-文案规范.md](基础-文案规范.md)。姊妹篇:[组件-Segmented分段控制器.md](组件-Segmented分段控制器.md)(两者的判别口径写在那边 §1,本文只留指针)。
|
||||
> 调研来源(不进展示层):antd Tabs(line / card / editable-card 三类;大号用于页头、小号用于弹窗的场景建议;溢出横滚)、Material 3(tabs 归 navigation、最少两个页签、禁嵌套)、Arco(线型 / 卡片 / 文本 / 圆角 / 胶囊五花八门的反面参照——变体多了互相打架)。设计师拍板(2026-08-26):**只收线型**,卡片型 / 可关闭页签不做;尺寸走 24/32/40 控件阶梯;与 Segmented 分写两份文档。
|
||||
|
||||
## 1. 什么时候用
|
||||
|
||||
标签页把**同一层级的几组内容**收进同一块区域,点谁看谁——切的是「内容区」,属于导航。
|
||||
|
||||
- 一个区域要装几组平级内容(如「详情 / 版本 / 权限」),用标签页。
|
||||
- 只是同一份内容换个看法(列表还是卡片、按日还是按周),用分段控制器,判别表见 [组件-Segmented分段控制器.md](组件-Segmented分段控制器.md) §1。
|
||||
- 有先后顺序、要一步步走完的流程,用分步组件,不用标签页——页签暗示「随便点」,流程不能随便点。
|
||||
- 页签最少 2 个:只有 1 个就不该分页签,直接铺内容。
|
||||
- **不嵌套**:标签页里不再放第二层标签页。真有两层结构,外层改用侧边导航或把内层换成分段控制器。
|
||||
|
||||
## 2. 长相与结构
|
||||
|
||||
BISHENG 的标签页只有**线型**一种画法:标签行 + 2px 指示条,没有卡片壳。一套画法贯穿全站,场景没长出来之前不养第二套。选中态配色有两款(见 §5.1):**品牌款**(默认)与**墨色款**(neutral),画法完全一致,只换选中色。
|
||||
|
||||
- **标签行**:页签水平排布、靠左对齐,行底部一条 1px 分隔线(border-base)通栏拉满,把标签行和内容区分开。
|
||||
- **分隔线可关**(`divider={false}`):**仅当外层容器已经画了这条边**——卡片描边、分区横线正好压在页签下沿时,两条 1px 细线隔着 1px 并排,读起来像渲染出错。容器没画边就别关:分隔线是页签与内容区的归属线,去掉后页签会飘在内容上方。关掉后指示条、间距、动效一概不变。
|
||||
- **指示条**:选中页签文字下方 2px 横条(颜色随 §5.1 配色款),宽度与文字等宽;切换时滑动到新页签。指示条**压在分隔线上、盖住其下的灰线**(同一条基线),不是浮在灰线上方叠成两条。
|
||||
- **相邻页签间距 24px**,首个页签左缘与内容区左缘对齐,不额外缩进。
|
||||
- 标签行右端允许放**附加操作区**(如刷新按钮、筛选),与页签垂直居中对齐;只放和这块内容相关的轻操作,重操作进内容区。
|
||||
|
||||
## 3. 尺寸 Size
|
||||
|
||||
三档,medium 是默认。高度走控件阶梯(同 [组件-Button按钮.md](组件-Button按钮.md) §3),字号跟《字体规范》随档取值。
|
||||
|
||||
| size | 标签行高度 | 字号 / 行高 | 什么时候用 |
|
||||
|---|---|---|---|
|
||||
| `small` | 24px | 14 / 22 | 弹窗、抽屉、卡片内的次级分区 |
|
||||
| `medium`(**默认**) | 32px | 14 / 22 | 绝大多数内容区 |
|
||||
| `large` | 40px | 16 / 24 | 页面顶层、页头下的一级分区 |
|
||||
|
||||
- 字重未选中 400、**选中 500 加粗一档**(2026-08-26 拍板:深色下只靠颜色区分不够醒目)。加粗不许引发位移:**每个页签的宽度按 500 字重预留**(隐形加粗文本占位,antd 同款画法),切换时相邻页签不动、指示条不抖——组件内置,业务侧无感。
|
||||
- 一个页面里标签页可以出现多次,但**层级越深档位越小**:页头用 large,页内容器用 medium / small,别让弹窗里的页签和页头一样大。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 弹窗里用 small 档页签 | 弹窗里塞 40px 大号页签 | 档位暗示层级,弹窗是次级容器,页签比页头还大会压过正文 |
|
||||
| 宽度按加粗态预留后再加粗 | 直接切字重不预留宽度 | 加粗会让文字变宽(拉丁字母 / 数字尤甚),页签集体位移、指示条对不上 |
|
||||
|
||||
## 4. 内容形态
|
||||
|
||||
- **文案 2~6 个字,同组等长最好**——「详情 / 版本 / 权限」比「详情 / 历史版本记录 / 权限」整齐得多;写法跟《文案规范》。
|
||||
- 文案不省略号截断:装不下就改短文案,页签上的「…」等于没写。
|
||||
- 可加**前缀 icon**,尺寸随档 14 / 16 / 18px、与文字间距 8px(small 档 4px),同《图标规范》与按钮一套;同组页签要么都带 icon 要么都不带,别一半有一半没有。
|
||||
- 可在文字右侧挂**数字徽标**(未读数、条目数),画法归口 [组件-Badge徽标.md](组件-Badge徽标.md):高 16px、最小宽 16px、圆角 full(一位数正圆、两位起胶囊;2026-08-28 由徽标规范统一,原为本节沿用频道模块的 6px)、字号 caption-sm/字重 500,底色为当前配色款主色 5% 透明度、文字为该主色,与文字的间距同 icon(small 档 4px,其余 8px)。
|
||||
- **传 0 或不传都不渲染**——业务侧把计数原样传进来即可,不必自己判空。
|
||||
- **徽标不跟随选中态变色**:它报的是「有多少」,不是「你在哪一页」,选中与未选中一律同色;但它**跟随配色款**,墨色款下是墨色,否则墨色款会被徽标重新引入品牌色。
|
||||
- 数字**不截断也不折算**(不做 99+),位数多了页签跟着变宽——与频道模块保持一致;真出现挤爆布局的场景再归口 Badge 规范统一。
|
||||
- 徽标自带 500 字重,不受页签 400⇄500 切换影响,不会引发位移。
|
||||
- 徽标的完整样式体系(类型、语义色、超限显示)已归口 [组件-Badge徽标.md](组件-Badge徽标.md)(独立数字 `standalone` + `brand` 色,画法与本节一致);本节只定标签页里的这一种用法。
|
||||
- **数量与溢出**:页签数量不设硬上限,但超过 7 个先想想能不能合并归类。溢出时**横向滚动 + 两端渐隐**,不换行、不出第二行;选中页签始终保持在可视区内。
|
||||
|
||||
## 5. 状态 State
|
||||
|
||||
| 状态 | 样式 |
|
||||
|---|---|
|
||||
| 未选中 default | 次要文字色(text-2) |
|
||||
| 悬停 hover | 文字加深为主文字色(text-1) |
|
||||
| 选中 active | 字重 500 + 底部 2px 指示条;文字与指示条颜色随配色款(§5.1):品牌款用品牌色,墨色款用主文字色(text-1) |
|
||||
| 禁用 disabled | 灰字(同按钮 disabled 文字色,见 [组件-Button按钮.md](组件-Button按钮.md) §6)+「禁止」光标;支持单个页签禁用 |
|
||||
| 键盘焦点 focus-visible | 页签文字外显示灰色聚焦环(同输入框的 2px gray-2 环口径,见 [组件-Input输入框.md](组件-Input输入框.md) §5) |
|
||||
|
||||
### 5.1 配色款 Variant
|
||||
|
||||
| 款 | 选中文字 / 指示条 | 用在哪 |
|
||||
|---|---|---|
|
||||
| **品牌款 brand**(默认) | 品牌色(跟随蓝⇄绿主题) | 常规场景。选中是品牌色该说话的时刻(《色彩规范》:品牌色管选中态) |
|
||||
| **墨色款 neutral** | 主文字色(text-1) | 品牌色会打架的界面:页面上已有品牌色主按钮 / 链接群抢同一视野,或页签只是弱导航、不该成为页面焦点 |
|
||||
|
||||
- 墨色款的选中表达只剩**加粗 + 指示条**两重信号,比品牌款轻一档——这是它的目的,不是缺陷;别再给它叠别的强调(背景块、描边)补偿。
|
||||
- 墨色款深色模式天然成立:text-1 深色下翻转为近白,无对比度债;品牌款靠品牌深色阶成立(见下条)。
|
||||
- 两款只是换色,结构、尺寸、动效、交互完全一致;一个标签行只能选一款,不许混用。
|
||||
- **品牌款深色模式随品牌深色阶自动成立**(2026-08-26 建档归口):blue-500 深色下由 token 层解析为深色主色(蓝 `#3C7EFF` 5.0:1 / 绿 `#3CB062` 6.8:1,Arco 官方深色算法——提亮同时保饱和),组件与浅色共用同一套 blue-500 类名,无 `dark:` 覆盖。此前深色阶未建时曾靠组件内 brand-400 临时提亮顶过一轮(500 只有 3.6:1、比未选中文字还暗的主次颠倒实测,与 300 发白失品牌感的「亮度换彩度」天花板,定档过程见改动记录)——正是品牌深色阶的立项依据。深色下选中仍不靠亮度压邻居,靠**色相 + 500 字重 + 指示条**三重信号。
|
||||
- 指示条滑动过渡 200ms;**内容区即时替换,不加转场动画**——大面积内容动画只会让切换显得迟钝。
|
||||
- 切换即生效、不需要确认;会丢失用户输入的切换(如页签内有未保存表单)由业务侧拦截提示,组件不内置。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 未开放的功能页签直接不渲染 | 长期挂着一个永远禁用的页签 | 禁用是「暂时不可用」,永远点不了的入口只会反复勾起好奇又反复失望 |
|
||||
| 内容即时替换 | 页签内容加左右滑动转场 | 转场拖慢高频切换,滚动位置和动画还会互相打架 |
|
||||
|
||||
## 6. 移动端适配
|
||||
|
||||
跨组件通则见 [基础-多端适配原则.md](基础-多端适配原则.md),这里只写标签页自己的细则。
|
||||
|
||||
| 项 | 触屏 / 窄屏规则 |
|
||||
|---|---|
|
||||
| 布局 | 一律顶部横排,可横向滑动;不做底部 / 侧边变体 |
|
||||
| 悬停 hover | 触屏没有悬停,关掉 hover 态 |
|
||||
| 热区 | 标签行高度不足 44px 的档位用透明热区扩到 ≥44px(同按钮口径,WCAG / Apple HIG 推荐值),视觉高度不变 |
|
||||
| 溢出 | 横滑 + 渐隐同桌面;选中页签自动滚到可视区 |
|
||||
|
||||
<!-- site-hide -->
|
||||
## 给实现窗口
|
||||
|
||||
1. 组件位置建议 `packages/ui/src/components/Tabs/`,cva `variants: { size }`(标签行高 24 / 32 / 40,字号 14 / 14 / 16)。指示条单独一个绝对定位元素,测量选中页签文字宽度后 transform 过去(200ms ease),别用每页签自己的 border-bottom 拼——那样做不了滑动。
|
||||
2. 无障碍走 WAI-ARIA tabs pattern:`role="tablist" / "tab" / "tabpanel"`,`aria-selected`,roving tabindex(左右方向键移动、Home/End 跳两端、Enter/Space 激活;或采用焦点即激活的 automatic 模式,二选一后在 demo 页注明)。
|
||||
3. 颜色全走 token:未选中 `text-text-2`、hover `text-text-1`、选中与指示条随配色款——品牌款用品牌色 token(跟随蓝 / 绿主题,不写死色值),墨色款用 `text-1`(文字 `text-text-1`、指示条 `bg-text-1`);分隔线 `border-border-base`;disabled 复用按钮 disabled 文字 token。
|
||||
4. 溢出渐隐用两端 mask / 渐变遮罩 + `scrollIntoView` 保持选中项可见;触屏热区复用 `btn-touch-hit` 口径。
|
||||
5. 站点接线(元规范 §5):`rspress.config.ts` 侧栏注册本文 + `components/tabs.mdx` demo 页(ASCII 文件名);00-总纲 §四 与 01-设计规范 §0 索引行本次已随建档更新。
|
||||
6. 现状扫描未做:client 内现存 tabs 实现(radix Tabs、手拼 nav 等)的用量与迁移映射,待迁移窗口扫描后补附录。
|
||||
|
||||
## 待决策清单
|
||||
|
||||
- 相邻页签间距 24px 为本次建档取值(业内 antd / Arco 用 32px,BISHENG 整体走紧凑路线故收一档),待设计师在真实页面上验收。
|
||||
- 数字徽标样式(底色、尺寸、超 99 显示)归口未来的 Badge 规范,本文只定「挂在文字右侧」。
|
||||
- 指示条 200ms 过渡为暂定值,动效规范建档后归口统一时长曲线。
|
||||
- 卡片型、可关闭页签(浏览器多开式)、垂直布局本期均不做——设计师拍板只收线型;真长出多文档 / 多会话平铺场景再议,届时另起类型章节。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-28 | §4 数字徽标的圆角随徽标规范统一为 full(原 6px,沿用自频道模块);徽标本体已改由 Badge 组件渲染,其余口径不变 | 待 committer 窗口提交 |
|
||||
| 2026-08-28 | §4 数字徽标一条指针改指 [组件-Badge徽标.md](组件-Badge徽标.md)(已建档归口);画法、口径零改动 | 待 committer 窗口提交 |
|
||||
| 2026-08-26 | 新增**数字徽标**(§4,`badge`):画法取自频道模块子频道页签(16px 高、主色 5% 底、caption-sm/500);定下三条口径——0 不渲染、不跟随选中态变色但跟随配色款、不做 99+ 截断 | 待 committer 窗口提交 |
|
||||
| 2026-08-26 | 新增**无分隔线款**(§2,`divider={false}`):只在外层容器已画同一条边时用,避免双细线并排;其余画法不变 | 待 committer 窗口提交 |
|
||||
| 2026-08-26 | **品牌深色阶建档,收编临时提亮补丁**:blue-500 深色下由 token 层解析为深色主色(Arco 官方深色算法,蓝 `#3C7EFF` / 绿 `#3CB062`),组件删掉 `dark:` brand-400 覆盖、恢复单一 blue-500 类名;下面那行的三档实测定档由此归口(色阶本体见 基础-色彩规范 §1) | 待 committer 窗口提交 |
|
||||
| 2026-08-26 | 新增**墨色款 neutral**(§5.1,`variant` 二选一):选中文字与指示条用 text-1 不用品牌色,用于品牌色打架 / 弱导航场景;结构动效与品牌款完全一致,深色模式随 text-1 自动成立 | 待 committer 窗口提交 |
|
||||
| 2026-08-26 | 指示条压在分隔线上盖住灰线(§2 补写),不再与灰线叠成两条 | 待 committer 窗口提交 |
|
||||
| 2026-08-26 | 深色模式选中文字与指示条提亮(brand-500 无深色阶,在深底上比未选中文字还暗,3.6:1 vs 8.2:1,主次颠倒、选中显小):实测 500/400/300 三档后定 **brand-400**(5.2:1,Arco 官方深色主色同落点)——300 亮但发白没品牌感,浅色阶「亮度换彩度」的天花板即品牌深色阶立项依据;手法同 Button 深色品牌文字 | 待 committer 窗口提交 |
|
||||
| 2026-08-26 | 选中态字重 400→**500**(设计师验收拍板:深色下只靠颜色区分不够醒目);宽度按加粗态预留写进 §3,防位移由组件内置 | 待 committer 窗口提交 |
|
||||
| 2026-08-26 | 建档 v1:只收线型(卡片型 / 可关闭页签不做,拍板记录见待决策清单);尺寸三档 24/32/40 对齐控件阶梯、字重全档 400 不加粗;选中品牌色 + 2px 指示条滑动;页签间距 24px、溢出横滚渐隐不换行;状态五态;移动端顶部横排 + ≥44px 热区。与 [组件-Segmented分段控制器.md](组件-Segmented分段控制器.md) 同日建档,判别口径归其 §1 | 待 committer 窗口提交 |
|
||||
@@ -0,0 +1,165 @@
|
||||
# 标签 Tag
|
||||
|
||||
> 设计系统 · 标签 v1 · 2026-08-28 建档
|
||||
> 与 [00-总纲.md](00-总纲.md)、[01-设计规范.md](01-设计规范.md) 配套;颜色见 [基础-色彩规范.mdx](基础-色彩规范.mdx)(§3 功能色四态、§4 标签色对)、字号见 [基础-字体规范.mdx](基础-字体规范.mdx)、圆角见 [基础-圆角与阴影规范.mdx](基础-圆角与阴影规范.mdx)、图标见 [基础-图标规范.mdx](基础-图标规范.mdx)、文案见 [基础-文案规范.md](基础-文案规范.md)。姊妹篇:[组件-Badge徽标.md](组件-Badge徽标.md)(「标签还是状态点」的判别表在那边 §1)。
|
||||
> 调研来源(不进展示层):antd Tag(filled / solid / outlined 三款、5 状态色 + 11 预设色、CheckableTag;单一高度 22px)、Arco Tag(20/24/28/32 四档、字重 500、浅底默认 + bordered)、TDesign Tag(20/24/32 三档、light / dark / outline / light-outline 四款、square / round / mark 三形、maxWidth 省略号)。设计师拍板(2026-08-28):**配色只留浅底深字**,描边 / 实底 / 灰底款不做;尺寸**两档 20 / 24**;收展示型、可关闭、可选中、带 icon / 头像四类。**状态点(点 + 一个词)当日由 [组件-Badge徽标.md](组件-Badge徽标.md) 归口过来**,成为本文 §5 的 `dot` 前缀款。
|
||||
> 代码现状(2026-08-28 只读扫描,client/src):`components/ui/Badge.tsx` 是 shadcn pill 标签,实际当 Tag 用(MultiSelect 已选项、MessageSource 来源);`components/ui/Tag.tsx` 绿描边可移除 chip,0 处引用。迁库时统一归口本文,见给实现窗口。
|
||||
|
||||
## 1. 什么时候用
|
||||
|
||||
标签给一个对象**贴一个词**:它是什么类型、处在什么状态、属于哪个分类。贴的是「属性」,不是操作。
|
||||
|
||||
- 说明「这是什么 / 什么状态」(技能、助手、审批中、已完成),用标签。
|
||||
- 列表里每行都要标状态、要一眼扫过去,用**带状态点的 small 档标签**(§5 `dot`):点 + 一个词,行高不变、颜色好扫。
|
||||
- 说「有多少 / 有没有新的」,用徽标,不用标签,判别表见 [组件-Badge徽标.md](组件-Badge徽标.md) §1。
|
||||
- 要触发一个动作,用按钮——标签长得像按钮,但点标签不该「发生什么」。
|
||||
- 一个对象最多贴 **3 个**标签:再多就没人看了,多出来的收进详情。
|
||||
|
||||
## 2. 长相与结构
|
||||
|
||||
一块浅色底 + 深色字,圆角 4px(sm 档),没有描边、没有实底。**同一语义一个色**,颜色对照 §3。
|
||||
|
||||
- 文字水平垂直居中,左右内边距随尺寸档(§4)。
|
||||
- 可在文字前挂 icon 或头像,可在文字后挂关闭按钮(§5)。
|
||||
- 标签之间横向间距 8px;一行放不下换行,行间距 8px。
|
||||
- 深色模式:浅底自动切到深而饱和的色底,字色随功能色深色阶走(《色彩规范》§3),业务不写 `dark:` 覆盖。
|
||||
|
||||
## 3. 类型 Type
|
||||
|
||||
按「贴什么」分五个语义色,按「能不能动」分三种交互型。语义色管颜色,交互型管行为,两轴各选一个。
|
||||
|
||||
### 3.1 语义色 Color
|
||||
|
||||
| color | 底色 / 字色 | 贴什么 |
|
||||
|---|---|---|
|
||||
| `default`(**默认**) | fill-2 / text-2 | 无语义的普通分类、关键词、已选项 |
|
||||
| `brand` | 品牌最浅档 / 品牌主色(随蓝⇄绿主题) | 强调类分类:当前版本、推荐、新 |
|
||||
| `success` | success-tint / success | 已完成、已通过、在线、已发布 |
|
||||
| `warning` | warning-tint / warning | 待处理、即将到期、草稿 |
|
||||
| `danger` | danger-tint / danger | 已驳回、失败、已停用、已过期 |
|
||||
|
||||
- 固定例外(不随主题切换):**审批中恒为蓝**、**技能恒为紫**,两组色对见《色彩规范》§4,其余业务不得再新增固定色。
|
||||
- 一种语义只用一个色:「已完成」全站都是 success,不许这页绿那页蓝。
|
||||
- 第三方品牌色(各家 logo 色)原样保留,不进语义色(《色彩规范》§4)。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 「已驳回」用 danger,「待处理」用 warning | 「待处理」也用 danger 红 | 红表示出了问题,待处理只是还没轮到,红多了真出问题时没人当回事 |
|
||||
| 普通分类词一律 default 灰 | 每个分类各配一种彩色 | 颜色没有语义就只剩装饰,读者会去猜「蓝比绿高级吗」 |
|
||||
|
||||
### 3.2 交互型
|
||||
|
||||
| 型 | 能做什么 | 用在哪 |
|
||||
|---|---|---|
|
||||
| **展示型**(默认) | 只看,不响应鼠标 | 状态、类型、分类 |
|
||||
| **可关闭** `closable` | 右侧 × 移除自己 | 已选项、筛选条件、已上传文件 |
|
||||
| **可选中** `checkable` | 点一下切选中 / 未选中,可多选 | 筛选面板、兴趣 / 标签挑选 |
|
||||
|
||||
- 可关闭标签:**点 × 立即移除,不弹确认**——移除已选项本来就可逆(再选一次就回来了)。真不可逆的(删除标签定义)用按钮走二次确认。
|
||||
- 可选中标签固定 `default` 灰底起步,选中后变品牌浅底 + 品牌字(§6);**不与语义色组合**——选中态已经占用了颜色这一层信号。
|
||||
- 可关闭标签可以配语义色,但已选项、筛选条件这类场景 **一律 default 灰**:它们是用户自己挑的,不需要颜色再说一遍。
|
||||
|
||||
## 4. 尺寸 Size
|
||||
|
||||
两档,medium 是默认。标签是贴在别人身上的附属物,**比它所在控件阶梯小一档**。
|
||||
|
||||
| size | 高度 | 字号 / 行高 | 左右内边距 | icon | 什么时候用 |
|
||||
|---|---|---|---|---|---|
|
||||
| `small` | 20px | 12 / 20 | 6px | 12px | 表格单元格、列表行内、输入框内已选项 |
|
||||
| `medium`(**默认**) | 24px | 12 / 20 | 8px | 14px | 卡片、详情页头、筛选面板 |
|
||||
|
||||
- 字重一律 400;选中态也不加粗,加粗会让标签变宽跳位。
|
||||
- 同一组标签用同一档:一行里 20 和 24 混着放,参差比什么都显眼。
|
||||
- 32px 输入框内的已选项用 small(20px):上下各留 6px 才不顶边。
|
||||
|
||||
## 5. 内容形态
|
||||
|
||||
- **文案 2~6 个字**,名词或状态词,不带标点、不带动词——「已完成」不是「完成!」,「审批中」不是「正在审批」。写法跟《文案规范》。
|
||||
- 默认不限宽、不截断。业务传 `maxWidth` 时**省略号截断 + Tooltip 显示全文**(同《Tooltip 规范》的溢出口径);固定宽度的列(表格)才需要这么做。
|
||||
- **前缀状态点**(`dot`,2026-08-28 由徽标规范归口过来):实心圆,尺寸随档 **4 / 6px**,与文字间距 4px,**颜色跟文字色走**(`currentColor`)——选定语义色后点和字自动同色,不做第二次颜色决策。列表行用 small 档。五态沿用 §3.1 的语义色:
|
||||
|
||||
| 状态 | color | 例 |
|
||||
|---|---|---|
|
||||
| 无语义 / 未开始 | `default` | 未启动、草稿、排队中 |
|
||||
| 进行中 | `brand` | 运行中、解析中 |
|
||||
| 成功 | `success` | 在线、已完成 |
|
||||
| 待办 / 将至 | `warning` | 待处理、即将到期 |
|
||||
| 失败 / 停止 | `danger` | 已停止、失败、超时 |
|
||||
|
||||
- 状态点**不做呼吸 / 波纹动画**:一列几十个点一起闪,什么都看不清。若「运行中」确实需要动态感,归口未来的动效规范。
|
||||
- **前缀 icon**:尺寸随档 12 / 14px,与文字间距 4px,颜色跟文字色走(`currentColor`);同一组标签要么都带 icon 要么都不带。
|
||||
- **前缀头像**:圆形 14 / 16px,左内边距收到 4px 让头像贴边,标签圆角改 **full 胶囊**——头像是圆的,方角包圆像漏了一角。头像标签只用 `default` 灰底。
|
||||
- **关闭按钮**:× 图标 12px(`Outlined.Close`),与文字间距 4px,**右内边距不收窄、保持档位本身的 8 / 6px**(2026-08-28 改:原定收到 4px,实际看下来 × 像是要掉出标签——头像自带一块实心圆能撑住边缘,一个描边图标撑不住);默认 text-3,悬停 text-1;热区是标签全高。
|
||||
- 前缀位只有一个:**状态点 / icon / 头像三选一**,优先级 头像 > 状态点 > icon。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 「已完成」「知识库」 | 「已经完成了」「知识库相关内容」 | 标签是词不是句,长了就成了第二行正文 |
|
||||
| 表格里定宽列截断 + Tooltip | 标签换行撑高表格行 | 一个长标签把整行撑成两行高,表格节奏全乱 |
|
||||
|
||||
## 6. 状态 State
|
||||
|
||||
展示型标签只有默认和禁用两态,不响应悬停——它不可点,亮起来只会骗人去点。
|
||||
|
||||
| 状态 | 展示型 | 可关闭 | 可选中 |
|
||||
|---|---|---|---|
|
||||
| 默认 default | 语义色底 + 字(§3.1) | 同左 | fill-2 底 + text-2 字 |
|
||||
| 悬停 hover | 无 | 仅 × 变 text-1,标签本体不变 | fill-3 底 |
|
||||
| 选中 checked | — | — | 品牌 7% 透明底 + 品牌字(同列表选中态,《色彩规范》§1.2) |
|
||||
| 选中悬停 | — | — | 品牌 10% 透明底 |
|
||||
| 禁用 disabled | fill-1 底 + text-4 字 | 同左,× 同 text-4、不可点 | 同左,选中的禁用项保持品牌字但降为 text-4 底纹 |
|
||||
| 键盘焦点 focus-visible | — | × 外显示 2px gray-2 聚焦环(同输入框口径,见 [组件-Input输入框.md](组件-Input输入框.md) §5) | 标签外显示同一聚焦环 |
|
||||
|
||||
- 可选中标签用鼠标或键盘 Space / Enter 切换,切换即生效、不需要确认。
|
||||
- 没有 loading 态:标签不发请求;异步移除由业务在 × 点击后自己处理,标签先移除、失败再加回来并用 Toast 说明。
|
||||
|
||||
| ✅ 推荐 | ❌ 不推荐 | 原因 |
|
||||
|---|---|---|
|
||||
| 展示型标签鼠标移上去没反应 | 展示型标签加 hover 变色 | 变色暗示「可点」,点了没反应就是骗了一次 |
|
||||
| 已选项用灰底可关闭标签 | 已选项用品牌色可关闭标签 | 品牌色管选中态和主操作,一排品牌色 chip 会抢走真正的主按钮 |
|
||||
|
||||
## 7. 移动端适配
|
||||
|
||||
跨组件通则见 [基础-多端适配原则.md](基础-多端适配原则.md),这里只写标签自己的细则。
|
||||
|
||||
| 项 | 触屏 / 窄屏规则 |
|
||||
|---|---|
|
||||
| 尺寸 | 不变;只有可选中标签在触屏上**统一用 medium**,20px 太难点 |
|
||||
| 悬停 hover | 触屏没有悬停,关掉 hover 态 |
|
||||
| 热区 | 可关闭的 × 与可选中标签用透明热区扩到 ≥44px(同按钮口径,WCAG / Apple HIG 推荐值),视觉尺寸不变;相邻标签热区允许重叠,以视觉中心就近判定 |
|
||||
| 换行 | 标签组正常换行,不横向滚动——筛选条件被藏进滚动区就等于没显示 |
|
||||
|
||||
<!-- site-hide -->
|
||||
## 给实现窗口
|
||||
|
||||
1. 组件位置 `packages/ui/src/components/Tag/`(2026-08-28 已落地),`size: small | medium` × `color: default | brand | success | warning | danger | approving | skill`(后两个即 §3.1 的固定例外,独立取值而非 `brand` 的别名——`brand` 会跟着主题变绿,它们不会),行为用 props:`closable` / `onClose` / `closeLabel`、`checkable` / `checked` / `defaultChecked` / `onChange`、`dot` / `icon` / `avatar`、`maxWidth`、`disabled`。状态点写成 `bg-current`(跟字同色,禁用时自动跟着降到 text-4),`size-1` / `size-1.5`,`aria-hidden`——旁边的词才是内容。可选中标签渲染为 `<button aria-pressed>`,可关闭的 × 是独立 `<button aria-label="移除 {label}">`(文案由调用方传,组件库不含文案),展示型渲染为 `<span>`。**`closable` 在 `checkable` 上不生效**:button 套 button 是非法 HTML,而「挑中它 / 丢掉它」本来就是同一个问题的两种答法。
|
||||
2. 颜色全走 token:default `bg-fill-2 text-text-2`;brand `bg-blue-50 text-blue-500`(随蓝⇄绿主题,深色由品牌深色阶自动翻转);success / warning / danger 用 `--success-tint` / `--success` 等功能色四态 token(深色 tint 已是深而饱和的色底,无需覆盖);固定例外「审批中蓝」「技能紫」已在 `tokens.css` 落成独立 token(`--tag-approving` / `--tag-approving-tint`、`--tag-skill` / `--tag-skill-tint`,明暗两套,**值写死、不引用 `--brand-*`**,`.theme-green` 不覆盖它们),类名 `bg-tag-approving-tint text-tag-approving`,不写裸 hex。选中态 `bg-blue-500/[0.07]`、选中悬停 `bg-blue-500/10`;禁用 `bg-fill-1 text-text-4`,选中的禁用项 `bg-text-4/20 text-blue-500`。
|
||||
3. 圆角 `rounded-sm`(4px);带 `avatar` 时切 `rounded-full`。高度 `h-5` / `h-6`,字号 `text-caption`(12/20),`font-normal`;padding `px-1.5` / `px-2`,**只有头像那一侧**收到 `pl-1`,× 那一侧保持档位 padding。前缀 icon 的档位类要写成 `[&>svg]`(直接子元素)而不是 `[&_svg]`:× 嵌在自己的 button 里,后代选择器会以 (0,2,0) 压过 × 自己的 `size-3`,把它顶成 14px。
|
||||
4. 截断:`maxWidth` 生效时 `truncate` + 包一层 Tooltip(复用组件库 Tooltip,仅在实际溢出时才挂——用 `scrollWidth > clientWidth` 判断,避免每个标签都挂一个 Tooltip 监听)。
|
||||
5. 触屏:可交互标签复用 `btn-touch-hit` 热区口径;hover 类照常写普通 `hover:`,禁止自造前缀(原因见 [组件-Button按钮.md](组件-Button按钮.md) 给实现窗口第 6 条)。
|
||||
6. 迁移映射(本次只读扫描,2026-08-28,范围 `client/src`,排除 node_modules):
|
||||
- `components/ui/Badge.tsx`(shadcn,5 款 variant)→ 归口本组件:`default` / `secondary` / `gray` → `color="default"`;`destructive` → `color="danger"`;`outline` 款废弃(本规范无描边款),使用处改 `default`。使用处:`components/ui/MultiSelect.tsx`(已选项,2 处,含 `bg-primary/20 text-primary` 品牌色 chip → 改 default 灰 + `closable`)、`pages/appChat/components/MessageSource.tsx`(1 处,可点击的「来源」→ 不是标签,改成文字按钮)。
|
||||
- `components/ui/Tag.tsx`(绿描边 chip,`rounded-3xl border-2 border-green-600`):0 处引用,直接删除。**待迁**。
|
||||
- **已迁(2026-08-28)**:知识空间文件状态标签三处 → `<Tag size="small" dot color="default | danger | approving">`。`SpaceDetail/FileListRow.tsx` 的 `StatusBadge`(桌面列表行)、`SpaceDetail/FileCard.tsx` 的 `renderStatusOverlayTag`(卡片视图的图标浮层 + H5 列表行 inline 款)与同函数里的「上传中」占位胶囊。**本款的现实参照就是它们**(20px、圆角 4、12/20 字、4px 点 + 词,点色恒等于字色),画法一致,三处按本规范收敛:左右内边距 8 → 6px(§4 的 small 档取值);三套裸 hex 换成 token(`#f2f4f7/#6b7785` → fill-2/text-2,`#fff2f0/#f53f3f` → danger-tint/danger);**审批中由 `bg-blue-50` 改走固定例外色 `approving`**——原写法在绿主题下会跟着变绿,正是 §3.1 固定例外要防的那件事。状态 → 语义色的映射表留在各自页面,只把三值联合类型 `KnowledgeStatusTone` 提到 `pages/knowledge/knowledgeUtils.ts` 共用。
|
||||
- **已迁(2026-08-28)**:审批中心的状态标签 → `StatusBadge` 内部改渲染 `<Tag>`(`components/approval/approvalPresentation.tsx`),**三处一律用带状态点的标签**(设计师拍板):列表行(`ApprovalPane`,待我处理 / 已处理 / 我的申请)与审批进度的节点标签用 small,详情页头用 medium(单个对象的状态要醒目,§4 的档位)。同一个状态在三处长得一样,所以 `StatusBadge` 不开 `dot` 开关——它不是调用点的选择。唯一要留意的是节点标签那处:时间轴左侧本来就有一颗节点圆点,同一行会出现两颗(一颗是节点进度、一颗在标签里),验收时看一眼。语义色映射:`pending` → `approving`(原 `#e8f3ff/#165dff`,正是这条固定例外要保住的蓝)、`approved`/`executed` → success、`rejected` → danger、`exception`/`execute_failed` → warning、`cancelled`/`skipped`/`withdrawn` → default。同排的「已撤销授权」灰胶囊一并换成 default 标签。画法收敛三处:圆角 full → 4px、字重 500 → 400、节点标签字号 11 → 12px。
|
||||
- 未迁:`SpaceDetail/VersionHistorySheet.tsx` 的版本状态胶囊(同样的裸 hex 色对,但**不带点**,且配置里的 `dot` 字段是死的),下一个迁移窗口一并收。
|
||||
- 全站散落的手拼状态标签(`rounded-* bg-[#xxx] text-[#xxx]` 的 span)未扫描,迁移窗口用「`bg-[#` + 状态词」grep 后补附录。
|
||||
- **组件名归位**:迁库后 `Badge` 这个导出名让给 [组件-Badge徽标.md](组件-Badge徽标.md) 的徽标组件,本组件导出 `Tag`;client 侧 `~/components/ui/Badge` re-export 在迁移完成前保留为 `Tag` 的别名,迁完删。
|
||||
7. 站点接线(元规范 §5):本文 + `components/tag.mdx` demo 页均已注册进 `rspress.config.ts` 侧栏,front matter `component: Tag` 已写(组件已进库);00-总纲 §四 与 01-设计规范 §0 索引行随建档更新。
|
||||
|
||||
## 待决策清单
|
||||
|
||||
- 描边款 outline、实底款 solid、灰底 neutral 款本期均不做——设计师拍板只留浅底深字;真长出「一屏几十个状态标签浅底糊成一片」的场景再议描边款。
|
||||
- 可选中标签的选中底取品牌 7% 透明底(与列表 / 菜单选中态同源),未在真实筛选面板上验收;若与页面选中态互相干扰,备选是品牌最浅档实色(brand-50)。
|
||||
- 标签组的横向间距 8px 为本次建档取值(Arco 8px、antd 8px),待验收。间距由使用方的容器给(组件不带外边距),组件库不代管标签组布局。
|
||||
- 「技能紫」的深色取值 `#33004D` 底 / `#9B5DE0` 字为落地时按功能色同一套深色算法推的,紫色不在 Arco 官方深色阶里,**待设计师在深色模式下验收**;「审批中蓝」深色直接锁定深色品牌蓝的值(`#000D4D` / `#3C7EFF`),不随 `.theme-green` 变。
|
||||
- 「一个对象最多 3 个标签」是产品口径,不是组件硬限制;组件不截断数量。
|
||||
|
||||
## 改动记录
|
||||
|
||||
| 日期 | 改了什么 | 提交 |
|
||||
|---|---|---|
|
||||
| 2026-08-28 | **接手状态点**:徽标规范的「点 + 一个词」归口本文,成为 §5 的 `dot` 前缀款(点随档 4 / 6px、`currentColor` 跟字同色、五态沿用 §3.1 语义色、不做动画);前缀位改为「状态点 / icon / 头像三选一」;§1 判别表与迁移映射同步,现实参照是知识空间文件列表的 `StatusBadge` | 待 committer 窗口提交 |
|
||||
| 2026-08-28 | 落地后设计师验收两处修正:可关闭标签的**右内边距不再收到 4px**,保持档位的 8 / 6px(§5);× 图标锁死 12px(前缀 icon 的档位类改用直接子元素选择器,原后代选择器把 medium 档的 × 顶成了 14px) | 待 committer 窗口提交 |
|
||||
| 2026-08-28 | 组件落地 `packages/ui/src/components/Tag/`:五语义色 + `approving` / `skill` 两个固定例外色(新增 `--tag-approving-*` / `--tag-skill-*` 明暗 token 与 tailwind 接线);展示 / 可关闭 / 可选中三型、两档尺寸、icon / 头像、`maxWidth` 溢出才挂 Tooltip、禁用五态全部按本文实现;`closable` 与 `checkable` 互斥(见给实现窗口 1);demo 页 `components/tag.mdx` 上线。client 侧 `ui/Badge.tsx`、`ui/Tag.tsx` 的替换仍待迁 | 待 committer 窗口提交 |
|
||||
| 2026-08-28 | 建档 v1:只留浅底深字一款(描边 / 实底 / 灰底款不做);语义色五档 default / brand / success / warning / danger + 审批中蓝、技能紫两处固定例外归《色彩规范》§4;交互型三种(展示 / 可关闭 / 可选中),可选中固定灰底起步、选中品牌 7% 底;尺寸两档 20 / 24、字 12 / 400、圆角 4(头像款 full);状态表分交互型三列;移动端可选中统一 medium + ≥44px 热区。与 [组件-Badge徽标.md](组件-Badge徽标.md) 同日建档,判别表归其 §1 | 待 committer 窗口提交 |
|
||||
@@ -0,0 +1,187 @@
|
||||
import * as React from 'react';
|
||||
import cn from '../../utils/cn';
|
||||
|
||||
/**
|
||||
* Badge — design-system base component (组件-Badge徽标.md v1).
|
||||
*
|
||||
* A badge answers two questions only: IS there anything new, and HOW MANY
|
||||
* (§1). It carries no content of its own, it is never a button, and a red dot
|
||||
* that never clears is the same as no red dot at all. What says「what this
|
||||
* is」is a Tag (判别表 in §1 of the same doc).
|
||||
*
|
||||
* Four forms, one size — the badge does not grow with its host (§3):
|
||||
* • `count` on a host — 16px pill in the host's top-right corner (§2)
|
||||
* • `dot` on a host — 6px circle, same corner (§2)
|
||||
* • `count` with no host — the standalone number after a tab / menu label
|
||||
* • `dot` with no host — the unread marker in its own column of a list row
|
||||
*
|
||||
* Reporting a STATE (「运行中」/「失败」) is not one of them: that is a dot
|
||||
* plus a word, which is a word about the object — a Tag with `dot` (§1, moved
|
||||
* there 2026-08-28).
|
||||
*
|
||||
* Baked in per spec: two semantic colors — `danger` solid (「needs you」, the
|
||||
* corner default) and `brand` 5% tint (「just how many」, the standalone
|
||||
* default) — and they are not interchangeable (§2); the number is NEVER
|
||||
* abbreviated (no 99+) and the pill widens with the digits (§3); tabular
|
||||
* figures so 9→10 does not jitter (§3); a 1px page-colored ring so a corner
|
||||
* badge stays legible on a colored icon or an avatar (§3); 0 renders nothing
|
||||
* unless the caller opts in with `showZero` (§4); no hover, no focus, no
|
||||
* click, no enter/leave transition — the click belongs to the host (§5).
|
||||
*
|
||||
* The corner form is decorative to a screen reader (`aria-hidden`): the host
|
||||
* carries the merged description (`aria-label="通知,3 条未读"`, §落地 4).
|
||||
*/
|
||||
|
||||
/** §2 — semantic, not positional: red = act on it, brand tint = just a count. */
|
||||
export type BadgeColor = 'danger' | 'brand';
|
||||
|
||||
/**
|
||||
* §2 — `danger` is a solid fill with white text (the loudest thing on the
|
||||
* page, reserved for「waiting on you」); `brand` is the 5% brand tint drawn
|
||||
* by Tabs today, so it follows the blue⇄green theme and the dark brand ramp
|
||||
* with no `dark:` override.
|
||||
*/
|
||||
const COLOR: Record<BadgeColor, string> = {
|
||||
danger: 'bg-danger text-white',
|
||||
brand: 'bg-blue-500/5 text-blue-500',
|
||||
};
|
||||
|
||||
/** §5 — a dead entry's badge greys out with it; nagging about something the
|
||||
* user cannot act on is worse than saying nothing. */
|
||||
const DISABLED_FILL = 'bg-text-4 text-white';
|
||||
|
||||
|
||||
/** §3 — one size, 16px tall, min 16px wide, full radius (a single digit is a
|
||||
* circle, two digits stretch it into a pill), the
|
||||
* caption-sm rung the type scale keeps for exactly this, weight 500, tabular
|
||||
* figures. `leading-none` because the 18px line box of caption-sm would
|
||||
* otherwise fight the 16px height. */
|
||||
const PILL =
|
||||
'inline-flex h-4 min-w-4 items-center justify-center px-1 text-caption-sm font-medium leading-none tabular-nums';
|
||||
|
||||
/** §3 — 6px circle. */
|
||||
const DOT = 'inline-block h-1.5 w-1.5 shrink-0 rounded-full';
|
||||
|
||||
export interface BadgeProps {
|
||||
/**
|
||||
* The host the badge rides on — an icon, an avatar, a nav entry. WITH a
|
||||
* host the badge is a corner badge; WITHOUT one it is the standalone number.
|
||||
* There is no third mode, so there is nothing to declare: the shape of the
|
||||
* call site decides.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* How many. Rendered as given — never abbreviated, never capped (§3): a
|
||||
* four-digit unread count is a notification-policy problem, not a badge
|
||||
* problem. 0 and `undefined` render nothing (§4), so a caller can pass a
|
||||
* raw count straight through without guarding it.
|
||||
*/
|
||||
count?: number;
|
||||
/**
|
||||
* §2 — 「there is something new」 without a number. On a host it rides the
|
||||
* top-right corner; with no host it is the unread marker a list row keeps in
|
||||
* its own column. Ignored when `count` shows.
|
||||
*/
|
||||
dot?: boolean;
|
||||
/**
|
||||
* §2 — defaults by position, because the two forms mean different things:
|
||||
* a corner badge is `danger` (act on it), a standalone number is `brand`
|
||||
* (just how many). Override when the host component has its own palette —
|
||||
* a neutral Tabs row passes its own classes via `className`.
|
||||
*/
|
||||
color?: BadgeColor;
|
||||
/** §4 — render `0` instead of nothing. Only for「0 is itself the answer」
|
||||
* (a filter result count); an unread counter must stay silent at zero. */
|
||||
showZero?: boolean;
|
||||
/** §5 — the host is round (avatar, circular icon button), so pull the badge
|
||||
* in until its center sits ON the circumference instead of off in space. */
|
||||
circle?: boolean;
|
||||
/** §5 — `[x, y]` px nudge of the corner badge, for hosts whose artwork does
|
||||
* not fill its box. Positive x moves right, positive y moves down. */
|
||||
offset?: [number, number];
|
||||
/** §5 — grey out with a disabled host. */
|
||||
disabled?: boolean;
|
||||
/** Classes for the OUTER element: the wrapper in corner mode, the badge
|
||||
* itself otherwise. */
|
||||
className?: string;
|
||||
/** Classes for the badge itself, in every mode. */
|
||||
badgeClassName?: string;
|
||||
}
|
||||
|
||||
function Badge({
|
||||
children,
|
||||
count,
|
||||
dot = false,
|
||||
color,
|
||||
showZero = false,
|
||||
circle = false,
|
||||
offset,
|
||||
disabled = false,
|
||||
className,
|
||||
badgeClassName,
|
||||
}: BadgeProps) {
|
||||
const hasHost = children !== undefined;
|
||||
// §4 — 0 is silence unless the caller says 0 is the answer.
|
||||
const showCount = count !== undefined && (count > 0 || (showZero && count === 0));
|
||||
const showDot = !showCount && dot;
|
||||
// §2 — the default is per FORM, not per position: a dot always means
|
||||
// 「something new」 and is therefore always red; only the standalone NUMBER
|
||||
// defaults to the quiet brand tint, because that one is just reporting how
|
||||
// many. (A brand-tinted dot would be a 5% wash 6px across — invisible.)
|
||||
const fill = disabled ? DISABLED_FILL : COLOR[color ?? (hasHost || showDot ? 'danger' : 'brand')];
|
||||
|
||||
if (!hasHost) {
|
||||
// §3 (2026-08-28) — the standalone number is the SAME pill as the corner
|
||||
// one: full radius, so a single digit is a circle. It shipped at the 6px
|
||||
// radius Tabs had been drawing; the designer settled the open question on
|
||||
// the settings nav — one number badge, one radius.
|
||||
if (showCount) {
|
||||
return <span className={cn(PILL, 'rounded-full', fill, className, badgeClassName)}>{count}</span>;
|
||||
}
|
||||
// §2 — the same red dot, in its own column: a list row whose unread marker
|
||||
// has nowhere to hang (no icon, no avatar) still needs one, and the row is
|
||||
// the host in every sense that matters. No page-colored ring here — there
|
||||
// is nothing underneath it to separate from.
|
||||
if (showDot) {
|
||||
return <span aria-hidden className={cn(DOT, fill, className, badgeClassName)} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [dx, dy] = offset ?? [0, 0];
|
||||
// §5 — the badge's CENTER lands on the host's top-right corner; on a round
|
||||
// host it is pulled in by 14.6% of the box (cos45°/2) so the center lands on
|
||||
// the circumference instead. `transform` owns both halves of the placement,
|
||||
// so an `offset` cannot fight a translate utility.
|
||||
const inset = circle ? '14.6%' : '0';
|
||||
|
||||
return (
|
||||
<span className={cn('relative inline-flex', className)}>
|
||||
{children}
|
||||
{(showCount || showDot) && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute',
|
||||
// §3 — a 1px ring in the page color separates the badge from a
|
||||
// colored icon or a photo underneath; it follows the theme, so
|
||||
// dark mode needs no override.
|
||||
'ring-1 ring-bg-page',
|
||||
showCount ? cn(PILL, 'rounded-full') : DOT,
|
||||
fill,
|
||||
badgeClassName,
|
||||
)}
|
||||
style={{
|
||||
top: inset,
|
||||
right: inset,
|
||||
transform: `translate(calc(50% + ${dx}px), calc(-50% + ${dy}px))`,
|
||||
}}
|
||||
>
|
||||
{showCount ? count : null}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Badge } from './Badge';
|
||||
export type { BadgeProps, BadgeColor } from './Badge';
|
||||
@@ -0,0 +1,306 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import cn from '../../utils/cn';
|
||||
import { Tooltip } from '../Tooltip/Tooltip';
|
||||
|
||||
/**
|
||||
* Breadcrumb — where the current page sits in the structure (组件-Breadcrumb面包屑.md v1).
|
||||
*
|
||||
* The page hands over the FULL chain, root first and current page last; every
|
||||
* rule the spec pins down lives here and a business page never restates it:
|
||||
* the 96px cap on parent names with its truncation tooltip (§4), the collapse
|
||||
* into an ellipsis menu past four levels (§5), the narrow-screen thresholds
|
||||
* (§6), and the `nav` / `aria-current` wiring (§7). A single-level chain
|
||||
* renders nothing at all (§1) — an empty breadcrumb row costs a line of page
|
||||
* and gives back no information.
|
||||
*
|
||||
* Not this component: "step 3 of 5" (that is a step bar, which only looks
|
||||
* alike), and browsing history — the chain is structural, not where you came
|
||||
* from.
|
||||
*/
|
||||
export interface BreadcrumbItem {
|
||||
/** React key + menu identity. Falls back to the index when omitted. */
|
||||
key?: string;
|
||||
/** Displayed name. Truncation is the component's job, not the caller's. */
|
||||
title: string;
|
||||
/** Called when the level is activated. Every level must open a real page (§2). */
|
||||
onClick?: () => void;
|
||||
/** Renders the level as a real link (middle-click, "open in new tab"). */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface BreadcrumbProps {
|
||||
/** Full chain, root first, current page last. Fewer than 2 renders nothing. */
|
||||
items: BreadcrumbItem[];
|
||||
/** Collapse once the chain is LONGER than this (§5.1). */
|
||||
maxItems?: number;
|
||||
/** How many trailing levels stay visible, current page included (§5.1). */
|
||||
itemsAfterCollapse?: number;
|
||||
/**
|
||||
* Tooltip + `aria-label` for the ellipsis trigger, given the number of levels
|
||||
* it hides — "点击展开省略的 N 层" (§5.2). A function because the count is the
|
||||
* component's to know and the wording is the caller's: this library holds no
|
||||
* i18n keys.
|
||||
*/
|
||||
expandLabel: (hiddenCount: number) => string;
|
||||
/** `aria-label` of the wrapping `nav` (§7). */
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** §4 — 96px ≈ 8 CJK characters at 12px. Capped by width, never by counting characters. */
|
||||
const PARENT_MAX_WIDTH = 'max-w-[96px]';
|
||||
|
||||
/** §5.1 — desktop: collapse past 4 levels, keep the last 2 (current page included). */
|
||||
const DEFAULT_MAX_ITEMS = 4;
|
||||
const DEFAULT_ITEMS_AFTER_COLLAPSE = 2;
|
||||
|
||||
/** §6 — narrow: collapse past 3 levels, leaving `root · … · current`. */
|
||||
const NARROW_MAX_ITEMS = 3;
|
||||
const NARROW_ITEMS_AFTER_COLLAPSE = 1;
|
||||
|
||||
/** §6 — the shared 576px breakpoint (基础-多端适配原则.md), not one of our own. */
|
||||
const NARROW_QUERY = '(max-width: 575.98px)';
|
||||
|
||||
function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
const list = window.matchMedia(query);
|
||||
const sync = () => setMatches(list.matches);
|
||||
sync();
|
||||
list.addEventListener('change', sync);
|
||||
return () => list.removeEventListener('change', sync);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** §3 — an icon, never a text `>`: `>` is a maths glyph that sits low next to CJK. */
|
||||
function Separator() {
|
||||
return <Outlined.Right className="size-4 shrink-0 text-text-4" aria-hidden />;
|
||||
}
|
||||
|
||||
/** True once the element's own content is wider than the box drawn for it. */
|
||||
function isClipped(el: HTMLElement | null): boolean {
|
||||
return el ? el.scrollWidth > el.clientWidth : false;
|
||||
}
|
||||
|
||||
interface CrumbProps {
|
||||
item: BreadcrumbItem;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A clickable level. §3/§4: hint grey, brand on hover, capped at 96px, and the
|
||||
* full-name tooltip appears ONLY when the name really is clipped — measured on
|
||||
* pointer-enter, so a chain of short names mounts no tooltips at all.
|
||||
*/
|
||||
function Crumb({ item, className }: CrumbProps) {
|
||||
const [clipped, setClipped] = React.useState(false);
|
||||
const handleEnter = (event: React.PointerEvent<HTMLElement>) => {
|
||||
setClipped(isClipped(event.currentTarget));
|
||||
};
|
||||
|
||||
const shared = {
|
||||
onPointerEnter: handleEnter,
|
||||
className: cn(
|
||||
'block shrink-0 truncate text-text-3 outline-none transition-colors',
|
||||
'hover:text-blue-600 focus-visible:ring-2 focus-visible:ring-blue-600/40',
|
||||
PARENT_MAX_WIDTH,
|
||||
className,
|
||||
),
|
||||
};
|
||||
|
||||
const crumb = item.href ? (
|
||||
<a href={item.href} onClick={item.onClick} {...shared}>
|
||||
{item.title}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" onClick={item.onClick} {...shared}>
|
||||
{item.title}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={item.title} side="bottom" disabled={!clipped}>
|
||||
{crumb}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the collapsed-levels menu. §5.3: the full name, capped only by the
|
||||
* menu's own 240px — the user opened the menu to READ the name, so eliding it
|
||||
* a second time would lose the information twice. The tooltip goes to the right
|
||||
* of the panel so it never covers the rows below.
|
||||
*/
|
||||
function MenuItemName({ title }: { title: string }) {
|
||||
const [clipped, setClipped] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip content={title} side="right" disabled={!clipped}>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate"
|
||||
onPointerEnter={(event) => setClipped(isClipped(event.currentTarget))}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function Breadcrumb({
|
||||
items,
|
||||
maxItems = DEFAULT_MAX_ITEMS,
|
||||
itemsAfterCollapse = DEFAULT_ITEMS_AFTER_COLLAPSE,
|
||||
expandLabel,
|
||||
ariaLabel = 'breadcrumb',
|
||||
className,
|
||||
}: BreadcrumbProps) {
|
||||
const narrow = useMediaQuery(NARROW_QUERY);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
// The ellipsis tooltip is driven entirely by pointer enter/leave: Radix would
|
||||
// otherwise re-open it from the focus it hands back to the trigger when the
|
||||
// menu closes, and leave it standing there.
|
||||
const [tipOpen, setTipOpen] = React.useState(false);
|
||||
const [currentClipped, setCurrentClipped] = React.useState(false);
|
||||
|
||||
// §6 — a narrow viewport only ever tightens what the caller asked for.
|
||||
const effectiveMax = narrow ? Math.min(maxItems, NARROW_MAX_ITEMS) : maxItems;
|
||||
const effectiveAfter = narrow
|
||||
? Math.min(itemsAfterCollapse, NARROW_ITEMS_AFTER_COLLAPSE)
|
||||
: itemsAfterCollapse;
|
||||
|
||||
// §1 — one level is the page title again, not a path.
|
||||
if (items.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = items[items.length - 1];
|
||||
// §5.1 — the root and the last `itemsAfterCollapse` levels always stay out;
|
||||
// whatever sits between them goes behind the ellipsis, which is therefore
|
||||
// always the second position.
|
||||
const hidden = items.length > effectiveMax
|
||||
? items.slice(1, Math.max(1, items.length - effectiveAfter))
|
||||
: [];
|
||||
const collapsed = hidden.length > 0;
|
||||
const head = collapsed ? items.slice(0, 1) : items.slice(0, -1);
|
||||
const tail = collapsed ? items.slice(1 + hidden.length, -1) : [];
|
||||
const keyOf = (item: BreadcrumbItem, index: number) => item.key ?? String(index);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
// §3 — 12/24, 2px either side of each separator; §4 — never wraps.
|
||||
className={cn(
|
||||
'flex min-w-0 flex-nowrap items-center gap-0.5 text-caption leading-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{head.map((item, index) => (
|
||||
<span key={keyOf(item, index)} className="flex shrink-0 items-center gap-0.5">
|
||||
<Crumb item={item} />
|
||||
<Separator />
|
||||
</span>
|
||||
))}
|
||||
|
||||
{collapsed && (
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
<DropdownMenuPrimitive.Root
|
||||
open={menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setMenuOpen(open);
|
||||
if (open) {
|
||||
setTipOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* §5.2 — a bare `…` at rest (a permanent chip weighs the row down);
|
||||
the 24×24 container appears on hover and stays while the menu is
|
||||
open. Pointer + tooltip + that container are the three signals
|
||||
that it can be clicked, and none of them is optional. */}
|
||||
<Tooltip
|
||||
content={expandLabel(hidden.length)}
|
||||
side="bottom"
|
||||
open={tipOpen}
|
||||
disabled={!tipOpen || menuOpen}
|
||||
>
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
aria-label={expandLabel(hidden.length)}
|
||||
onPointerEnter={() => setTipOpen(!menuOpen)}
|
||||
onPointerLeave={() => setTipOpen(false)}
|
||||
className={cn(
|
||||
'btn-touch-hit relative flex size-6 shrink-0 cursor-pointer items-center',
|
||||
'justify-center rounded leading-none text-text-3 outline-none transition-colors',
|
||||
'hover:bg-fill-2 data-[state=open]:bg-fill-2',
|
||||
'focus-visible:ring-2 focus-visible:ring-blue-600/40',
|
||||
)}
|
||||
>
|
||||
…
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
{/* §5.3 — one column, one level per row, top = highest ancestor.
|
||||
No separators and no indent: the vertical order already says
|
||||
it, and indenting a ninth level would eat the width. Radix
|
||||
brings Esc / outside-click / arrow keys / aria-expanded. */}
|
||||
<DropdownMenuPrimitive.Content
|
||||
align="center"
|
||||
sideOffset={4}
|
||||
className={cn(
|
||||
'z-popover max-h-80 min-w-[120px] max-w-[240px] overflow-y-auto',
|
||||
'rounded-lg bg-bg-page p-1 shadow-popup',
|
||||
)}
|
||||
>
|
||||
{hidden.map((item, index) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
key={keyOf(item, index + 1)}
|
||||
onSelect={item.onClick}
|
||||
// The whole row is the target, and it wears the same colors
|
||||
// as the crumbs outside: hint grey, brand on hover.
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer select-none items-center rounded px-3',
|
||||
'text-caption text-text-3 outline-none transition-colors',
|
||||
'data-[highlighted]:bg-fill-1 data-[highlighted]:text-blue-600',
|
||||
)}
|
||||
>
|
||||
<MenuItemName title={item.title} />
|
||||
</DropdownMenuPrimitive.Item>
|
||||
))}
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
<Separator />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{tail.map((item, index) => (
|
||||
<span
|
||||
key={keyOf(item, 1 + hidden.length + index)}
|
||||
className="flex shrink-0 items-center gap-0.5"
|
||||
>
|
||||
<Crumb item={item} />
|
||||
<Separator />
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* §2/§3 — the current page: one shade darker ("you are here"), not a
|
||||
link and not hoverable, and free of the 96px cap — only the page
|
||||
width limits it, which is why it still gets the clipping tooltip. */}
|
||||
<Tooltip content={current.title} side="bottom" disabled={!currentClipped}>
|
||||
<span
|
||||
aria-current="page"
|
||||
className="min-w-0 truncate text-text-1"
|
||||
onPointerEnter={(event) => setCurrentClipped(isClipped(event.currentTarget))}
|
||||
>
|
||||
{current.title}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Breadcrumb } from './Breadcrumb';
|
||||
export type { BreadcrumbItem, BreadcrumbProps } from './Breadcrumb';
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import cn from '../../utils/cn';
|
||||
import {
|
||||
CARD_DESCRIPTION,
|
||||
CARD_LABEL,
|
||||
CARD_SHELL,
|
||||
CONTROL_BASE,
|
||||
CONTROL_CHECKED,
|
||||
CONTROL_OFFSET,
|
||||
CONTROL_SIZE,
|
||||
DESCRIPTION,
|
||||
GROUP_LAYOUT,
|
||||
ROW_BASE,
|
||||
ROW_HOVER_CONTROL,
|
||||
ROW_TEXT,
|
||||
type SelectionSize,
|
||||
} from '../Selection/shared';
|
||||
|
||||
/**
|
||||
* Checkbox — design-system base component (组件-Checkbox复选框.md v1).
|
||||
*
|
||||
* Picks SEVERAL from a set (or a single "I agree" tick); the result travels
|
||||
* with the form — a setting that applies the moment it is flipped is a Switch.
|
||||
* Baked in per spec: the 14/16/18 ladder with its fixed 4px radius (§3), the
|
||||
* gray→brand state chain with indeterminate reserved for select-all (§5), the
|
||||
* button's three disabled tokens + gray label (§5's three signals), the
|
||||
* keyboard-only gray focus ring, and the ≥44px touch row (§6). Radix keeps the
|
||||
* a11y contract (`aria-checked="mixed"` for indeterminate).
|
||||
*/
|
||||
|
||||
/** §5 — indeterminate paints exactly like checked; only the mark differs. */
|
||||
const CONTROL_INDETERMINATE =
|
||||
'data-[state=indeterminate]:border-blue-500 data-[state=indeterminate]:bg-blue-500 data-[state=indeterminate]:text-white';
|
||||
|
||||
/** §5 — the indeterminate bar, scaled 6/8/10 with the box. */
|
||||
const BAR_WIDTH: Record<SelectionSize, string> = {
|
||||
small: 'w-1.5',
|
||||
medium: 'w-2',
|
||||
large: 'w-2.5',
|
||||
};
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>, 'asChild'> {
|
||||
/** §3 — control ladder; medium is the default. */
|
||||
size?: SelectionSize;
|
||||
/** Option text — part of the hot zone (§4: 点字即点框). */
|
||||
label?: React.ReactNode;
|
||||
/** One secondary line under the label, hint color (§4). */
|
||||
description?: React.ReactNode;
|
||||
/** Class for the wrapping <label> when `label`/`description` is present. */
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
/** The box itself — shared by the basic row and the card form. */
|
||||
function renderControl(
|
||||
size: SelectionSize,
|
||||
className: string | undefined,
|
||||
inRow: boolean,
|
||||
props: Omit<CheckboxProps, 'size' | 'label' | 'description' | 'wrapperClassName' | 'className'>,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group/box rounded',
|
||||
CONTROL_BASE,
|
||||
CONTROL_CHECKED,
|
||||
CONTROL_INDETERMINATE,
|
||||
CONTROL_SIZE[size],
|
||||
inRow && CONTROL_OFFSET[size],
|
||||
inRow && ROW_HOVER_CONTROL,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* forceMount + scale keep the mark in the DOM so it can animate in AND
|
||||
out (200ms, same clock as the Switch thumb / Radio dot) — radix would
|
||||
otherwise unmount it and the check pops. */}
|
||||
<CheckboxPrimitive.Indicator
|
||||
forceMount
|
||||
className="flex scale-0 items-center justify-center text-current transition-transform duration-200 data-[state=checked]:scale-100 data-[state=indeterminate]:scale-100"
|
||||
>
|
||||
{/* strokeWidth 3 on the 24-viewBox icon = 1.5px at the medium box's
|
||||
12px display size (designer-tuned 2026-08-25: 2px read too heavy,
|
||||
the default 2 → 1px hairline too thin). The indeterminate bar below
|
||||
is 1.5px to match. */}
|
||||
<Outlined.Check strokeWidth={3} className="group-data-[state=indeterminate]/box:hidden" />
|
||||
{/* Not an icon — the §5 indeterminate bar, drawn in currentColor so the
|
||||
disabled override grays it along with the check. */}
|
||||
<span
|
||||
className={cn(
|
||||
'hidden h-[1.5px] rounded-full bg-current group-data-[state=indeterminate]/box:block',
|
||||
BAR_WIDTH[size],
|
||||
)}
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ size = 'medium', label, description, wrapperClassName, className, ...props }, ref) => {
|
||||
const hasRow = label !== undefined || description !== undefined;
|
||||
const control = renderControl(size, className, hasRow, props, ref);
|
||||
if (!hasRow) return control;
|
||||
return (
|
||||
<label className={cn(ROW_BASE, ROW_TEXT[size], wrapperClassName)}>
|
||||
{control}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
{label !== undefined && <span>{label}</span>}
|
||||
{description !== undefined && <span className={DESCRIPTION}>{description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
export interface CheckboxGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** §2 — horizontal 16px gaps / vertical 8px; narrow screens always stack. */
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout shell for a set of parallel options (§2). State stays on the
|
||||
* individual checkboxes — a group is spacing plus a `group` role, nothing more
|
||||
* (validation copy is rendered by the caller, same as the Input family).
|
||||
*/
|
||||
function CheckboxGroup({ direction = 'horizontal', className, ...props }: CheckboxGroupProps) {
|
||||
return <div role="group" className={cn(GROUP_LAYOUT[direction], className)} {...props} />;
|
||||
}
|
||||
|
||||
export interface CheckboxCardProps extends Omit<CheckboxProps, 'wrapperClassName'> {
|
||||
/** Card title — medium weight, body color (§2). */
|
||||
label: React.ReactNode;
|
||||
/** Class for the card shell (the <label>). */
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card form (§2) — the basic box in a card shell: whole card clickable,
|
||||
* selection tints the card while the border stays gray, radius 12 / min-height
|
||||
* 48. Options with a title + description (plan / package pickers).
|
||||
*/
|
||||
const CheckboxCard = React.forwardRef<HTMLButtonElement, CheckboxCardProps>(
|
||||
({ size = 'medium', label, description, wrapperClassName, className, ...props }, ref) => (
|
||||
<label className={cn(CARD_SHELL, wrapperClassName)}>
|
||||
{renderControl(size, className, false, props, ref)}
|
||||
<span className={CARD_LABEL}>{label}</span>
|
||||
{description !== undefined && <span className={CARD_DESCRIPTION}>{description}</span>}
|
||||
</label>
|
||||
),
|
||||
);
|
||||
CheckboxCard.displayName = 'CheckboxCard';
|
||||
|
||||
export { Checkbox, CheckboxGroup, CheckboxCard };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Checkbox, CheckboxGroup, CheckboxCard } from './Checkbox';
|
||||
export type { CheckboxProps, CheckboxGroupProps, CheckboxCardProps } from './Checkbox';
|
||||
@@ -0,0 +1,212 @@
|
||||
import * as React from 'react';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import cn from '../../utils/cn';
|
||||
import {
|
||||
CARD_DESCRIPTION,
|
||||
CARD_LABEL,
|
||||
CARD_SHELL,
|
||||
CONTROL_BASE,
|
||||
CONTROL_CHECKED,
|
||||
CONTROL_OFFSET,
|
||||
CONTROL_SIZE,
|
||||
DESCRIPTION,
|
||||
GROUP_LAYOUT,
|
||||
ROW_BASE,
|
||||
ROW_HOVER_CONTROL,
|
||||
ROW_TEXT,
|
||||
type SelectionSize,
|
||||
} from '../Selection/shared';
|
||||
|
||||
/**
|
||||
* Radio — design-system base component (组件-Radio单选框.md v1).
|
||||
*
|
||||
* Picks ONE from a fully visible set (2–7 options; more means a Select), and
|
||||
* a checked option cannot be un-picked. Baked in per spec: the shared 14/16/18
|
||||
* ladder with the white inner dot at (outer − 8) (§3), the same gray→brand
|
||||
* chain as the checkbox (§5), the button-group skin as a `variant` riding the
|
||||
* Button height ladder (§2/§3), the card shell shared with CheckboxCard, and
|
||||
* roving-tabindex arrow-key movement via Radix (落地 §3).
|
||||
*/
|
||||
|
||||
type RadioGroupVariant = 'default' | 'button';
|
||||
|
||||
interface RadioGroupContextValue {
|
||||
size: SelectionSize;
|
||||
variant: RadioGroupVariant;
|
||||
}
|
||||
|
||||
const RadioGroupContext = React.createContext<RadioGroupContextValue>({
|
||||
size: 'medium',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
/** §3 — the white inner dot: outer − 8 → 6/8/10. */
|
||||
const DOT_SIZE: Record<SelectionSize, string> = {
|
||||
small: 'size-1.5',
|
||||
medium: 'size-2',
|
||||
large: 'size-2.5',
|
||||
};
|
||||
|
||||
/** §3 — button-group cells ride the Button ladder: 24/32/40 high, 8/16/16
|
||||
* padding, radius 4/6/8 on the group's outer corners only. */
|
||||
const BUTTON_CELL_SIZE: Record<SelectionSize, string> = {
|
||||
small:
|
||||
'h-6 px-2 text-[length:var(--font-size-3)] leading-[var(--line-height-3)] first:rounded-l last:rounded-r',
|
||||
medium:
|
||||
'h-8 px-4 text-[length:var(--font-size-3)] leading-[var(--line-height-3)] first:rounded-l-md last:rounded-r-md',
|
||||
large:
|
||||
'h-10 px-4 text-[length:var(--font-size-4)] leading-[var(--line-height-4)] first:rounded-l-lg last:rounded-r-lg',
|
||||
};
|
||||
|
||||
/**
|
||||
* §5 — the button-group state table: white + body text unchecked, light gray
|
||||
* wash on hover (text-button hover), brand text over the unified selection
|
||||
* tint when checked — never a solid brand fill (a switcher must not compete
|
||||
* with the primary button). Disabled reuses the button tokens.
|
||||
*
|
||||
* Border plumbing follows antd's segmented layout (designer call,
|
||||
* 2026-08-25): every cell OWNS its 1px border and overlaps its neighbor by
|
||||
* -ml-px, so the CHECKED cell can turn its whole ring brand — shared edges
|
||||
* included — by rising one z step. A container border + dividers could never
|
||||
* recolor the checked segment's edges. The ring is the light brand step
|
||||
* (blue-100), same value as the card shell's selected border.
|
||||
*/
|
||||
const BUTTON_CELL_BASE =
|
||||
'btn-touch-hit relative -ml-px inline-flex cursor-pointer items-center justify-center whitespace-nowrap border border-border-base bg-bg-page text-text-1 outline-none transition-colors first:ml-0 data-[state=unchecked]:hover:bg-btn-fill-1 data-[state=checked]:z-10 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-500/[0.07] data-[state=checked]:text-blue-500 focus-visible:z-20 focus-visible:shadow-focus disabled:cursor-not-allowed disabled:!border-btn-disabled-border disabled:!bg-btn-disabled-bg disabled:!text-btn-disabled-text';
|
||||
|
||||
export interface RadioGroupProps
|
||||
extends Omit<React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>, 'asChild'> {
|
||||
/** §3 — one ladder for both skins; medium is the default. */
|
||||
size?: SelectionSize;
|
||||
/** §2 — `button` renders the segmented skin for high-frequency switching. */
|
||||
variant?: RadioGroupVariant;
|
||||
/** §2 — default skin only: horizontal 16px gaps / vertical 8px. */
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
|
||||
({ size = 'medium', variant = 'default', direction = 'horizontal', className, ...props }, ref) => {
|
||||
const context = React.useMemo(() => ({ size, variant }), [size, variant]);
|
||||
return (
|
||||
<RadioGroupContext.Provider value={context}>
|
||||
<RadioGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Button skin: the cells own their borders (see BUTTON_CELL_BASE),
|
||||
// the container is just a row.
|
||||
variant === 'button' ? 'inline-flex items-stretch' : GROUP_LAYOUT[direction],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</RadioGroupContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
RadioGroup.displayName = 'RadioGroup';
|
||||
|
||||
export interface RadioProps
|
||||
extends Omit<React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>, 'asChild'> {
|
||||
/** One secondary line under the label (§4) — default skin only. */
|
||||
description?: React.ReactNode;
|
||||
/** Class for the wrapping <label> when the option has text. */
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
/** The circle itself — shared by the basic row and the card form. */
|
||||
function renderControl(
|
||||
size: SelectionSize,
|
||||
className: string | undefined,
|
||||
inRow: boolean,
|
||||
props: Omit<RadioProps, 'description' | 'wrapperClassName' | 'className' | 'children'>,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
CONTROL_BASE,
|
||||
CONTROL_CHECKED,
|
||||
CONTROL_SIZE[size],
|
||||
inRow && CONTROL_OFFSET[size],
|
||||
inRow && ROW_HOVER_CONTROL,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* §5 — pure white dot via currentColor: white when checked, the disabled
|
||||
override grays it (选中禁用 = 浅灰底 + 灰内点). forceMount keeps the
|
||||
dot in the DOM so it can scale in AND out (200ms, same clock as the
|
||||
Switch thumb) — radix would otherwise unmount it and the dot pops. */}
|
||||
<RadioGroupPrimitive.Indicator
|
||||
forceMount
|
||||
className="flex scale-0 items-center justify-center transition-transform duration-200 data-[state=checked]:scale-100"
|
||||
>
|
||||
<span className={cn('rounded-full bg-current', DOT_SIZE[size])} />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One option. Under `variant="button"` it renders as a segmented cell; the
|
||||
* `description` prop is a default-skin affordance and is ignored there (§2:
|
||||
* cell copy is 2–4 characters).
|
||||
*/
|
||||
const Radio = React.forwardRef<HTMLButtonElement, RadioProps>(
|
||||
({ description, wrapperClassName, className, children, ...props }, ref) => {
|
||||
const { size, variant } = React.useContext(RadioGroupContext);
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(BUTTON_CELL_BASE, BUTTON_CELL_SIZE[size], className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
const hasRow = children !== undefined || description !== undefined;
|
||||
const control = renderControl(size, className, hasRow, props, ref);
|
||||
if (!hasRow) return control;
|
||||
return (
|
||||
<label className={cn(ROW_BASE, ROW_TEXT[size], wrapperClassName)}>
|
||||
{control}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
{children !== undefined && <span>{children}</span>}
|
||||
{description !== undefined && <span className={DESCRIPTION}>{description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
Radio.displayName = 'Radio';
|
||||
|
||||
export interface RadioCardProps extends Omit<RadioProps, 'wrapperClassName' | 'children'> {
|
||||
/** Card title — medium weight, body color (Checkbox §2). */
|
||||
label: React.ReactNode;
|
||||
/** Class for the card shell (the <label>). */
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card form — the shell is the checkbox card's (Checkbox §2, adopted by Radio
|
||||
* §2); only the control inside is a circle and selection is exclusive.
|
||||
*/
|
||||
const RadioCard = React.forwardRef<HTMLButtonElement, RadioCardProps>(
|
||||
({ label, description, wrapperClassName, className, ...props }, ref) => {
|
||||
const { size } = React.useContext(RadioGroupContext);
|
||||
return (
|
||||
<label className={cn(CARD_SHELL, wrapperClassName)}>
|
||||
{renderControl(size, className, false, props, ref)}
|
||||
<span className={CARD_LABEL}>{label}</span>
|
||||
{description !== undefined && <span className={CARD_DESCRIPTION}>{description}</span>}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
RadioCard.displayName = 'RadioCard';
|
||||
|
||||
export { RadioGroup, Radio, RadioCard };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RadioGroup, Radio, RadioCard } from './Radio';
|
||||
export type { RadioGroupProps, RadioProps, RadioCardProps } from './Radio';
|
||||
@@ -0,0 +1,181 @@
|
||||
import * as React from 'react';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import cn from '../../utils/cn';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
/**
|
||||
* Segmented — design-system base component (组件-Segmented分段控制器.md v1).
|
||||
*
|
||||
* Switches HOW the same content is shown — list vs cards, day vs week — and
|
||||
* applies IMMEDIATELY. Navigation between content blocks is Tabs; a choice
|
||||
* that travels with a form is a Radio (判别表 in the spec §1). Semantically a
|
||||
* radio group (§落地 2): `role="radiogroup"` / `role="radio"`, arrow keys move
|
||||
* AND select. Baked in per spec: gray track (fill-2) with a floating
|
||||
* page-colored thumb, no shadow (§2); 3px track inset with concentric radii;
|
||||
* equal-width segments sized by the widest, `block` fills the container (§2);
|
||||
* three sizes 28/32/36 — medium sits on the control ladder, small/large pull
|
||||
* in half a step (§3); selection never uses brand color, selected weight 500
|
||||
* over unselected 400 (§5); 200ms thumb slide; the gray keyboard-only
|
||||
* focus ring around the whole control; ≥44px touch hot zones (§6). Always has
|
||||
* a selected segment — with no `value`/`defaultValue` the first enabled
|
||||
* option is selected (§1).
|
||||
*/
|
||||
|
||||
export type SegmentedSize = 'small' | 'medium' | 'large';
|
||||
|
||||
export interface SegmentedOption {
|
||||
value: string;
|
||||
/** Text (2–4 chars per §4) and/or nothing for icon-only segments. */
|
||||
label?: React.ReactNode;
|
||||
/** Rides the 14/16/18 icon ladder; 8px gap to the text, 4px on small (§4). */
|
||||
icon?: React.ReactNode;
|
||||
/**
|
||||
* §4 — icon-only segments MUST explain themselves: plain-text tooltip,
|
||||
* doubling as the `aria-label` when there is no text label.
|
||||
*/
|
||||
tooltip?: string;
|
||||
/** §5 — this segment grays out and skips; the others stay clickable. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** §3 — track height / outer radius / per-segment padding, 28/32/36 ladder.
|
||||
* Font sizes reference the PRIMITIVE scale vars (control text must not follow
|
||||
* the ≤768px body remap — same rationale as Button §3). */
|
||||
const TRACK_SIZE: Record<SegmentedSize, string> = {
|
||||
small: 'h-7 rounded text-[length:var(--font-size-3)] leading-[var(--line-height-3)]',
|
||||
medium: 'h-8 rounded-md text-[length:var(--font-size-3)] leading-[var(--line-height-3)]',
|
||||
large: 'h-9 rounded-lg text-[length:var(--font-size-4)] leading-[var(--line-height-4)]',
|
||||
};
|
||||
|
||||
/** §4 — segment padding 8/12/16, icon 14/16/18 with 8px gap (4px on small). */
|
||||
const SEGMENT_SIZE: Record<SegmentedSize, string> = {
|
||||
small: 'gap-1 px-2 [&_svg]:size-3.5',
|
||||
medium: 'gap-2 px-3 [&_svg]:size-4',
|
||||
large: 'gap-2 px-4 [&_svg]:size-[18px]',
|
||||
};
|
||||
|
||||
/** §2 — thumb radius = outer radius − 3px inset (concentric nesting). */
|
||||
const THUMB_RADIUS: Record<SegmentedSize, string> = {
|
||||
small: 'rounded-[1px]',
|
||||
medium: 'rounded-[3px]',
|
||||
large: 'rounded-[5px]',
|
||||
};
|
||||
|
||||
export interface SegmentedProps
|
||||
extends Omit<
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>,
|
||||
'onChange' | 'onValueChange' | 'orientation' | 'dir' | 'loop' | 'asChild'
|
||||
> {
|
||||
/** 2–5 segments (§1); a bare string is shorthand for `{ value, label }`. */
|
||||
options: (SegmentedOption | string)[];
|
||||
/** §3 — 28/32/36; medium matches the 32px controls sitting beside it. */
|
||||
size?: SegmentedSize;
|
||||
/** §2 — fill the parent, segments split the width evenly (窄屏首选, §6). */
|
||||
block?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const Segmented = React.forwardRef<HTMLDivElement, SegmentedProps>(
|
||||
(
|
||||
{ options, size = 'medium', block = false, value, defaultValue, onChange, disabled, className, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const items = React.useMemo(
|
||||
() => options.map((o) => (typeof o === 'string' ? ({ value: o, label: o } as SegmentedOption) : o)),
|
||||
[options],
|
||||
);
|
||||
|
||||
// §1 — "always one selected": uncontrolled with no defaultValue starts on
|
||||
// the first enabled segment. The current value is mirrored locally so the
|
||||
// thumb knows which segment to sit on in both modes.
|
||||
const fallback = defaultValue ?? items.find((o) => !o.disabled)?.value;
|
||||
const [innerValue, setInnerValue] = React.useState(fallback);
|
||||
const current = value !== undefined ? value : innerValue;
|
||||
const activeIndex = items.findIndex((o) => o.value === current);
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
setInnerValue(next);
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
ref={ref}
|
||||
orientation="horizontal"
|
||||
value={current}
|
||||
onValueChange={handleChange}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
// §2 — equal-width segments, all sized by the widest (auto-cols-fr on
|
||||
// a content-sized grid); `relative` anchors the sliding thumb.
|
||||
'relative grid-flow-col auto-cols-fr select-none bg-fill-2 p-[3px] font-normal',
|
||||
block ? 'grid w-full' : 'inline-grid',
|
||||
// §5 — keyboard-only gray ring around the WHOLE control (2px gray-2,
|
||||
// same as the input focus ring).
|
||||
'has-[:focus-visible]:shadow-focus',
|
||||
TRACK_SIZE[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* §2/§5 — the floating thumb: one absolute element sliding 200ms to
|
||||
the selected segment. Equal columns make its geometry pure CSS —
|
||||
width = one column, translateX = index × own width. Light: the
|
||||
spec's white block = bg-bg-page. Dark: elevation LIGHTENS on
|
||||
#121212 (same direction as the button fills), so the page color
|
||||
would sink BELOW the track — float it two ramp steps above fill-2
|
||||
instead (fill-4, the iOS dark-segmented contrast). */}
|
||||
{activeIndex >= 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute bottom-[3px] left-[3px] top-[3px] bg-bg-page transition-transform duration-200 dark:bg-fill-4',
|
||||
THUMB_RADIUS[size],
|
||||
)}
|
||||
style={{
|
||||
width: `calc((100% - 6px) / ${items.length})`,
|
||||
transform: `translateX(${activeIndex * 100}%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{items.map((option) => {
|
||||
const segment = (
|
||||
<RadioGroupPrimitive.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
// §4 — an icon-only segment still needs an accessible name.
|
||||
aria-label={option.label === undefined ? option.tooltip : undefined}
|
||||
className={cn(
|
||||
// z-index lifts the label above the thumb; btn-touch-hit is the
|
||||
// ≥44px touch hot zone (§6), anchored by `relative`.
|
||||
'btn-touch-hit relative z-[1] inline-flex cursor-pointer items-center justify-center whitespace-nowrap outline-none transition-colors',
|
||||
// §5 — selected text-1 at weight 500 on the thumb, unselected
|
||||
// text-3 at 400, hover deepens to text-1 (background
|
||||
// untouched). CJK glyphs keep their advance across weights and
|
||||
// labels are 2–4 chars (§4), so the bolder selected segment
|
||||
// does not widen the equal-width columns.
|
||||
'text-text-3 hover:text-text-1 data-[state=checked]:font-medium data-[state=checked]:text-text-1',
|
||||
'disabled:cursor-not-allowed disabled:text-btn-disabled-text',
|
||||
SEGMENT_SIZE[size],
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
return option.tooltip ? (
|
||||
<Tooltip key={option.value} content={option.tooltip}>
|
||||
{segment}
|
||||
</Tooltip>
|
||||
) : (
|
||||
segment
|
||||
);
|
||||
})}
|
||||
</RadioGroupPrimitive.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
Segmented.displayName = 'Segmented';
|
||||
|
||||
export { Segmented };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Segmented } from './Segmented';
|
||||
export type { SegmentedProps, SegmentedOption, SegmentedSize } from './Segmented';
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Shared internals of the selection family — Checkbox / Radio (组件-Checkbox复选框.md
|
||||
* / 组件-Radio单选框.md, both v1).
|
||||
*
|
||||
* The two specs share one ladder (§3: control 14/16/18, text 14/14/16, 8px gap),
|
||||
* one state chain (§5: gray ramp until checked, brand at the moment of choice,
|
||||
* the button's three disabled tokens) and one card shell (Checkbox §2, adopted
|
||||
* by Radio §2), so the classes live once here. Internal module — not exported
|
||||
* from the package entry.
|
||||
*/
|
||||
|
||||
/** §3 — same small/medium/large ladder as Button / Input. */
|
||||
export type SelectionSize = 'small' | 'medium' | 'large';
|
||||
|
||||
/**
|
||||
* Row text sizes reference the PRIMITIVE type vars on purpose: the semantic
|
||||
* --text-body remaps 14→16 under 768px for reading text, while control rows
|
||||
* keep the §3 ladder so the box stays aligned with its first line.
|
||||
*/
|
||||
export const ROW_TEXT: Record<SelectionSize, string> = {
|
||||
small: 'text-[length:var(--font-size-3)] leading-[var(--line-height-3)]',
|
||||
medium: 'text-[length:var(--font-size-3)] leading-[var(--line-height-3)]',
|
||||
large: 'text-[length:var(--font-size-4)] leading-[var(--line-height-4)]',
|
||||
};
|
||||
|
||||
/** Centers the control on the label's FIRST line: (line-height − control) / 2. */
|
||||
export const CONTROL_OFFSET: Record<SelectionSize, string> = {
|
||||
small: 'mt-1', // (22 − 14) / 2
|
||||
medium: 'mt-[3px]', // (22 − 16) / 2
|
||||
large: 'mt-[3px]', // (24 − 18) / 2
|
||||
};
|
||||
|
||||
/** §3 — control square/circle 14/16/18 with the matching indicator-icon size. */
|
||||
export const CONTROL_SIZE: Record<SelectionSize, string> = {
|
||||
small: 'size-3.5 [&_svg]:size-2.5',
|
||||
medium: 'size-4 [&_svg]:size-3',
|
||||
large: 'size-[18px] [&_svg]:size-3.5',
|
||||
};
|
||||
|
||||
/**
|
||||
* §4/§6 — the whole row is the hot zone (text included), disabled turns the
|
||||
* row text gray with the not-allowed cursor (§5's three signals), and touch
|
||||
* pads the row to ≥44px ("整行 label 撑高", 落地 note).
|
||||
*/
|
||||
export const ROW_BASE =
|
||||
'group/row inline-flex max-w-full cursor-pointer items-start gap-2 text-text-1 coarse-pointer:py-[11px] has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:text-btn-disabled-text';
|
||||
|
||||
/** Hovering anywhere on the row deepens an UNCHECKED control's border (§5). */
|
||||
export const ROW_HOVER_CONTROL = 'group-hover/row:data-[state=unchecked]:border-border-deep';
|
||||
|
||||
/** §4 — one secondary line, hint color; goes disabled-gray with the row. */
|
||||
export const DESCRIPTION = 'text-text-3 group-has-[[data-disabled]]/row:text-btn-disabled-text';
|
||||
|
||||
/**
|
||||
* §5 — the state chain both controls share. Unchecked: page bg + base border,
|
||||
* hover deepens the border only. Disabled: the button's three tokens with `!`
|
||||
* so they beat the checked brand color (same technique as Button). Focus ring
|
||||
* appears on :focus-visible only — keyboard, not click.
|
||||
*/
|
||||
export const CONTROL_BASE =
|
||||
'btn-touch-hit relative inline-flex shrink-0 cursor-pointer items-center justify-center border border-border-base bg-bg-page outline-none transition-colors hover:data-[state=unchecked]:border-border-deep focus-visible:shadow-focus disabled:cursor-not-allowed disabled:!border-btn-disabled-border disabled:!bg-btn-disabled-bg disabled:!text-btn-disabled-text';
|
||||
|
||||
/**
|
||||
* §5 — checked is the semantic moment: brand fill (follows blue⇄green), white
|
||||
* mark. The mark draws with currentColor, so the disabled override above also
|
||||
* grays it (选中禁用 = 浅灰底 + 灰勾/灰点).
|
||||
*/
|
||||
export const CONTROL_CHECKED =
|
||||
'data-[state=checked]:border-blue-500 data-[state=checked]:bg-blue-500 data-[state=checked]:text-white';
|
||||
|
||||
/** §2 — group spacing: horizontal 16, vertical 8; narrow screens always stack. */
|
||||
export const GROUP_LAYOUT: Record<'horizontal' | 'vertical', string> = {
|
||||
horizontal: 'flex flex-wrap gap-x-4 gap-y-2 max-[768px]:flex-col',
|
||||
vertical: 'flex flex-col gap-2',
|
||||
};
|
||||
|
||||
/**
|
||||
* Checkbox §2 (adopted by Radio §2) — the card shell: radius 12 / min-height 48
|
||||
* / 12px horizontal padding; hover is a light gray wash; selection tints the
|
||||
* WHOLE card with the unified selection bg AND turns the border a light brand
|
||||
* step (blue-100, follows the theme) — the original keep-it-gray route read
|
||||
* mismatched against the brand tint (designer call, 2026-08-25); disabled
|
||||
* reuses the row's three signals with the hover wash suppressed.
|
||||
*/
|
||||
export const CARD_SHELL =
|
||||
'group/row flex min-h-12 cursor-pointer items-center gap-2 rounded-xl border border-border-base px-3 text-body text-text-1 transition-colors hover:bg-fill-1 has-[[data-state=checked]]:border-blue-100 has-[[data-state=checked]]:bg-blue-500/[0.07] has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:text-btn-disabled-text has-[[data-disabled]]:hover:bg-transparent';
|
||||
|
||||
/** Card title: medium weight, body color (§2). */
|
||||
export const CARD_LABEL = 'shrink-0 whitespace-nowrap font-medium';
|
||||
|
||||
/** Card secondary line: hint color, ellipsis when it runs out of room (§2). */
|
||||
export const CARD_DESCRIPTION =
|
||||
'min-w-0 truncate text-text-3 group-has-[[data-disabled]]/row:text-btn-disabled-text';
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import cn from '../../utils/cn';
|
||||
|
||||
/**
|
||||
* Switch — design-system base component (组件-Switch开关.md v1).
|
||||
*
|
||||
* Flips ONE standalone setting and applies IMMEDIATELY — a choice that travels
|
||||
* with a form is a Checkbox/Radio. Baked in per spec: two sizes 22×38 / 18×32
|
||||
* with a full-radius track, round thumb and 2px inset (§2); brand track on /
|
||||
* gray track off with one-step-deeper hovers (§4); disabled keeps the side it
|
||||
* stopped on at 40% opacity (§4 — a uniform gray would hide WHICH side);
|
||||
* loading locks the toggle with a spinner in the thumb; the keyboard-only gray
|
||||
* focus ring; and the ≥44px touch hot zone (§5). On async failure the CALLER
|
||||
* flips `checked` back and explains via toast (§4 — 失败要回弹).
|
||||
*/
|
||||
|
||||
export type SwitchSize = 'default' | 'small';
|
||||
|
||||
/** §2 — track height × min width (widens with inner text, min stays).
|
||||
* 22×38 / 18×32, not antd's 22×44 / 16×28: the designer called 2:1 too wide
|
||||
* (2026-08-25, referencing the chat tools switch at 20×34 ≈ 1.7:1). */
|
||||
const TRACK_SIZE: Record<SwitchSize, string> = {
|
||||
default: 'h-[22px] min-w-[38px]',
|
||||
small: 'h-[18px] min-w-8',
|
||||
};
|
||||
|
||||
/** §2 — thumb 18/14 with a 2px inset; spinner rides the icon ladder (落地 §3). */
|
||||
const THUMB_SIZE: Record<SwitchSize, string> = {
|
||||
default: 'size-[18px] [&_svg]:size-3.5',
|
||||
small: 'size-3.5 [&_svg]:size-2.5',
|
||||
};
|
||||
|
||||
/** §3 — inner text: 12px white, sitting on the side the thumb vacated. */
|
||||
const INNER_TEXT = 'select-none text-[length:var(--font-size-1)] leading-none text-white';
|
||||
|
||||
export interface SwitchProps
|
||||
extends Omit<React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>, 'asChild'> {
|
||||
/** §2 — `default` 22×44 (aligns with the 22px body line); `small` 16×28. */
|
||||
size?: SwitchSize;
|
||||
/**
|
||||
* §4 — waiting for the server to confirm: spinner in the thumb, toggle
|
||||
* locked. The visual state stays where the user put it; on failure the
|
||||
* caller flips `checked` back and explains with a toast.
|
||||
*/
|
||||
loading?: boolean;
|
||||
/** §3 — optional emphasis inside the track, ≤2 characters or one icon; shown while ON. */
|
||||
checkedChildren?: React.ReactNode;
|
||||
/** §3 — counterpart shown while OFF. `small` has no room and renders neither. */
|
||||
unCheckedChildren?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
(
|
||||
{ size = 'default', loading = false, disabled, checkedChildren, unCheckedChildren, className, ...props },
|
||||
ref,
|
||||
) => {
|
||||
// §3 — small cannot fit inner text, so it never renders any.
|
||||
const showInner = size === 'default';
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
// §4 — the 40% opacity is invisible to a screen reader (落地 §4).
|
||||
aria-disabled={disabled || loading || undefined}
|
||||
className={cn(
|
||||
'group/track btn-touch-hit relative inline-flex shrink-0 cursor-pointer items-center rounded-full outline-none transition-colors',
|
||||
// §4 — off: gray track (switch-off = gray-4, hover gray-5); on: brand
|
||||
// track, hover one step lighter (same source as Button primary hover).
|
||||
'bg-switch-off enabled:hover:bg-switch-off-hover data-[state=checked]:bg-blue-500 data-[state=checked]:enabled:hover:bg-blue-400',
|
||||
'focus-visible:shadow-focus',
|
||||
TRACK_SIZE[size],
|
||||
disabled && 'cursor-not-allowed opacity-40',
|
||||
loading && !disabled && 'cursor-default',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showInner && unCheckedChildren !== undefined && (
|
||||
<span className={cn(INNER_TEXT, 'ml-[22px] mr-1.5 group-data-[state=checked]/track:hidden')}>
|
||||
{unCheckedChildren}
|
||||
</span>
|
||||
)}
|
||||
{showInner && checkedChildren !== undefined && (
|
||||
<span className={cn(INNER_TEXT, 'ml-1.5 mr-[22px] hidden group-data-[state=checked]/track:inline')}>
|
||||
{checkedChildren}
|
||||
</span>
|
||||
)}
|
||||
{/* §2 — the thumb travels edge to edge with the 2px inset; `left` +
|
||||
transform track a text-widened track without hardcoding a distance.
|
||||
0.2s per 落地 §3. Fixed white: the track carries the color. */}
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
'absolute left-0.5 top-1/2 flex -translate-y-1/2 items-center justify-center rounded-full bg-white transition-all duration-200 data-[state=checked]:left-[calc(100%-2px)] data-[state=checked]:-translate-x-full',
|
||||
THUMB_SIZE[size],
|
||||
)}
|
||||
>
|
||||
{loading && <Outlined.Loading className="animate-spin text-text-3" />}
|
||||
</SwitchPrimitive.Thumb>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Switch } from './Switch';
|
||||
export type { SwitchProps, SwitchSize } from './Switch';
|
||||
@@ -0,0 +1,291 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import cn from '../../utils/cn';
|
||||
import { Badge } from '../Badge';
|
||||
|
||||
/**
|
||||
* Tabs — design-system base component (组件-Tabs标签页.md v1).
|
||||
*
|
||||
* Groups PEER content blocks into one area — switching changes WHERE you are,
|
||||
* so it is navigation. The same content shown a different way is a Segmented
|
||||
* (判别表 in 组件-Segmented分段控制器.md §1). Line type ONLY — no card tabs, no
|
||||
* closable tabs (§2). Baked in per spec: left-aligned tab row over a full-width
|
||||
* 1px divider (optional — `divider={false}`), 24px between tabs, first tab
|
||||
* flush with the content edge (§2);
|
||||
* three sizes on the 24/32/40 ladder; unselected weight 400, selected 500 —
|
||||
* every tab reserves its width at 500 via an invisible bold copy of the label
|
||||
* (antd's trick), so bolding never moves the neighbors or the indicator (§3);
|
||||
* selected text + a 2px indicator sliding 200ms under the selected tab, in
|
||||
* the brand color by default or in ink via `variant="neutral"` (§5); content
|
||||
* swaps instantly, no transition (§5);
|
||||
* an optional count badge rides right of the label (§4); overflow scrolls
|
||||
* horizontally with fading edges, never wraps, the selected tab keeps itself
|
||||
* visible (§4); ≥44px touch hot zones (§6). Accessibility is
|
||||
* the WAI-ARIA tabs pattern in automatic mode: arrow keys move focus AND
|
||||
* activate, Home/End jump to the ends (§落地 2).
|
||||
*/
|
||||
|
||||
export type TabsSize = 'small' | 'medium' | 'large';
|
||||
|
||||
/** §5 — how the selected tab speaks: brand color (default) or ink (text-1). */
|
||||
export type TabsVariant = 'brand' | 'neutral';
|
||||
|
||||
export interface TabItem {
|
||||
/** Identity of the tab; also what `activeKey` / `onChange` speak. */
|
||||
key: string;
|
||||
/** 2–6 chars, same length across the set reads best (§4); never truncated. */
|
||||
label: React.ReactNode;
|
||||
/** Optional leading icon — all tabs in a set have one or none do (§4). */
|
||||
icon?: React.ReactNode;
|
||||
/**
|
||||
* §4 — count badge right of the label (unread / item count). 0 and
|
||||
* undefined render nothing, so callers can pass a raw count straight
|
||||
* through without guarding. Not capped: the tab grows with the digits,
|
||||
* matching the channel module this was lifted from.
|
||||
*/
|
||||
badge?: number;
|
||||
/** §5 — grays out and skips focus; prefer not rendering a dead tab at all. */
|
||||
disabled?: boolean;
|
||||
/** Panel content. Omit on every item to use Tabs as a bare bar (routing). */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** §3 — row height 24/32/40; font follows the type scale per rung (14/14/16).
|
||||
* Font sizes reference the PRIMITIVE scale vars — control text must not follow
|
||||
* the ≤768px body remap (same rationale as Button §3). */
|
||||
const ROW_SIZE: Record<TabsSize, string> = {
|
||||
small: 'h-6 text-[length:var(--font-size-3)] leading-[var(--line-height-3)]',
|
||||
medium: 'h-8 text-[length:var(--font-size-3)] leading-[var(--line-height-3)]',
|
||||
large: 'h-10 text-[length:var(--font-size-4)] leading-[var(--line-height-4)]',
|
||||
};
|
||||
|
||||
/** §4 — icon rides the 14/16/18 ladder, 8px gap to the text (4px on small). */
|
||||
const TAB_SIZE: Record<TabsSize, string> = {
|
||||
small: 'gap-1 [&_svg]:size-3.5',
|
||||
medium: 'gap-2 [&_svg]:size-4',
|
||||
large: 'gap-2 [&_svg]:size-[18px]',
|
||||
};
|
||||
|
||||
/** §5 — selected-state color per variant. `brand` follows the blue⇄green
|
||||
* theme (and the dark brand ramp — see the use site). `neutral`
|
||||
* speaks in ink: selected text and indicator are text-1, which already flips
|
||||
* to near-white in dark mode — no dark override needed, and no contrast debt
|
||||
* (text-1 is the loudest text on either background). Selection then rests on
|
||||
* weight 500 + the indicator alone, so neutral is for surfaces where a brand
|
||||
* accent would fight nearby brand elements (§5). */
|
||||
const VARIANT: Record<TabsVariant, { tab: string; indicator: string; badge: string }> = {
|
||||
brand: {
|
||||
// Dark needs no override: blue-500 resolves through the dark brand ramp
|
||||
// (tokens.css .dark → #3C7EFF blue / #3CB062 green), bright AND saturated
|
||||
// on #121212 — the fix §5's 实测 ladder (500 too dim / 300 washed out /
|
||||
// interim 400) was waiting for.
|
||||
tab: 'data-[state=active]:text-blue-500 data-[state=active]:hover:text-blue-500',
|
||||
indicator: 'bg-blue-500',
|
||||
// §4 — the badge does NOT track selection: it reports "how many", not
|
||||
// "which tab you are on", so it stays brand on selected and unselected
|
||||
// tabs alike (the channel module reads this way and it is the point of
|
||||
// the badge). It follows the VARIANT instead, or a neutral tab row would
|
||||
// sprout the brand accent the variant exists to avoid.
|
||||
badge: 'bg-blue-500/5 text-blue-500',
|
||||
},
|
||||
neutral: {
|
||||
tab: 'data-[state=active]:text-text-1 data-[state=active]:hover:text-text-1',
|
||||
indicator: 'bg-text-1',
|
||||
badge: 'bg-text-1/5 text-text-1',
|
||||
},
|
||||
};
|
||||
|
||||
/** §4 — fading edges while overflowing: mask off the side(s) that continue. */
|
||||
const EDGE_FADE = {
|
||||
none: undefined,
|
||||
left: 'linear-gradient(to right, transparent, black 24px)',
|
||||
right: 'linear-gradient(to left, transparent, black 24px)',
|
||||
both: 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)',
|
||||
};
|
||||
|
||||
export interface TabsProps {
|
||||
/** At least 2 — a single tab is just content, not tabs (§1). */
|
||||
items: TabItem[];
|
||||
/** §3 — deeper containers take smaller rungs: page header large, dialogs small. */
|
||||
size?: TabsSize;
|
||||
/**
|
||||
* §5 — `brand` (default): selected tab in the brand color. `neutral`:
|
||||
* selected tab in ink (text-1) — for surfaces where a brand accent would
|
||||
* compete with nearby brand elements; selection rests on weight + indicator.
|
||||
*/
|
||||
variant?: TabsVariant;
|
||||
/**
|
||||
* §2 — the 1px divider under the whole row. Default on. Turn it off when
|
||||
* the surrounding container already draws that edge (a card border, a
|
||||
* section rule) — two hairlines 1px apart read as a rendering bug.
|
||||
*/
|
||||
divider?: boolean;
|
||||
/** Controlled selected key; use `defaultActiveKey` for uncontrolled. */
|
||||
activeKey?: string;
|
||||
defaultActiveKey?: string;
|
||||
onChange?: (key: string) => void;
|
||||
/**
|
||||
* §2 — light operations at the right end of the tab row (refresh, a filter),
|
||||
* vertically centered; heavy actions belong in the content area.
|
||||
*/
|
||||
extra?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Tabs({
|
||||
items,
|
||||
size = 'medium',
|
||||
variant = 'brand',
|
||||
divider = true,
|
||||
activeKey,
|
||||
defaultActiveKey,
|
||||
onChange,
|
||||
extra,
|
||||
className,
|
||||
}: TabsProps) {
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
const tabRefs = React.useRef(new Map<string, HTMLButtonElement>());
|
||||
|
||||
// The current key is mirrored locally so the indicator knows where to sit in
|
||||
// both controlled and uncontrolled mode.
|
||||
const [innerKey, setInnerKey] = React.useState(defaultActiveKey ?? items.find((i) => !i.disabled)?.key);
|
||||
const current = activeKey !== undefined ? activeKey : innerKey;
|
||||
|
||||
// §5 — the indicator is ONE absolute element that slides (200ms) to the
|
||||
// selected tab; per-tab border-bottoms cannot slide. Measured relative to
|
||||
// the list, so it scrolls together with the tabs.
|
||||
const [indicator, setIndicator] = React.useState<{ left: number; width: number } | null>(null);
|
||||
// §4 — which edges continue out of view and therefore fade.
|
||||
const [fade, setFade] = React.useState<keyof typeof EDGE_FADE>('none');
|
||||
|
||||
const updateFade = React.useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const left = list.scrollLeft > 1;
|
||||
const right = list.scrollLeft + list.clientWidth < list.scrollWidth - 1;
|
||||
setFade(left ? (right ? 'both' : 'left') : right ? 'right' : 'none');
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const list = listRef.current;
|
||||
const tab = current !== undefined ? tabRefs.current.get(current) : undefined;
|
||||
const measure = () => {
|
||||
setIndicator(tab ? { left: tab.offsetLeft, width: tab.offsetWidth } : null);
|
||||
updateFade();
|
||||
};
|
||||
measure();
|
||||
// Re-measure on any size change of the list or the selected tab (container
|
||||
// resize, font swap-in, label change).
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (list) ro.observe(list);
|
||||
if (tab) ro.observe(tab);
|
||||
return () => ro.disconnect();
|
||||
}, [current, items, size, updateFade]);
|
||||
|
||||
// §4 — the selected tab keeps itself inside the visible range.
|
||||
React.useEffect(() => {
|
||||
const list = listRef.current;
|
||||
const tab = current !== undefined ? tabRefs.current.get(current) : undefined;
|
||||
if (list && tab && list.scrollWidth > list.clientWidth) {
|
||||
tab.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
}, [current]);
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
setInnerKey(next);
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Root value={current} onValueChange={handleChange} activationMode="automatic" className={className}>
|
||||
{/* §2 — one 1px divider under the WHOLE row, extra area included;
|
||||
`divider={false}` drops it when the container already draws that edge. */}
|
||||
<div className={cn('flex items-end', divider && 'border-b border-border-base')}>
|
||||
<TabsPrimitive.List
|
||||
ref={listRef}
|
||||
onScroll={updateFade}
|
||||
className={cn(
|
||||
// §4 — overflow scrolls, never wraps; the scrollbar itself is
|
||||
// hidden (the fading edges are the affordance).
|
||||
'relative flex min-w-0 flex-1 items-center gap-6 overflow-x-auto',
|
||||
// -mb-px sinks the list 1px onto the wrapper's border-b so the
|
||||
// bottom-0 indicator paints OVER the gray divider instead of
|
||||
// stacking above it (the indicator can't use -bottom-px itself:
|
||||
// overflow-x-auto forces overflow-y to auto, which would clip it).
|
||||
// Without a divider there is nothing to sink onto — keeping it
|
||||
// would just pull the row 1px past the wrapper's own bottom.
|
||||
divider && '-mb-px',
|
||||
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
ROW_SIZE[size],
|
||||
)}
|
||||
style={{ maskImage: EDGE_FADE[fade], WebkitMaskImage: EDGE_FADE[fade] }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TabsPrimitive.Trigger
|
||||
key={item.key}
|
||||
value={item.key}
|
||||
disabled={item.disabled}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(item.key, el);
|
||||
else tabRefs.current.delete(item.key);
|
||||
}}
|
||||
className={cn(
|
||||
// relative anchors the ≥44px touch hot zone (§6); rounded only
|
||||
// softens the keyboard focus ring (§5 — 2px gray, input's ring).
|
||||
'btn-touch-hit relative inline-flex h-full shrink-0 cursor-pointer items-center whitespace-nowrap rounded font-normal outline-none transition-colors focus-visible:shadow-focus',
|
||||
// §5 — unselected text-2, hover deepens to text-1, selected
|
||||
// speaks per variant (brand color or ink — see VARIANT), one
|
||||
// weight step up (the label box already reserves this width).
|
||||
'text-text-2 hover:text-text-1 data-[state=active]:font-medium',
|
||||
VARIANT[variant].tab,
|
||||
'disabled:cursor-not-allowed disabled:text-btn-disabled-text',
|
||||
TAB_SIZE[size],
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{/* §3 — the box is sized by an invisible 500-weight copy, so the
|
||||
400⇄500 flip never widens the tab (pure-CJK labels would not
|
||||
move anyway — CJK advances are weight-independent — but Latin
|
||||
and digits do). The visible copy centers in the reserved box. */}
|
||||
<span className="relative whitespace-nowrap">
|
||||
<span aria-hidden className="invisible font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="absolute inset-0 flex items-center justify-center">{item.label}</span>
|
||||
</span>
|
||||
{/* §4 — count badge, drawn by the Badge component's standalone form
|
||||
(组件-Badge徽标.md §2): same 16px pill, same caption-sm/500, so
|
||||
the 400⇄500 flip on selection cannot resize it and the row
|
||||
cannot shift. Sits in the trigger's flex gap (4px small / 8px
|
||||
otherwise), same as the icon. The color comes from the VARIANT,
|
||||
not from Badge's own default, so a neutral row stays neutral. */}
|
||||
<Badge count={item.badge} className={cn('shrink-0', VARIANT[variant].badge)} />
|
||||
</TabsPrimitive.Trigger>
|
||||
))}
|
||||
{/* §5 — 2px brand indicator on the divider, text-wide, 200ms slide. */}
|
||||
{indicator && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 h-0.5 transition-[transform,width] duration-200',
|
||||
VARIANT[variant].indicator,
|
||||
)}
|
||||
style={{ width: indicator.width, transform: `translateX(${indicator.left}px)` }}
|
||||
/>
|
||||
)}
|
||||
</TabsPrimitive.List>
|
||||
{extra !== undefined && <div className="flex shrink-0 items-center self-center pl-6">{extra}</div>}
|
||||
</div>
|
||||
{/* §5 — the panel swaps instantly; no transition on content. */}
|
||||
{items
|
||||
.filter((item) => item.children !== undefined)
|
||||
.map((item) => (
|
||||
<TabsPrimitive.Content key={item.key} value={item.key} className="outline-none">
|
||||
{item.children}
|
||||
</TabsPrimitive.Content>
|
||||
))}
|
||||
</TabsPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Tabs } from './Tabs';
|
||||
export type { TabsProps, TabItem, TabsSize, TabsVariant } from './Tabs';
|
||||
@@ -0,0 +1,317 @@
|
||||
import * as React from 'react';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import cn from '../../utils/cn';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
/**
|
||||
* Tag — design-system base component (组件-Tag标签.md v1).
|
||||
*
|
||||
* A tag puts ONE word on an object: what it is, what state it is in, which
|
||||
* category it belongs to (§1). It labels an attribute, never an action — a
|
||||
* tag looks clickable, so a display tag deliberately has no hover at all
|
||||
* (§6); lighting up and then doing nothing is a lie told once per tag. What
|
||||
* says「how many / anything new」is a Badge (判别表 in 组件-Badge徽标.md §1).
|
||||
*
|
||||
* One look only: a light tint with dark text, 4px radius, no border, no solid
|
||||
* fill, no gray outline variant (§2) — the designer's call, and adding one
|
||||
* back is a spec change, not a prop.
|
||||
*
|
||||
* Two axes that pick independently (§3): a semantic color (what is being
|
||||
* said) × an interaction type (what it can do). Semantic colors carry the
|
||||
* meaning — the same state is the same color across the whole product — and
|
||||
* the two frozen exceptions,「审批中」blue and「技能」purple, are their own
|
||||
* colors here because they must NOT follow the blue⇄green theme switch
|
||||
* (色彩规范 §4). There is no eighth color: a category with no meaning is
|
||||
* `default` gray, or the reader is left guessing whether blue outranks green.
|
||||
*
|
||||
* The status form lives here too (moved from Badge 2026-08-28): `dot` puts a
|
||||
* small filled circle in front of the word, in the tag's own color. A list row
|
||||
* that reports 「解析失败」 is saying WHAT this file is right now — an
|
||||
* attribute — so it is a tag, not a badge; the badge only ever answers 「is
|
||||
* there anything new / how many」 (判别表 in 组件-Badge徽标.md §1).
|
||||
*
|
||||
* Baked in per spec: the 20/24 size ladder at 12/400 (a tag is one rung below
|
||||
* the control it is stuck on, §4); a checkable tag always starts gray and
|
||||
* turns brand-tinted when checked, so selection owns the color channel and
|
||||
* cannot be combined with a semantic one (§3.2); removal is immediate with no
|
||||
* confirm — re-picking is the undo (§3.2); an avatar turns the tag into a
|
||||
* pill, because a square corner around a round face reads as a rendering bug
|
||||
* (§5); `maxWidth` truncates and only then mounts a Tooltip (§5); ≥44px touch
|
||||
* hot zones, and a checkable tag jumps to medium on touch where 20px is
|
||||
* unhittable (§7).
|
||||
*/
|
||||
|
||||
/** §4 — two rungs. `medium` is the default; `small` is for a table cell, a
|
||||
* list row, or the picked items inside a 32px field. */
|
||||
export type TagSize = 'small' | 'medium';
|
||||
|
||||
/**
|
||||
* §3.1 — five semantic colors plus the two frozen exceptions. `approving` and
|
||||
* `skill` are separate values rather than `brand`: they are pinned to blue and
|
||||
* purple in BOTH brand themes (色彩规范 §4), which is exactly what `brand`
|
||||
* cannot promise.
|
||||
*/
|
||||
export type TagColor =
|
||||
| 'default'
|
||||
| 'brand'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'approving'
|
||||
| 'skill';
|
||||
|
||||
/** §3.1 — tint background + main-color text, both from the token layer, so
|
||||
* dark mode arrives on its own (the dark tints are deep saturated washes, not
|
||||
* light chips — 色彩规范 §3). */
|
||||
const COLOR: Record<TagColor, string> = {
|
||||
default: 'bg-fill-2 text-text-2',
|
||||
brand: 'bg-blue-50 text-blue-500',
|
||||
success: 'bg-success-tint text-success',
|
||||
warning: 'bg-warning-tint text-warning',
|
||||
danger: 'bg-danger-tint text-danger',
|
||||
approving: 'bg-tag-approving-tint text-tag-approving',
|
||||
skill: 'bg-tag-skill-tint text-tag-skill',
|
||||
};
|
||||
|
||||
/** §6 — one disabled look for every color: the tag is still there, it just
|
||||
* stops claiming anything. */
|
||||
const DISABLED = 'bg-fill-1 text-text-4';
|
||||
|
||||
/** §4 — height, horizontal padding, icon rung. Font is `text-caption` (12/20)
|
||||
* on both rungs; the height is what changes, not the type.
|
||||
*
|
||||
* `[&>svg]`, NOT `[&_svg]`: the leading icon rides the 12/14 rung, but the
|
||||
* close 「×」 is fixed at 12px on both rungs (§5), and it is nested inside its
|
||||
* own button. A descendant selector here would out-specify the 「×」's own
|
||||
* `size-3` (0,2,0 beats 0,1,0) and silently blow it up to 14px on the medium
|
||||
* rung — which is exactly what it did until this was scoped to direct
|
||||
* children. */
|
||||
const SIZE: Record<TagSize, string> = {
|
||||
small: 'h-5 gap-1 px-1.5 [&>svg]:size-3',
|
||||
medium: 'h-6 gap-1 px-2 [&>svg]:size-3.5',
|
||||
};
|
||||
|
||||
/** §5 — the status dot rides the same ladder as the icon it replaces: 4px on
|
||||
* the 20px rung (what the knowledge-space file list draws today), 6px on the
|
||||
* 24px one. It takes the tag's own text color (`currentColor`), so a status
|
||||
* never needs a second color decision — `color="danger"` makes both the word
|
||||
* and the dot red. */
|
||||
const DOT_SIZE: Record<TagSize, string> = {
|
||||
small: 'size-1',
|
||||
medium: 'size-1.5',
|
||||
};
|
||||
|
||||
/** §5 — the avatar is 14/16px and sits flush, so THAT side's padding drops to
|
||||
* 4px. The 「×」 side does not: see the shell below. */
|
||||
const AVATAR_SIZE: Record<TagSize, string> = {
|
||||
small: 'size-3.5',
|
||||
medium: 'size-4',
|
||||
};
|
||||
|
||||
export interface TagProps {
|
||||
/** The word itself: 2–6 chars, a noun or a state, no punctuation and no
|
||||
* verb (§5). Comes from the caller — the library holds no copy. */
|
||||
children: React.ReactNode;
|
||||
/** §4 — default `medium` (24px). One group of tags uses ONE rung. */
|
||||
size?: TagSize;
|
||||
/** §3.1 — default `default` (gray). One meaning keeps one color product-wide. */
|
||||
color?: TagColor;
|
||||
/**
|
||||
* §5 — a filled dot in front of the word, in the tag's own color: the form a
|
||||
* list row uses to report state (「运行中」/「失败」) when every row carries
|
||||
* one and the eye has to scan the column. Wins over `icon`; ignored when
|
||||
* `avatar` is set. NO pulse animation, ever — a column of breathing dots is
|
||||
* a column nobody can read.
|
||||
*/
|
||||
dot?: boolean;
|
||||
/** §5 — leading icon, 12/14px, inherits the text color. Either all the tags
|
||||
* in a group have one or none do. Ignored when `dot` or `avatar` is set. */
|
||||
icon?: React.ReactNode;
|
||||
/** §5 — leading avatar (14/16px, cropped to a circle here). Turns the tag
|
||||
* into a pill and forces the gray `default` color: a face plus a semantic
|
||||
* tint is two claims in one chip. */
|
||||
avatar?: React.ReactNode;
|
||||
/**
|
||||
* §3.2 — the「×」that removes this tag. Removal is immediate and needs no
|
||||
* confirm; deleting the underlying THING is a button with a real confirm.
|
||||
* Ignored on a checkable tag — a button inside a button is invalid HTML,
|
||||
* and「pick it / drop it」are two answers to the same question anyway.
|
||||
*/
|
||||
closable?: boolean;
|
||||
onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
/** Accessible name of the「×」. Text comes from the caller (library contract). */
|
||||
closeLabel?: string;
|
||||
/** §3.2 — click to toggle. Always starts from the gray fill; `color` is
|
||||
* ignored, because the checked state already owns the color channel. */
|
||||
checkable?: boolean;
|
||||
/** Controlled checked state; use `defaultChecked` for uncontrolled. */
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
/**
|
||||
* §5 — cap the width in px: the label truncates and a Tooltip with the full
|
||||
* text appears, but ONLY once it actually overflows. For fixed-width columns;
|
||||
* everywhere else let the tag be as wide as its word.
|
||||
*/
|
||||
maxWidth?: number;
|
||||
/** §6 — display and closable tags grey out; a checkable one also stops toggling. */
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Tag({
|
||||
children,
|
||||
size = 'medium',
|
||||
color = 'default',
|
||||
dot = false,
|
||||
icon,
|
||||
avatar,
|
||||
closable = false,
|
||||
onClose,
|
||||
closeLabel = 'Remove',
|
||||
checkable = false,
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
onChange,
|
||||
maxWidth,
|
||||
disabled = false,
|
||||
className,
|
||||
}: TagProps) {
|
||||
const [innerChecked, setInnerChecked] = React.useState(defaultChecked);
|
||||
const isChecked = checked !== undefined ? checked : innerChecked;
|
||||
|
||||
// §5 — mount the Tooltip only for a label that is really clipped: one
|
||||
// hover listener per tag in a filter panel is a lot of listeners for a
|
||||
// tooltip that would never say anything new.
|
||||
const labelRef = React.useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = React.useState(false);
|
||||
React.useLayoutEffect(() => {
|
||||
const label = labelRef.current;
|
||||
if (maxWidth === undefined || !label) {
|
||||
setOverflowing(false);
|
||||
return;
|
||||
}
|
||||
const measure = () => setOverflowing(label.scrollWidth > label.clientWidth + 1);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(label);
|
||||
return () => ro.disconnect();
|
||||
}, [maxWidth, children]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !isChecked;
|
||||
setInnerChecked(next);
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
const withAvatar = avatar !== undefined;
|
||||
const showClose = closable && !checkable;
|
||||
|
||||
const fill = disabled
|
||||
? // §6 — a checked-but-disabled tag keeps the brand text so the user can
|
||||
// still read WHICH ones are picked; only the fill drops out.
|
||||
checkable && isChecked
|
||||
? 'bg-text-4/20 text-blue-500'
|
||||
: DISABLED
|
||||
: checkable
|
||||
? isChecked
|
||||
? // §6 — the 7% brand tint the whole product uses for「selected」
|
||||
// (色彩规范 §1.2), 10% on hover.
|
||||
'bg-blue-500/[0.07] text-blue-500 hover:bg-blue-500/10'
|
||||
: 'bg-fill-2 text-text-2 hover:bg-fill-3'
|
||||
: COLOR[withAvatar ? 'default' : color];
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{withAvatar ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('shrink-0 overflow-hidden rounded-full [&>*]:size-full', AVATAR_SIZE[size])}
|
||||
>
|
||||
{avatar}
|
||||
</span>
|
||||
) : dot ? (
|
||||
// The word next to it is the content; the dot is a color cue, so it is
|
||||
// decorative to a screen reader.
|
||||
<span aria-hidden className={cn('shrink-0 rounded-full bg-current', DOT_SIZE[size])} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
<span
|
||||
ref={labelRef}
|
||||
className={cn('min-w-0', maxWidth !== undefined && 'truncate')}
|
||||
style={maxWidth !== undefined ? { maxWidth } : undefined}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={closeLabel}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClose?.(event);
|
||||
}}
|
||||
className={cn(
|
||||
// §5 — the hot zone is the tag's full height; the icon is 12px on
|
||||
// both rungs. §6 — only the × reacts to hover, never the tag body.
|
||||
'btn-touch-hit relative inline-flex h-full shrink-0 items-center rounded-sm outline-none',
|
||||
'text-text-3 transition-colors hover:text-text-1 focus-visible:shadow-focus',
|
||||
'disabled:cursor-not-allowed disabled:text-text-4 disabled:hover:text-text-4',
|
||||
)}
|
||||
>
|
||||
<Outlined.Close className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const shell = cn(
|
||||
// §2 — 4px radius, one line, centered; the label is the only thing allowed
|
||||
// to shrink (min-w-0 on it) so an icon or the × never gets squeezed out.
|
||||
'inline-flex max-w-full items-center rounded-sm text-caption font-normal align-middle',
|
||||
SIZE[size],
|
||||
// §5 — a face is round, so the chip becomes a pill and hugs it at 4px.
|
||||
withAvatar && 'rounded-full pl-1',
|
||||
// §5 (2026-08-28) — the 「×」 side keeps the rung's own padding (8px on
|
||||
// medium, 6px on small). It used to shrink to 4px the way the avatar side
|
||||
// does; on screen that read as the 「×」 falling out of the chip, because
|
||||
// unlike an avatar the icon has no fill of its own to hold the edge.
|
||||
fill,
|
||||
className,
|
||||
);
|
||||
|
||||
const tag = checkable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isChecked}
|
||||
disabled={disabled}
|
||||
onClick={handleToggle}
|
||||
className={cn(
|
||||
shell,
|
||||
// §7 — 20px is not a touch target: a checkable tag takes the medium
|
||||
// rung on touch, on top of the invisible ≥44px hot zone every
|
||||
// interactive control gets.
|
||||
'btn-touch-hit relative cursor-pointer outline-none transition-colors focus-visible:shadow-focus',
|
||||
'coarse-pointer:h-6 coarse-pointer:px-2',
|
||||
'disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<span className={shell}>{body}</span>
|
||||
);
|
||||
|
||||
// §5 — same overflow rule as everywhere else: truncate, then let a hover
|
||||
// reveal the full text.
|
||||
if (maxWidth === undefined) return tag;
|
||||
return (
|
||||
<Tooltip content={children} disabled={!overflowing}>
|
||||
{tag}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tag };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Tag } from './Tag';
|
||||
export type { TagProps, TagSize, TagColor } from './Tag';
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import cn from '../../utils/cn';
|
||||
|
||||
/**
|
||||
* Tooltip — one line of plain text explaining a control (组件-Tooltip文字提示.md v1).
|
||||
*
|
||||
* What the component pins down and a page cannot restate: the solid dark
|
||||
* surface (§3), the 100ms hover delay with its 300ms skip window (§6), the
|
||||
* top-centre default with auto-flip (§5), the focusable hot zone a disabled
|
||||
* trigger needs to be hoverable at all (§7), and the top overlay tier so a
|
||||
* tooltip can appear over a dialog (落地 §6).
|
||||
*
|
||||
* Not this component: anything with a link, a button or a field inside — that
|
||||
* is a Popover, because keyboard focus never enters a tooltip and those
|
||||
* controls would not exist for keyboard users (§2). There is deliberately no
|
||||
* "rich tooltip" with a title and actions.
|
||||
*
|
||||
* Touch: nothing shows, by design (§8). Radix does not respond to touch and we
|
||||
* keep it that way — a long-press substitute collides with text selection and
|
||||
* the system menu. Which is the whole constraint on what may go in a tooltip:
|
||||
* only what costs the user nothing to miss.
|
||||
*/
|
||||
|
||||
/** §6 — 100ms to appear; another tooltip within 300ms skips the wait entirely. */
|
||||
const DELAY_DURATION = 100;
|
||||
const SKIP_DELAY_DURATION = 300;
|
||||
|
||||
/**
|
||||
* §3 — every tooltip carries an arrow; there is no arrow-less variant, so a
|
||||
* bubble always reads as belonging to one specific control. It is an 8px
|
||||
* square rotated 45°, so what shows is a triangle 8√2 ≈ 11px wide and half
|
||||
* that tall. Radix draws it as an SVG placed against the bubble rather than a
|
||||
* rotated box overlapping it, so the join stays seamless — the reason the
|
||||
* surface can be one flat color with no overlap to reconcile.
|
||||
*/
|
||||
const ARROW_WIDTH = 11;
|
||||
const ARROW_HEIGHT = 6;
|
||||
|
||||
/** §3 — 4px between trigger and bubble, measured from the arrow tip. */
|
||||
const TRIGGER_GAP = 4;
|
||||
|
||||
/**
|
||||
* §3 (2026-08-25) — solid dark surface, white text, in BOTH color modes.
|
||||
*
|
||||
* The surface is `--tooltip-bg`, not the grey ramp: the ramp inverts under
|
||||
* `.dark` and would leave white text on a near-white bubble. That token is dark
|
||||
* in both modes and merely lightens a step in dark, and its dark value lives at
|
||||
* the token definition — the component holds no hex.
|
||||
*
|
||||
* No alpha, deliberately: a translucent bubble seams where the arrow meets it,
|
||||
* makes contrast unverifiable against a backdrop nobody can predict, and turns
|
||||
* off subpixel antialiasing on 14px text. No backdrop blur either (root
|
||||
* AGENTS.md). `text-white` is literal on purpose — a flipping text token would
|
||||
* put dark text on this permanently dark surface.
|
||||
*/
|
||||
const SURFACE_CLASS = 'bg-tooltip text-white';
|
||||
const ARROW_STYLE: React.CSSProperties = { fill: 'rgb(var(--tooltip-bg))' };
|
||||
|
||||
/**
|
||||
* §3 — 14/22 body type, 6/12 padding (34px tall on one line, near enough to a
|
||||
* 32px control), 6px radius, wraps past 250px, popup shadow, no border: on a
|
||||
* dark surface a 1px edge has nothing to do. `z-tooltip` is the top of the four
|
||||
* overlay tiers (落地 §6) — a tooltip must be able to sit over a dialog.
|
||||
* Text stays selectable: §6 lets the pointer move into the bubble to copy it.
|
||||
*/
|
||||
const BUBBLE_CLASS =
|
||||
`${SURFACE_CLASS} z-tooltip max-w-[250px] break-words rounded-md px-3 py-1.5 text-body shadow-popup ` +
|
||||
'data-[state=delayed-open]:animate-tooltip-in data-[state=instant-open]:animate-tooltip-in ' +
|
||||
'data-[state=closed]:animate-tooltip-out motion-reduce:animate-none';
|
||||
|
||||
/**
|
||||
* True when a `TooltipProvider` is above us. Radix's own provider context is
|
||||
* private, and a provider nested inside every tooltip would shadow the app-root
|
||||
* one — killing the skip window (§6) that only a SHARED provider can give.
|
||||
*/
|
||||
const HasProviderContext = React.createContext(false);
|
||||
|
||||
export interface TooltipProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** §6 — override only with a reason; the defaults ARE the spec. */
|
||||
delayDuration?: number;
|
||||
skipDelayDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional, and worth mounting once at the app root: it is what makes the next
|
||||
* tooltip in a row of icon buttons appear instantly (§6) and what keeps only
|
||||
* one tooltip open at a time. Without it every `<Tooltip>` falls back to a
|
||||
* provider of its own, which is correct but has no shared skip window.
|
||||
*/
|
||||
export function TooltipProvider({
|
||||
children,
|
||||
delayDuration = DELAY_DURATION,
|
||||
skipDelayDuration = SKIP_DELAY_DURATION,
|
||||
}: TooltipProviderProps) {
|
||||
return (
|
||||
<HasProviderContext.Provider value={true}>
|
||||
<TooltipPrimitive.Provider
|
||||
delayDuration={delayDuration}
|
||||
skipDelayDuration={skipDelayDuration}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Provider>
|
||||
</HasProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TooltipProps {
|
||||
/** One line of plain text (§4). Nothing interactive — that would be a Popover. */
|
||||
content: React.ReactNode;
|
||||
/**
|
||||
* The trigger. §7: it should be something focusable (button, link, field) —
|
||||
* on a bare `span` a keyboard user can never reach the tooltip. A disabled
|
||||
* control is handled here, not by the page.
|
||||
*/
|
||||
children: React.ReactElement;
|
||||
/** §5 — 12 positions; top-centre by default, auto-flipped when space runs out. */
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
align?: 'start' | 'center' | 'end';
|
||||
/**
|
||||
* Keep the trigger, drop the tooltip. For "only when the text is actually
|
||||
* clipped" cases: nothing is mounted, so no stray portal and no bubble that
|
||||
* re-opens from the focus Radix hands back after a menu closes.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Extra classes on the bubble. The surface itself is not a per-page decision. */
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* §7 — a disabled control dispatches no pointer events, so its "why is this
|
||||
* off?" tooltip would never fire. Wrap it in a focusable hot zone that takes
|
||||
* the hover instead. Internal on purpose: the spec forbids pages hand-rolling
|
||||
* this each time.
|
||||
*/
|
||||
function useTriggerElement(children: React.ReactElement): React.ReactElement {
|
||||
const { disabled } = children.props as { disabled?: boolean };
|
||||
if (!disabled) {
|
||||
return children;
|
||||
}
|
||||
return (
|
||||
<span tabIndex={0} className="inline-flex cursor-not-allowed [&>*]:pointer-events-none">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
side = 'top',
|
||||
align = 'center',
|
||||
disabled = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
contentClassName,
|
||||
}: TooltipProps) {
|
||||
const hasProvider = React.useContext(HasProviderContext);
|
||||
const trigger = useTriggerElement(children);
|
||||
|
||||
const tooltip = (
|
||||
<TooltipPrimitive.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
|
||||
<TooltipPrimitive.Trigger asChild>{trigger}</TooltipPrimitive.Trigger>
|
||||
{!disabled && (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
side={side}
|
||||
align={align}
|
||||
// Radix measures to the bubble, so the arrow's own height has to be
|
||||
// added for the 4px of §3 to land at the arrow TIP.
|
||||
sideOffset={TRIGGER_GAP + ARROW_HEIGHT}
|
||||
className={cn(BUBBLE_CLASS, contentClassName)}
|
||||
>
|
||||
{content}
|
||||
<TooltipPrimitive.Arrow width={ARROW_WIDTH} height={ARROW_HEIGHT} style={ARROW_STYLE} />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)}
|
||||
</TooltipPrimitive.Root>
|
||||
);
|
||||
|
||||
// `disableHoverableContent` stays at Radix's `false`: turning it off would
|
||||
// break WCAG 1.4.13, which requires hover content to stay reachable.
|
||||
return hasProvider ? (
|
||||
tooltip
|
||||
) : (
|
||||
<TooltipProvider>{tooltip}</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Tooltip, TooltipProvider } from './Tooltip';
|
||||
export type { TooltipProps, TooltipProviderProps } from './Tooltip';
|
||||
Reference in New Issue
Block a user