Skip to content

Commit aff65fa

Browse files
authored
Merge pull request #3374 from Cinnnnnnndy/main
开源组件库优化
2 parents c39bfb3 + 020dc29 commit aff65fa

85 files changed

Lines changed: 4476 additions & 679 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎design-system/apps/design-lab/src/App.tsx‎

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { FoundationsPage } from "./pages/FoundationsPage";
2-
import { LabNavigation, LabSectionNavigation } from "./components/LabNavigation";
2+
import { LabNavigation, LabSectionNavigation, hasSectionNavigation } from "./components/LabNavigation";
3+
import { defaultComponent, libraryCategory } from "./componentNavigation";
34
import {
45
useEffect,
56
useLayoutEffect,
@@ -18,7 +19,7 @@ import { Button, Dialog, DialogBody, DialogClose, DialogDescription, DialogHeade
1819
type DensityMode,
1920
type IconName,
2021
} from "@openbitfun/ui";
21-
import { OpenBitFunMark } from "@openbitfun/ui/brand";
22+
import { OpenBitFunSolidMark } from "@openbitfun/ui/brand";
2223
import { BrandPage } from "./pages/BrandPage";
2324
import { componentRegistry } from "@openbitfun/ui/registry";
2425
import {
@@ -174,7 +175,6 @@ export function App() {
174175
const [sidebarOpen, setSidebarOpen] = useState(false);
175176
const searchInputRef = useRef<HTMLInputElement>(null);
176177
const searchResultsRef = useRef<HTMLDivElement>(null);
177-
const searchShortcut = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? "⌘ K" : "Ctrl K";
178178

179179
const themeName = getThemeDataName(colorScheme, contrast);
180180
// Document-level portals live outside the page ThemeRoot. Give them the same
@@ -413,13 +413,16 @@ export function App() {
413413
const activeComponent = route.page === "component"
414414
? componentRegistry.find((component) => component.name === route.componentName)
415415
: undefined;
416+
const librarySelection = route.page === "components" || route.page === "mobile" || route.page === "flow-chat"
417+
? defaultComponent(libraryCategory(route.page))
418+
: activeComponent;
416419

417420
return (
418421
<DesignSystemProvider colorScheme={colorScheme} contrast={contrast} density={density} locale={locale} nativeTooltipPolicy="application">
419422
<ThemeRoot
420423
className="lab-shell"
421424
data-page={route.page}
422-
data-component-library={["components", "component", "mobile", "flow-chat"].includes(route.page) || undefined}
425+
data-section-navigation={hasSectionNavigation(route.page) || undefined}
423426
data-sidebar-open={sidebarOpen || undefined}
424427
colorScheme={colorScheme}
425428
contrast={contrast}
@@ -433,23 +436,23 @@ export function App() {
433436
icon={<CatalogIcon glyph={Menu} />}
434437
onClick={() => setSidebarOpen(true)}
435438
/>
436-
<a className="design-wordmark-link" href="#overview" onClick={event => {
439+
<a className="design-wordmark-link" href="#overview" aria-label="OpenBitFun Design" onClick={event => {
437440
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
438441
event.preventDefault();
439442
navigate({ page: "overview" });
440443
}}>
441-
<OpenBitFunMark size={28} /><span>OpenBitFun<span className="design-wordmark-suffix"> Design</span></span>
444+
{/* The product site's header lockup: the app mark next to the wordmark. The name is
445+
visible now, and the link's accessible label still says which site it leads to. */}
446+
<OpenBitFunSolidMark className="design-app-mark" size={28} />
447+
<span className="design-wordmark-name">OpenBitFun</span>
442448
</a>
443449
<LabNavigation page={route.page} componentName={activeComponent?.name}
444450
onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
445451
<div className="lab-topbar-actions">
446-
<Button className="lab-search-trigger" size="sm" variant="fill"
447-
aria-label={t("search.label")} aria-haspopup="dialog"
448-
leadingIcon={<CatalogIcon name="search" />}
449-
onClick={() => setSearchOpen(true)}>
450-
<span className="lab-search-trigger-label">{t("search.placeholder")}</span>
451-
<KeyHint className="lab-search-shortcut">{searchShortcut}</KeyHint>
452-
</Button>
452+
{/* Icon only, like the two controls beside it: the accessible name still says what it
453+
opens, and the shortcut hint moved into the dialog it opens. */}
454+
<IconButton className="lab-search-trigger" aria-label={t("search.label")} aria-haspopup="dialog"
455+
icon={<CatalogIcon name="search" />} onClick={() => setSearchOpen(true)} />
453456
<IconButton
454457
aria-label={colorScheme === "light" ? t("theme.switchToDark") : t("theme.switchToLight")}
455458
icon={<CatalogIcon glyph={colorScheme === "light" ? Sun : Moon} />}
@@ -469,6 +472,7 @@ export function App() {
469472
<SearchField aria-label={t("search.label")} placeholder={t("search.placeholder")}
470473
ref={searchInputRef} value={searchQuery} onChange={event => setSearchQuery(event.target.value)}
471474
leadingIcon={<CatalogIcon name="search" />} clearLabel={t("design.clear")} onClear={() => setSearchQuery("")}
475+
shortcut={<KeyHint icon={<CatalogIcon name="command-mac" />}>K</KeyHint>}
472476
onKeyDown={event => {
473477
if (event.nativeEvent.isComposing) return;
474478
if (event.key === "ArrowDown") {
@@ -542,14 +546,20 @@ export function App() {
542546
</DialogHeader>
543547
<DialogBody className="lab-mobile-navigation">
544548
<LabNavigation page={route.page} onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
545-
<LabSectionNavigation page={route.page} onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
549+
<LabSectionNavigation componentName={librarySelection?.name} page={route.page} onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
546550
</DialogBody>
547551
</Sheet>
548552

549-
<LabSectionNavigation page={route.page} onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
553+
<LabSectionNavigation componentName={librarySelection?.name} page={route.page} onNavigate={page => page === "tokens" ? openTokenPage() : navigate({ page })} />
550554
<div className="lab-content">
551555
{route.page === "overview" && (
552-
<OverviewPage onNavigate={(target) => navigate({ page: target })} />
556+
<OverviewPage
557+
colorScheme={colorScheme}
558+
contrast={contrast}
559+
density={density}
560+
onNavigate={(target) => navigate({ page: target })}
561+
tokenOverrides={tokenOverrides}
562+
/>
553563
)}
554564

555565
{route.page === "foundations" && <FoundationsPage
@@ -568,7 +578,7 @@ export function App() {
568578

569579
{(["components", "component", "mobile", "flow-chat"].includes(route.page)) && (
570580
<ComponentsPage
571-
category={route.page === "mobile" ? "mobile" : route.page === "flow-chat" ? "flow-chat" : undefined}
581+
category={libraryCategory(route.page)}
572582
colorScheme={colorScheme}
573583
component={activeComponent}
574584
contrast={contrast}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { componentRegistry, type ComponentMeta } from "@openbitfun/ui/registry";
2+
3+
/* Which directory route belongs to which component category. The shell passes it to the page and
4+
the rail reads it back, so both agree on the route without duplicating the mapping. */
5+
export function libraryCategory(page: string): ComponentMeta["category"] | undefined {
6+
return page === "mobile" ? "mobile" : page === "flow-chat" ? "flow-chat" : undefined;
7+
}
8+
9+
/* The component a directory route opens with. Shared so the rail can mark the same row the page is
10+
showing when the route names no component of its own. */
11+
export function defaultComponent(category?: ComponentMeta["category"]): ComponentMeta | undefined {
12+
return componentRegistry.find(item => category ? item.category === category : item.name === "Button")
13+
?? componentRegistry[0];
14+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Icon as CatalogIcon, type IconName } from "@openbitfun/ui";
2+
import { Blocks, FileCode2, Layers3 } from "lucide-react";
3+
import { useI18n, type MessageKey } from "../i18n";
4+
5+
/* The usage block the guide opens with and the homepage closes on: three steps of the package
6+
contract beside the imports they add up to. One source, two homes — the homepage would otherwise
7+
carry a second copy of the snippet and drift from it. */
8+
9+
const steps: readonly {
10+
description: MessageKey;
11+
icon: typeof Layers3 | IconName;
12+
title: MessageKey;
13+
}[] = [
14+
{
15+
description: "gettingStarted.stepContractDescription",
16+
icon: Layers3,
17+
title: "gettingStarted.stepContractTitle",
18+
},
19+
{
20+
description: "gettingStarted.stepThemeDescription",
21+
icon: "palette",
22+
title: "gettingStarted.stepThemeTitle",
23+
},
24+
{
25+
description: "gettingStarted.stepComponentDescription",
26+
icon: Blocks,
27+
title: "gettingStarted.stepComponentTitle",
28+
},
29+
];
30+
31+
const snippet = `import "@openbitfun/theme-openbitfun/default.css";
32+
import "@openbitfun/ui/styles.css";
33+
import { Button, ThemeRoot } from "@openbitfun/ui";
34+
35+
export function App() {
36+
return (
37+
<ThemeRoot colorScheme="light" density="compact">
38+
<Button variant="primary">Continue</Button>
39+
</ThemeRoot>
40+
);
41+
}`;
42+
43+
interface GuideStepListProps {
44+
/** The guide puts the steps under its page title, so they are h2; a page that nests them under
45+
its own section title needs h3. One list, whichever level the outline asks for. */
46+
headingLevel?: 2 | 3;
47+
}
48+
49+
export function GuideStepList({ headingLevel = 2 }: GuideStepListProps) {
50+
const { t } = useI18n();
51+
const StepHeading = headingLevel === 3 ? "h3" : "h2";
52+
53+
return (
54+
<ol className="guide-step-list" aria-label={t("gettingStarted.stepsLabel")}>
55+
{steps.map(step => {
56+
const Icon = step.icon;
57+
return <li key={step.title}>
58+
<span className="guide-step-icon">{typeof Icon === "string" ? <CatalogIcon name={Icon} /> : <CatalogIcon glyph={Icon} />}</span>
59+
<div><StepHeading>{t(step.title)}</StepHeading><p>{t(step.description)}</p></div>
60+
</li>;
61+
})}
62+
</ol>
63+
);
64+
}
65+
66+
export function GuideCodeCard() {
67+
return (
68+
<div className="guide-code-card">
69+
<div><CatalogIcon glyph={FileCode2} size="sm" /><span>App.tsx</span></div>
70+
<pre><code>{snippet}</code></pre>
71+
</div>
72+
);
73+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import { Button, Icon as CatalogIcon, OverflowText, SearchField } from "@openbitfun/ui";
3+
import { componentRegistry } from "@openbitfun/ui/registry";
4+
import { useI18n } from "../i18n";
5+
import { getComponentCategoryLabel, getComponentDescription } from "../i18n/componentMetadata";
6+
7+
/* The component directory belongs to the navigation, not to a second index beside it: it hangs
8+
under the Components route in the rail, the way the Foundations chapters hang under theirs. It
9+
keeps its own search and its own scroll region, so a list this long never pushes the other
10+
routes out of the rail. */
11+
export function LabComponentIndex({ selected }: { selected?: string }) {
12+
const { t } = useI18n();
13+
const [query, setQuery] = useState("");
14+
const listRef = useRef<HTMLElement>(null);
15+
const activeLinkRef = useRef<HTMLAnchorElement>(null);
16+
const categories = Array.from(new Set(componentRegistry.map(component => component.category)));
17+
const normalizedQuery = query.trim().toLowerCase();
18+
const catalogComponents = componentRegistry.filter(component =>
19+
`${component.name} ${component.category} ${component.description} ${getComponentDescription(component.name, component.description, t)} ${getComponentCategoryLabel(component.category, t)}`.toLowerCase().includes(normalizedQuery),
20+
);
21+
22+
useEffect(() => {
23+
const list = listRef.current;
24+
const activeLink = activeLinkRef.current;
25+
if (!list) return;
26+
if (!activeLink) {
27+
list.scrollTop = 0;
28+
return;
29+
}
30+
// Keep the open component visible inside the directory without moving the page itself.
31+
const listBounds = list.getBoundingClientRect();
32+
const linkBounds = activeLink.getBoundingClientRect();
33+
if (linkBounds.top < listBounds.top) list.scrollTop += linkBounds.top - listBounds.top;
34+
else if (linkBounds.bottom > listBounds.bottom) list.scrollTop += linkBounds.bottom - listBounds.bottom;
35+
}, [selected, normalizedQuery]);
36+
37+
return (
38+
<div className="design-navigation-index">
39+
<SearchField aria-label={t("design.search")} placeholder={t("design.search")} value={query}
40+
onChange={event => setQuery(event.target.value)} clearLabel={t("design.clear")} onClear={() => setQuery("")}
41+
leadingIcon={<CatalogIcon name="search" />} />
42+
{/* The directory can be counted by looking, so the rail does not print the total — but a reader
43+
who cannot see the list still needs the filtered count as the query changes. The region stays
44+
live and comes off screen instead of out of the page. */}
45+
<span className="design-sr-only" role="status">{t("design.results", { count: catalogComponents.length })}</span>
46+
<nav className="design-library-list" aria-label={t("design.all")} ref={listRef}>
47+
{categories.map(group => {
48+
const items = catalogComponents.filter(item => item.category === group).sort((a, b) => a.name.localeCompare(b.name));
49+
if (!items.length) return null;
50+
return (
51+
<section className="design-library-group" key={group} aria-labelledby={`component-group-${group}`}>
52+
<h3 id={`component-group-${group}`}>{getComponentCategoryLabel(group, t)}</h3>
53+
<ul>
54+
{items.map(item => (
55+
<li key={item.name}>
56+
<a data-overflow-trigger className="design-library-link" href={`#component/${item.name.toLowerCase()}`}
57+
aria-current={selected === item.name ? "page" : undefined}
58+
ref={selected === item.name ? activeLinkRef : undefined}
59+
title={`${item.name} · ${getComponentDescription(item.name, item.description, t)}`}>
60+
<OverflowText>{item.name}</OverflowText>
61+
</a>
62+
</li>
63+
))}
64+
</ul>
65+
</section>
66+
);
67+
})}
68+
{catalogComponents.length === 0 && (
69+
<div className="design-library-empty">
70+
<p>{t("design.noResults")}</p>
71+
<Button size="sm" variant="text" onClick={() => setQuery("")}>{t("design.clear")}</Button>
72+
</div>
73+
)}
74+
</nav>
75+
</div>
76+
);
77+
}
Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1+
import { enUSMessages } from "../i18n/messages";
12
import { useI18n, type MessageKey } from "../i18n";
3+
import { Fragment, useSyncExternalStore } from "react";
4+
import { OpenBitFunSolidMark } from "@openbitfun/ui/brand";
5+
import {
6+
focusFoundationSection,
7+
foundationSections,
8+
getActiveFoundationSection,
9+
subscribeFoundationSection,
10+
} from "../foundationNavigation";
11+
import { LabComponentIndex } from "./LabComponentIndex";
212

313
type Page = "components" | "foundations" | "brand" | "colors" | "tokens" | "getting-started" | "resources" | "patterns" | "flow-chat" | "flow-chat-mock" | "mobile" | "subagent-ip";
414
type NavigationProps = { page: string; componentName?: string; onNavigate: (page: Page) => void };
@@ -7,16 +17,26 @@ function sectionFor(page: string) {
717
: ["foundations", "colors", "tokens", "getting-started", "resources"].includes(page) ? "foundations"
818
: page === "overview" ? undefined : "components";
919
}
20+
/** Whether this route has a section level at all. The shell needs to know before it renders, so
21+
* it can give the rail a column instead of a single centred one. */
22+
export function hasSectionNavigation(page: string) {
23+
return sectionFor(page) !== undefined;
24+
}
25+
/* Top-level site navigation, labelled from the English catalog in every locale: like the
26+
homepage slogan, these three names are the Lab's own identity rather than page copy, and a
27+
design system's canonical naming does not move with the reader's language. Everything
28+
inside the pages still follows the active locale. */
1029
export function LabNavigation({ page, onNavigate }: NavigationProps) {
1130
const { t } = useI18n();
1231
return <nav aria-label={t("app.pagesLabel")} className="lab-navigation design-primary-navigation">
1332
{(["components", "foundations", "brand"] as const).map(target => <a key={target} href={`#${target}`} aria-current={sectionFor(page) === target ? "true" : undefined}
14-
onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); onNavigate(target); }}>{t(`design.${target}`)}</a>)}
33+
onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); onNavigate(target); }}>{enUSMessages[`design.${target}`]}</a>)}
1534
</nav>;
1635
}
17-
export function LabSectionNavigation({ page, onNavigate }: NavigationProps) {
36+
export function LabSectionNavigation({ page, componentName, onNavigate }: NavigationProps) {
1837
const { t } = useI18n();
1938
const section = sectionFor(page);
39+
const activeChapter = useSyncExternalStore(subscribeFoundationSection, getActiveFoundationSection);
2040
const links: readonly [Page, MessageKey][] = section === "brand"
2141
? [["brand", "design.identity"], ["subagent-ip", "design.characters"]]
2242
: section === "foundations"
@@ -26,7 +46,34 @@ export function LabSectionNavigation({ page, onNavigate }: NavigationProps) {
2646
: [];
2747
if (!links.length) return null;
2848
return <nav className="design-secondary-navigation" aria-label={t(`design.${section!}`)}>
29-
{links.map(([target, label]) => <a key={target} href={`#${target}`} aria-current={page === target || (target === "components" && ["component", "mobile", "flow-chat"].includes(page)) ? "page" : undefined}
30-
onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); onNavigate(target); }}>{t(label)}</a>)}
49+
{links.map(([target, label]) => <Fragment key={target}>
50+
<a href={`#${target}`} aria-current={page === target || (target === "components" && ["component", "mobile", "flow-chat"].includes(page)) ? "page" : undefined}
51+
data-group={target === "foundations" || target === "components" ? "open" : undefined}
52+
className={target === "components" ? "design-navigation-library" : undefined}
53+
onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); onNavigate(target); }}>
54+
{t(label)}
55+
{/* The library's own row wears the mark — the same artwork and the same flattening the
56+
topbar's wordmark uses, one step down in size (the 16px control-icon step), and
57+
decorative: the row's text already names the route. */}
58+
{target === "components" && <OpenBitFunSolidMark className="design-app-mark" size={16} />}
59+
</a>
60+
{/* Both indexes hang under their route: one tree per section, so the rail and the page never
61+
show two competing lists of the same thing. */}
62+
{target === "components" && <LabComponentIndex selected={componentName} />}
63+
{target === "foundations" && <ul className="design-navigation-chapters" aria-label={t("foundations.contents")}>
64+
{foundationSections.map(chapter => <li key={chapter.id}>
65+
<a href={`#foundations/${chapter.id}`}
66+
aria-current={page === "foundations" && activeChapter === chapter.id ? "location" : undefined}
67+
onClick={event => {
68+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
69+
// Same chapter as the address bar: no hash change follows, so move the reader here.
70+
if (window.location.hash === `#foundations/${chapter.id}`) {
71+
event.preventDefault();
72+
focusFoundationSection(chapter.id);
73+
}
74+
}}>{t(chapter.label)}</a>
75+
</li>)}
76+
</ul>}
77+
</Fragment>)}
3178
</nav>;
3279
}

0 commit comments

Comments
 (0)