aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.tsx38
-rw-r--r--src/components/collapsed-navigation.tsx54
-rw-r--r--src/components/form-examples/color-file-fields.tsx69
-rw-r--r--src/components/form-examples/date-number-fields.tsx45
-rw-r--r--src/components/form-examples/field-grid.tsx10
-rw-r--r--src/components/form-examples/form-examples.module.css136
-rw-r--r--src/components/form-examples/form-examples.tsx91
-rw-r--r--src/components/form-examples/selection-fields.tsx68
-rw-r--r--src/components/form-examples/text-fields.tsx28
-rw-r--r--src/components/global-settings.module.css15
-rw-r--r--src/components/global-settings.tsx41
-rw-r--r--src/components/layout-basic.module.css156
-rw-r--r--src/components/layout-basic.tsx67
-rw-r--r--src/components/menu-icon.module.css6
-rw-r--r--src/components/mobile-navigation.module.css53
-rw-r--r--src/components/mobile-navigation.tsx64
-rw-r--r--src/components/sidebar-menu.tsx35
-rw-r--r--src/components/ui-kit/actions-feedback.tsx101
-rw-r--r--src/components/ui-kit/navigation-data.tsx75
-rw-r--r--src/components/ui-kit/ui-kit.module.css80
-rw-r--r--src/components/ui-kit/ui-kit.tsx6
-rw-r--r--src/index.css74
-rw-r--r--src/main.tsx12
-rw-r--r--src/page-about.tsx16
-rw-r--r--src/page-form.module.css23
-rw-r--r--src/page-form.tsx18
-rw-r--r--src/page-home.tsx11
-rw-r--r--src/page-not-found.tsx14
-rw-r--r--src/page-ui-kit.module.css23
-rw-r--r--src/page-ui-kit.tsx13
-rw-r--r--src/page.module.css9
-rw-r--r--src/route-config.ts82
-rw-r--r--src/router.tsx11
-rw-r--r--src/theme.ts25
34 files changed, 1569 insertions, 0 deletions
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..716d5ad
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,38 @@
+import { useLayoutEffect, useState } from 'react'
+import { Outlet, useHref, useNavigate, type NavigateOptions } from 'react-router'
+import { Provider } from '@react-spectrum/s2/Provider'
+import BasicLayout from './components/layout-basic'
+import { getInitialColorScheme, ThemeContext, themeStorageKey } from './theme'
+
+// Configure the type of the `routerOptions` prop on all React Spectrum components.
+declare module '@react-spectrum/s2/Provider' {
+ interface RouterConfig {
+ routerOptions: NavigateOptions
+ }
+}
+
+function App() {
+ const navigate = useNavigate()
+ const [colorScheme, setColorScheme] = useState(getInitialColorScheme)
+
+ useLayoutEffect(() => {
+ document.documentElement.dataset.theme = colorScheme
+ try {
+ localStorage.setItem(themeStorageKey, colorScheme)
+ } catch {
+ // Keep applying the theme even when browser storage is unavailable.
+ }
+ }, [colorScheme])
+
+ return (
+ <ThemeContext value={{ colorScheme, setColorScheme }}>
+ <Provider background="base" colorScheme={colorScheme} router={{ navigate, useHref }}>
+ <BasicLayout>
+ <Outlet />
+ </BasicLayout>
+ </Provider>
+ </ThemeContext>
+ )
+}
+
+export default App
diff --git a/src/components/collapsed-navigation.tsx b/src/components/collapsed-navigation.tsx
new file mode 100644
index 0000000..1748317
--- /dev/null
+++ b/src/components/collapsed-navigation.tsx
@@ -0,0 +1,54 @@
+import { NavLink } from 'react-router'
+import { ActionButton } from '@react-spectrum/s2/ActionButton'
+import { Menu, MenuItem, MenuTrigger, SubmenuTrigger, Text } from '@react-spectrum/s2/Menu'
+import { Tooltip, TooltipTrigger } from '@react-spectrum/s2/Tooltip'
+import { menuItems, type NavigationItem } from '../route-config'
+import iconStyles from './menu-icon.module.css'
+import styles from './layout-basic.module.css'
+
+function containsPage(item: NavigationItem, pageId?: string): boolean {
+ return item.id === pageId || !!item.children?.some((child) => containsPage(child, pageId))
+}
+
+function renderMenuItem(item: NavigationItem) {
+ const Icon = item.Icon
+ const content = <>{Icon && <Icon />}<Text>{item.title}</Text></>
+
+ return item.children ? (
+ <SubmenuTrigger key={item.id}>
+ <MenuItem id={item.id} textValue={item.title}>{content}</MenuItem>
+ <Menu aria-label={item.title}>{item.children.map(renderMenuItem)}</Menu>
+ </SubmenuTrigger>
+ ) : (
+ <MenuItem key={item.id} id={item.id} href={item.path} textValue={item.title}>{content}</MenuItem>
+ )
+}
+
+function CollapsedNavigation({ activePageId }: { activePageId?: string }) {
+ return (
+ <div className={styles.iconNavigation}>
+ {menuItems.map((item) => {
+ const Icon = item.Icon
+ const icon = Icon ? <Icon UNSAFE_className={iconStyles.icon} /> : item.title.slice(0, 1)
+
+ return item.children ? (
+ <div key={item.id} className={`${styles.iconNavigationGroup}${containsPage(item, activePageId) ? ` ${styles.isActive}` : ''}`}>
+ <MenuTrigger direction="right" align="start">
+ <TooltipTrigger placement="right">
+ <ActionButton aria-label={item.title} isQuiet>{icon}</ActionButton>
+ <Tooltip>{item.title}</Tooltip>
+ </TooltipTrigger>
+ <Menu aria-label={item.title}>{item.children.map(renderMenuItem)}</Menu>
+ </MenuTrigger>
+ </div>
+ ) : (
+ <NavLink key={item.id} className={styles.iconNavigationLink} to={item.path} end aria-label={item.title} title={item.title}>
+ {icon}
+ </NavLink>
+ )
+ })}
+ </div>
+ )
+}
+
+export default CollapsedNavigation
diff --git a/src/components/form-examples/color-file-fields.tsx b/src/components/form-examples/color-file-fields.tsx
new file mode 100644
index 0000000..e9d0e74
--- /dev/null
+++ b/src/components/form-examples/color-file-fields.tsx
@@ -0,0 +1,69 @@
+import FieldGrid from './field-grid'
+import { useState } from 'react'
+import { ColorArea, parseColor } from '@react-spectrum/s2/ColorArea'
+import { ColorField } from '@react-spectrum/s2/ColorField'
+import { ColorSlider } from '@react-spectrum/s2/ColorSlider'
+import { ColorWheel } from '@react-spectrum/s2/ColorWheel'
+import { ColorSwatch, ColorSwatchPicker } from '@react-spectrum/s2/ColorSwatchPicker'
+import { DropZone } from '@react-spectrum/s2/DropZone'
+import { FileTrigger } from '@react-spectrum/s2/FileTrigger'
+import { Button } from '@react-spectrum/s2/Button'
+import styles from './form-examples.module.css'
+
+export default function ColorFileFields({ disabled }: { disabled: boolean }) {
+ const [color, setColor] = useState(() => parseColor('hsl(265, 75%, 60%)'))
+ const [files, setFiles] = useState<File[]>([])
+ const [fileError, setFileError] = useState('')
+ return (
+ <>
+ <section className={styles.section} aria-labelledby="color-fields-title">
+ <h2 id="color-fields-title">颜色选择</h2>
+ <p>输入框、色板、色轮和滑块共享同一个颜色值。</p>
+ <FieldGrid>
+ <ColorField label="颜色值" value={color} onChange={(next) => { if (next) setColor(next) }} />
+ <ColorSlider isDisabled={disabled} label="色相" channel="hue" colorSpace="hsl" value={color} onChange={setColor} />
+ <ColorSlider isDisabled={disabled} label="透明度" channel="alpha" value={color} onChange={setColor} />
+ <div className={styles.stack}>
+ <span>预设色板</span>
+ <ColorSwatchPicker value={color} onChange={setColor}>
+ <ColorSwatch color="#8b5cf6" /><ColorSwatch color="#3b82f6" /><ColorSwatch color="#22c55e" /><ColorSwatch color="#f97316" />
+ </ColorSwatchPicker>
+ </div>
+ </FieldGrid>
+ <div className={styles.calendars}>
+ <div className={styles.stack}><span id="color-area-label">二维颜色面板</span><ColorArea isDisabled={disabled} aria-labelledby="color-area-label" colorSpace="hsl" xChannel="saturation" yChannel="lightness" value={color} onChange={setColor} /></div>
+ <div className={styles.stack}><span id="color-wheel-label">色轮</span><ColorWheel isDisabled={disabled} aria-labelledby="color-wheel-label" value={color} onChange={setColor} /></div>
+ </div>
+ <input type="hidden" name="color" value={color.toString('hexa')} />
+ <output className={styles.output}>{color.toString('hexa')}</output>
+ </section>
+ <section className={styles.section} aria-labelledby="file-fields-title">
+ <h2 id="file-fields-title">文件选择与拖放</h2>
+ <DropZone aria-label="附件拖放区域" getDropOperation={(types) => !disabled && types.has('Files') ? 'copy' : 'cancel'}
+ onDrop={async (event) => {
+ try {
+ const next = await Promise.all(event.items.filter((item) => item.kind === 'file').map((item) => item.getFile()))
+ setFiles(next)
+ setFileError('')
+ } catch {
+ setFileError('无法读取文件,请重新选择。')
+ }
+ }}>
+ <div className={styles.dropContent}>
+ <p>将文件拖放到这里,或从设备中选择。</p>
+ <FileTrigger allowsMultiple onSelect={(selected) => { if (selected) { setFiles(Array.from(selected)); setFileError('') } }}>
+ <Button type="button" variant="secondary">选择文件</Button>
+ </FileTrigger>
+ <p>仅在本页预览文件信息,不上传。</p>
+ </div>
+ </DropZone>
+ {fileError && <p role="alert">{fileError}</p>}
+ <div aria-live="polite">
+ {files.length ? <ul>{files.map((file, index) => <li key={`${file.name}-${index}`}>{file.name} · {Math.ceil(file.size / 1024)} KB</li>)}</ul> : <p>尚未选择文件。</p>}
+ </div>
+ {files.length > 0 && <Button type="button" variant="secondary" onPress={() => setFiles([])}>清空文件</Button>}
+ <input type="hidden" name="files" value={JSON.stringify(files.map(({ name, size, type }) => ({ name, size, type })))} />
+ </section>
+ </>
+ )
+}
diff --git a/src/components/form-examples/date-number-fields.tsx b/src/components/form-examples/date-number-fields.tsx
new file mode 100644
index 0000000..f2c489f
--- /dev/null
+++ b/src/components/form-examples/date-number-fields.tsx
@@ -0,0 +1,45 @@
+import FieldGrid from './field-grid'
+import { useState } from 'react'
+import { NumberField } from '@react-spectrum/s2/NumberField'
+import { Slider } from '@react-spectrum/s2/Slider'
+import { RangeSlider } from '@react-spectrum/s2/RangeSlider'
+import { DateField } from '@react-spectrum/s2/DateField'
+import { TimeField } from '@react-spectrum/s2/TimeField'
+import { DatePicker } from '@react-spectrum/s2/DatePicker'
+import { DateRangePicker } from '@react-spectrum/s2/DateRangePicker'
+import { Calendar } from '@react-spectrum/s2/Calendar'
+import { RangeCalendar, type DateRange } from '@react-spectrum/s2/RangeCalendar'
+import styles from './form-examples.module.css'
+
+export default function DateNumberFields({ disabled }: { disabled: boolean }) {
+ const [day, setDay] = useState('')
+ const [range, setRange] = useState<DateRange | null>(null)
+ return (
+ <>
+ <section className={styles.section} aria-labelledby="number-fields-title">
+ <h2 id="number-fields-title">数字与滑块</h2>
+ <FieldGrid>
+ <NumberField name="quantity" label="数量" defaultValue={2} minValue={1} maxValue={20} step={1} description="范围 1–20,支持步进按钮。" />
+ <NumberField name="budget" label="预算" defaultValue={1000} minValue={0} step={100} formatOptions={{ style: 'currency', currency: 'CNY' }} />
+ <NumberField name="ratio" label="百分比" defaultValue={0.5} minValue={0} maxValue={1} step={0.05} formatOptions={{ style: 'percent' }} />
+ <Slider name="volume" label="音量" defaultValue={40} minValue={0} maxValue={100} />
+ <RangeSlider startName="priceMin" endName="priceMax" label="价格区间" defaultValue={{ start: 20, end: 80 }} minValue={0} maxValue={100} />
+ </FieldGrid>
+ </section>
+ <section className={styles.section} aria-labelledby="date-fields-title">
+ <h2 id="date-fields-title">日期与时间</h2>
+ <FieldGrid>
+ <DateField name="date" label="分段日期输入" />
+ <TimeField name="time" label="时间输入" hourCycle={24} granularity="minute" />
+ <DatePicker name="appointment" label="日期选择器" />
+ <DatePicker name="appointmentTime" label="日期和时间" granularity="minute" hourCycle={24} />
+ <DateRangePicker startName="tripStart" endName="tripEnd" label="日期范围" />
+ </FieldGrid>
+ <div className={styles.calendars}>
+ <div className={styles.stack}><span id="calendar-label">独立日历</span><Calendar isDisabled={disabled} aria-labelledby="calendar-label" onChange={(value) => setDay(value?.toString() ?? '')} /><input type="hidden" name="calendar" value={day} /></div>
+ <div className={styles.stack}><span id="range-calendar-label">独立范围日历</span><RangeCalendar isDisabled={disabled} aria-labelledby="range-calendar-label" value={range} onChange={setRange} /><input type="hidden" name="calendarStart" value={range?.start.toString() ?? ''} /><input type="hidden" name="calendarEnd" value={range?.end.toString() ?? ''} /></div>
+ </div>
+ </section>
+ </>
+ )
+}
diff --git a/src/components/form-examples/field-grid.tsx b/src/components/form-examples/field-grid.tsx
new file mode 100644
index 0000000..7dbb83d
--- /dev/null
+++ b/src/components/form-examples/field-grid.tsx
@@ -0,0 +1,10 @@
+import { Children, type ReactNode } from 'react'
+import styles from './form-examples.module.css'
+
+export default function FieldGrid({ children }: { children: ReactNode }) {
+ return (
+ <div className={styles.grid}>
+ {Children.map(children, (child) => <div className={styles.field}>{child}</div>)}
+ </div>
+ )
+}
diff --git a/src/components/form-examples/form-examples.module.css b/src/components/form-examples/form-examples.module.css
new file mode 100644
index 0000000..d63db91
--- /dev/null
+++ b/src/components/form-examples/form-examples.module.css
@@ -0,0 +1,136 @@
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: end;
+ gap: 20px;
+ padding: 20px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ margin-bottom: 24px;
+}
+
+.form.form {
+ display: block;
+}
+
+.fields {
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.section,
+.result {
+ display: grid;
+ gap: 24px;
+ min-width: 0;
+ padding: 24px;
+ margin-bottom: 24px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+}
+
+.section h2,
+.result h2 {
+ font-size: 20px;
+ margin: 0;
+}
+
+.section p,
+.result p {
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
+ align-items: start;
+ gap: 28px;
+ min-width: 0;
+}
+
+.field {
+ display: grid;
+ grid-template-columns: [field] minmax(0, 1fr);
+ align-content: start;
+ min-width: 0;
+}
+
+.grid .field > * {
+ grid-column: 1 / -1;
+ min-width: 0;
+}
+
+.sideLabels .grid {
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 480px), 1fr));
+}
+
+.sideLabels .field {
+ grid-template-columns: [label] minmax(0, 10rem) [field] minmax(0, 1fr);
+ column-gap: 16px;
+}
+
+.layoutHint {
+ margin-bottom: 20px;
+ font-size: 14px;
+}
+
+.fields:disabled {
+ opacity: 0.6;
+}
+
+.stack {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ min-width: 0;
+ font-size: 14px;
+}
+
+.calendars {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 32px;
+ min-width: 0;
+ overflow-x: auto;
+}
+
+.dropContent {
+ padding: 24px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ text-align: center;
+}
+
+.output,
+.result pre {
+ margin: 0;
+ padding: 16px;
+ border-radius: 8px;
+ background: var(--social-bg);
+ font-family: monospace;
+ font-size: 13px;
+ overflow: auto;
+}
+
+.srOnly {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+@media (max-width: 640px) {
+ .section,
+ .result {
+ padding: 16px;
+ }
+}
diff --git a/src/components/form-examples/form-examples.tsx b/src/components/form-examples/form-examples.tsx
new file mode 100644
index 0000000..e1dd6c8
--- /dev/null
+++ b/src/components/form-examples/form-examples.tsx
@@ -0,0 +1,91 @@
+import { useLayoutEffect, useRef, useState } from 'react'
+import { Form } from '@react-spectrum/s2/Form'
+import { Button, ButtonGroup } from '@react-spectrum/s2/ButtonGroup'
+import { Picker, PickerItem } from '@react-spectrum/s2/Picker'
+import { Switch } from '@react-spectrum/s2/Switch'
+import { Checkbox } from '@react-spectrum/s2/Checkbox'
+import TextFields from './text-fields'
+import SelectionFields from './selection-fields'
+import DateNumberFields from './date-number-fields'
+import ColorFileFields from './color-file-fields'
+import styles from './form-examples.module.css'
+
+function DemoForm({ onReset }: { onReset: () => void }) {
+ const containerRef = useRef<HTMLDivElement>(null)
+ const [isCompact, setIsCompact] = useState(false)
+ const [size, setSize] = useState<'S' | 'M' | 'L' | 'XL'>('M')
+ const [sideLabels, setSideLabels] = useState(false)
+ const [disabled, setDisabled] = useState(false)
+ const [errors, setErrors] = useState<Record<string, string>>({})
+ const [result, setResult] = useState<Record<string, string | string[]> | null>(null)
+
+ useLayoutEffect(() => {
+ const container = containerRef.current
+ if (!container) return
+ const update = () => setIsCompact(container.clientWidth < 560)
+ update()
+ const observer = new ResizeObserver(update)
+ observer.observe(container)
+ return () => observer.disconnect()
+ }, [])
+
+ const useSideLabels = sideLabels && !isCompact
+
+ return (
+ <div ref={containerRef}>
+ <div className={styles.controls}>
+ <Picker label="控件尺寸" selectedKey={size} onSelectionChange={(key) => { if (key === 'S' || key === 'M' || key === 'L' || key === 'XL') setSize(key) }}>
+ <PickerItem id="S">S</PickerItem><PickerItem id="M">M</PickerItem><PickerItem id="L">L</PickerItem><PickerItem id="XL">XL</PickerItem>
+ </Picker>
+ <Switch isSelected={sideLabels} onChange={setSideLabels}>标签放在侧边</Switch>
+ <Switch isSelected={disabled} onChange={setDisabled}>禁用表单</Switch>
+ </div>
+ {sideLabels && isCompact && <p className={styles.layoutHint}>当前宽度较窄,已自动使用顶部标签。</p>}
+ <Form UNSAFE_className={`${styles.form}${useSideLabels ? ` ${styles.sideLabels}` : ''}`} size={size} isDisabled={disabled} labelPosition={useSideLabels ? 'side' : 'top'} necessityIndicator="label"
+ validationBehavior="native" validationErrors={errors}
+ onReset={(event) => { event.preventDefault(); onReset() }}
+ onSubmit={(event) => {
+ event.preventDefault()
+ const data = new FormData(event.currentTarget)
+ if (data.get('username') === 'admin') {
+ setErrors({ username: '该用户名已被占用,请换一个。(本地模拟业务校验)' })
+ setResult(null)
+ return
+ }
+ setErrors({})
+ const entries = [...new Set(data.keys())].map((key) => {
+ const values = data.getAll(key).map((value) => typeof value === 'string' ? value : value.name)
+ return [key, key === 'password' && values[0] ? '••••••••' : values.length === 1 ? values[0] : values]
+ })
+ setResult(Object.fromEntries(entries))
+ }}>
+ <fieldset className={styles.fields} disabled={disabled} inert={disabled}>
+ <legend className={styles.srOnly}>Spectrum 表单示例</legend>
+ <TextFields />
+ <SelectionFields disabled={disabled} />
+ <DateNumberFields disabled={disabled} />
+ <ColorFileFields disabled={disabled} />
+ <section className={styles.section} aria-labelledby="submit-title">
+ <h2 id="submit-title">校验与提交</h2>
+ <p>用户名和邮箱为必填项。可以直接提交,体验错误提示和焦点定位。</p>
+ <Checkbox name="agreement" value="accepted" isRequired>我已了解这是本地表单示例</Checkbox>
+ <ButtonGroup>
+ <Button type="submit" variant="primary">提交并预览</Button>
+ <Button type="reset" variant="secondary">重置全部</Button>
+ </ButtonGroup>
+ </section>
+ </fieldset>
+ </Form>
+ <section className={styles.result} aria-labelledby="result-title">
+ <h2 id="result-title">提交结果</h2>
+ <p role="status">{result ? '校验通过,以下为本次提交的数据。' : '填写表单并提交后,在这里查看结果。'}</p>
+ {result && <pre tabIndex={0}>{JSON.stringify(result, null, 2)}</pre>}
+ </section>
+ </div>
+ )
+}
+
+export default function FormExamples() {
+ const [version, setVersion] = useState(0)
+ return <DemoForm key={version} onReset={() => setVersion((value) => value + 1)} />
+}
diff --git a/src/components/form-examples/selection-fields.tsx b/src/components/form-examples/selection-fields.tsx
new file mode 100644
index 0000000..84e1ec1
--- /dev/null
+++ b/src/components/form-examples/selection-fields.tsx
@@ -0,0 +1,68 @@
+import FieldGrid from './field-grid'
+import { useState } from 'react'
+import { Checkbox, CheckboxGroup } from '@react-spectrum/s2/CheckboxGroup'
+import { Radio, RadioGroup } from '@react-spectrum/s2/RadioGroup'
+import { Switch } from '@react-spectrum/s2/Switch'
+import { Picker, PickerItem } from '@react-spectrum/s2/Picker'
+import { ComboBox, ComboBoxItem } from '@react-spectrum/s2/ComboBox'
+import { SelectBox, SelectBoxGroup, Text, type Selection } from '@react-spectrum/s2/SelectBoxGroup'
+import { SegmentedControl, SegmentedControlItem, type Key } from '@react-spectrum/s2/SegmentedControl'
+import { ToggleButton, ToggleButtonGroup } from '@react-spectrum/s2/ToggleButtonGroup'
+import styles from './form-examples.module.css'
+
+export default function SelectionFields({ disabled }: { disabled: boolean }) {
+ const [plan, setPlan] = useState<Selection>(new Set(['personal']))
+ const [view, setView] = useState<Key>('list')
+ const [format, setFormat] = useState<Selection>(new Set())
+ const [favorite, setFavorite] = useState(false)
+ const [partial, setPartial] = useState(true)
+ const [partialSelected, setPartialSelected] = useState(false)
+ return (
+ <section className={styles.section} aria-labelledby="selection-fields-title">
+ <h2 id="selection-fields-title">选择控件</h2>
+ <FieldGrid>
+ <Picker name="region" label="下拉单选" defaultSelectedKey="shanghai">
+ <PickerItem id="shanghai">上海</PickerItem><PickerItem id="beijing">北京</PickerItem><PickerItem id="shenzhen">深圳</PickerItem>
+ </Picker>
+ <ComboBox name="language" label="可搜索下拉框" defaultItems={[{ id: 'ts', name: 'TypeScript' }, { id: 'js', name: 'JavaScript' }, { id: 'py', name: 'Python' }]}>
+ {(item) => <ComboBoxItem id={item.id}>{item.name}</ComboBoxItem>}
+ </ComboBox>
+ <RadioGroup name="contact" label="单选组" defaultValue="email" orientation="horizontal">
+ <Radio value="email">邮件</Radio><Radio value="phone">电话</Radio>
+ </RadioGroup>
+ <CheckboxGroup name="interests" label="复选框组" defaultValue={['design']}>
+ <Checkbox value="design">设计</Checkbox><Checkbox value="development">开发</Checkbox><Checkbox value="research">研究</Checkbox>
+ </CheckboxGroup>
+ <div className={styles.stack}>
+ <Checkbox name="newsletter" value="yes">订阅邮件</Checkbox>
+ <Checkbox name="partialSelection" value="yes" isIndeterminate={partial} isSelected={partialSelected} onChange={(value) => { setPartial(false); setPartialSelected(value) }}>部分选中状态</Checkbox>
+ <Switch name="notifications" value="on" defaultSelected>开启通知</Switch>
+ </div>
+ <div className={styles.stack}>
+ <span id="view-label">分段选择</span>
+ <SegmentedControl aria-labelledby="view-label" selectedKey={view} onSelectionChange={setView}>
+ <SegmentedControlItem isDisabled={disabled} id="list">列表</SegmentedControlItem><SegmentedControlItem isDisabled={disabled} id="grid">网格</SegmentedControlItem>
+ </SegmentedControl>
+ <input type="hidden" name="view" value={String(view)} />
+ </div>
+ <div className={styles.stack}>
+ <span id="format-label">切换按钮组</span>
+ <ToggleButtonGroup aria-labelledby="format-label" selectionMode="multiple" selectedKeys={format} onSelectionChange={setFormat}>
+ <ToggleButton id="bold">粗体</ToggleButton><ToggleButton id="italic">斜体</ToggleButton>
+ </ToggleButtonGroup>
+ <input type="hidden" name="format" value={JSON.stringify(format === 'all' ? ['bold', 'italic'] : [...format])} />
+ <ToggleButton isSelected={favorite} onChange={setFavorite}>收藏</ToggleButton>
+ <input type="hidden" name="favorite" value={String(favorite)} />
+ </div>
+ </FieldGrid>
+ <div className={styles.stack}>
+ <span id="plan-label">卡片选择</span>
+ <SelectBoxGroup isDisabled={disabled} aria-labelledby="plan-label" selectedKeys={plan} onSelectionChange={setPlan}>
+ <SelectBox id="personal" textValue="个人版"><Text slot="label">个人版</Text><Text slot="description">个人工作空间</Text></SelectBox>
+ <SelectBox id="team" textValue="团队版"><Text slot="label">团队版</Text><Text slot="description">多人协作空间</Text></SelectBox>
+ </SelectBoxGroup>
+ <input type="hidden" name="plan" value={plan === 'all' ? 'all' : [...plan].join(',')} />
+ </div>
+ </section>
+ )
+}
diff --git a/src/components/form-examples/text-fields.tsx b/src/components/form-examples/text-fields.tsx
new file mode 100644
index 0000000..59cdd28
--- /dev/null
+++ b/src/components/form-examples/text-fields.tsx
@@ -0,0 +1,28 @@
+import FieldGrid from './field-grid'
+import { TextField } from '@react-spectrum/s2/TextField'
+import { TextArea } from '@react-spectrum/s2/TextArea'
+import { SearchField } from '@react-spectrum/s2/SearchField'
+import { ContextualHelp, Heading, Content } from '@react-spectrum/s2/ContextualHelp'
+import styles from './form-examples.module.css'
+
+export default function TextFields() {
+ return (
+ <section className={styles.section} aria-labelledby="text-fields-title">
+ <h2 id="text-fields-title">文本输入</h2>
+ <FieldGrid>
+ <TextField name="username" label="用户名" isRequired minLength={3} maxLength={20}
+ description="3–20 个字符;输入 admin 可体验提交后的业务校验。"
+ contextualHelp={<ContextualHelp><Heading>用户名</Heading><Content>可使用字母、数字和下划线。</Content></ContextualHelp>}
+ validate={(value) => value && !/^[a-zA-Z0-9_]+$/.test(value) ? '只能包含字母、数字和下划线。' : null} />
+ <TextField name="email" label="邮箱" type="email" isRequired placeholder="name@example.com" />
+ <TextField name="password" label="密码" type="password" minLength={8} description="选填,至少 8 位;提交预览中隐藏密码。" />
+ <TextField name="website" label="网址" type="url" placeholder="https://example.com" />
+ <TextField name="phone" label="电话" type="tel" placeholder="输入联系电话" />
+ <SearchField name="keyword" label="搜索关键词" description="支持输入与一键清空。" />
+ <TextArea name="bio" label="多行简介" maxLength={200} description="最多 200 个字符。" />
+ <TextField name="reference" label="只读字段" value="FORM-DEMO-001" isReadOnly />
+ <TextField label="禁用字段" defaultValue="不可编辑,也不参与提交" isDisabled />
+ </FieldGrid>
+ </section>
+ )
+}
diff --git a/src/components/global-settings.module.css b/src/components/global-settings.module.css
new file mode 100644
index 0000000..49b8f27
--- /dev/null
+++ b/src/components/global-settings.module.css
@@ -0,0 +1,15 @@
+.settings {
+ box-sizing: border-box;
+ width: min(260px, calc(100vw - 48px));
+ padding: 16px;
+ display: grid;
+ gap: 20px;
+ text-align: start;
+}
+
+.settings .title {
+ margin: 0;
+ font-size: 18px;
+ line-height: 1.4;
+ color: var(--text-h);
+}
diff --git a/src/components/global-settings.tsx b/src/components/global-settings.tsx
new file mode 100644
index 0000000..4e9c507
--- /dev/null
+++ b/src/components/global-settings.tsx
@@ -0,0 +1,41 @@
+import { useId } from 'react'
+import { ActionButton } from '@react-spectrum/s2/ActionButton'
+import { DialogTrigger, Popover } from '@react-spectrum/s2/Popover'
+import { Radio, RadioGroup } from '@react-spectrum/s2/RadioGroup'
+import { Tooltip, TooltipTrigger } from '@react-spectrum/s2/Tooltip'
+import SettingsIcon from '@react-spectrum/s2/icons/Settings'
+import { useTheme } from '../theme'
+import styles from './global-settings.module.css'
+
+function GlobalSettings() {
+ const titleId = useId()
+ const { colorScheme, setColorScheme } = useTheme()
+
+ return (
+ <DialogTrigger>
+ <TooltipTrigger>
+ <ActionButton aria-label="全局设置" isQuiet>
+ <SettingsIcon />
+ </ActionButton>
+ <Tooltip>全局设置</Tooltip>
+ </TooltipTrigger>
+ <Popover placement="bottom end" aria-labelledby={titleId}>
+ <div className={styles.settings}>
+ <h2 id={titleId} className={styles.title}>全局设置</h2>
+ <RadioGroup
+ label="外观模式"
+ value={colorScheme}
+ onChange={(value) => {
+ if (value === 'light' || value === 'dark') setColorScheme(value)
+ }}
+ >
+ <Radio value="light">亮色</Radio>
+ <Radio value="dark">暗色</Radio>
+ </RadioGroup>
+ </div>
+ </Popover>
+ </DialogTrigger>
+ )
+}
+
+export default GlobalSettings
diff --git a/src/components/layout-basic.module.css b/src/components/layout-basic.module.css
new file mode 100644
index 0000000..cc29f98
--- /dev/null
+++ b/src/components/layout-basic.module.css
@@ -0,0 +1,156 @@
+.appLayout {
+ --navigation-inset: 12px;
+ /* SideNav's leading gutter (12) plus half the shared icon size (20). */
+ --navigation-icon-center: calc(var(--navigation-inset) + 1.375rem * var(--s2-scale, 1));
+ display: grid;
+ grid-template-columns: 240px minmax(0, 1fr);
+ height: 100dvh;
+ overflow: hidden;
+ color: var(--text);
+ background: var(--bg);
+}
+
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-inline-end: 1px solid var(--border);
+ background: var(--social-bg);
+}
+
+.sidebarBrand,
+.topbar {
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ height: 64px;
+ flex-shrink: 0;
+ padding: 0 24px;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-h);
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.sidebarNavigation {
+ flex: 1;
+ min-height: 0;
+ padding: 16px var(--navigation-inset);
+}
+
+.expandedNavigation {
+ height: 100%;
+}
+
+.topbar {
+ gap: 12px;
+}
+
+.topbarActions {
+ margin-inline-start: auto;
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.appLayout.isCollapsed {
+ /* Include the sidebar border so both modes share the same icon center. */
+ grid-template-columns: calc(2 * var(--navigation-icon-center) + 1px) minmax(0, 1fr);
+}
+
+.isCollapsed .sidebarBrand {
+ justify-content: center;
+ padding-inline: 0;
+}
+
+.isCollapsed .sidebarNavigation {
+ overflow-y: auto;
+}
+
+.iconNavigation {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ /* Match SideNav's row spacing (space(6)). */
+ gap: 0.375rem;
+}
+
+.iconNavigationLink,
+.iconNavigationGroup {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 40px;
+ /* Match SideNav's 32-unit row height, including Spectrum's touch scale. */
+ height: calc(2rem * var(--s2-scale, 1));
+ flex-shrink: 0;
+ border-radius: 8px;
+ color: var(--text);
+}
+
+.iconNavigationLink:hover,
+.iconNavigationLink[aria-current="page"],
+.iconNavigationGroup.isActive {
+ background: var(--border);
+ color: var(--text-h);
+}
+
+.iconNavigationLink:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.contentLayout {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ min-width: 0;
+ min-height: 0;
+}
+
+.skipLink {
+ position: fixed;
+ top: 8px;
+ left: 8px;
+ z-index: 10;
+ padding: 8px 16px;
+ background: var(--bg);
+ color: var(--accent);
+ transform: translateY(calc(-100% - 16px));
+
+ &:focus {
+ transform: translateY(0);
+ }
+}
+
+@media (max-width: 640px) {
+ .appLayout,
+ .appLayout.isCollapsed {
+ --navigation-inset: 8px;
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .sidebar,
+ .desktopMenuToggle {
+ display: none;
+ }
+
+ .sidebarBrand,
+ .topbar {
+ padding-inline: 12px;
+ }
+}
+
+.pageContent {
+ /* Keep absolutely positioned accessibility helpers inside this scroll area. */
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ text-align: center;
+}
+
+.pageContent > * {
+ flex-shrink: 0;
+}
diff --git a/src/components/layout-basic.tsx b/src/components/layout-basic.tsx
new file mode 100644
index 0000000..05f3eef
--- /dev/null
+++ b/src/components/layout-basic.tsx
@@ -0,0 +1,67 @@
+import { useState, type ReactNode } from 'react'
+import { useMatches } from 'react-router'
+import type { PageMetadata } from '../route-config'
+import CollapsedNavigation from './collapsed-navigation'
+import { ActionButton } from '@react-spectrum/s2/ActionButton'
+import { Tooltip, TooltipTrigger } from '@react-spectrum/s2/Tooltip'
+import SidebarMenu from './sidebar-menu'
+import MobileNavigation from './mobile-navigation'
+import GlobalSettings from './global-settings'
+import HomeIcon from '@react-spectrum/s2/icons/Home'
+import ChevronLeftIcon from '@react-spectrum/s2/icons/ChevronLeft'
+import ChevronRightIcon from '@react-spectrum/s2/icons/ChevronRight'
+import styles from './layout-basic.module.css'
+
+interface BasicLayoutProps {
+ children: ReactNode
+}
+
+function BasicLayout({ children }: BasicLayoutProps) {
+ const [isCollapsed, setIsCollapsed] = useState(false)
+ const currentPage = useMatches().at(-1)?.handle as PageMetadata | undefined
+
+ return (
+ <div className={`${styles.appLayout}${isCollapsed ? ` ${styles.isCollapsed}` : ''}`}>
+ <a className={styles.skipLink} href="#main-content">Skip to content</a>
+ <aside id="sidebar" className={styles.sidebar} aria-label="Sidebar">
+ <div className={styles.sidebarBrand} aria-label="React Spectrum">
+ {isCollapsed ? <HomeIcon /> : 'React Spectrum'}
+ </div>
+ <nav className={styles.sidebarNavigation} aria-label="Main navigation">
+ {isCollapsed && <CollapsedNavigation activePageId={currentPage?.id} />}
+ <div className={styles.expandedNavigation} hidden={isCollapsed}>
+ <SidebarMenu />
+ </div>
+ </nav>
+ </aside>
+ <div className={styles.contentLayout}>
+ <header className={styles.topbar}>
+ <MobileNavigation />
+ <div className={styles.desktopMenuToggle}>
+ <TooltipTrigger>
+ <ActionButton
+ aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
+ aria-expanded={!isCollapsed}
+ aria-controls="sidebar"
+ isQuiet
+ onPress={() => setIsCollapsed((collapsed) => !collapsed)}
+ >
+ {isCollapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
+ </ActionButton>
+ <Tooltip>{isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}</Tooltip>
+ </TooltipTrigger>
+ </div>
+ <span>{currentPage?.title}</span>
+ <div className={styles.topbarActions}>
+ <GlobalSettings />
+ </div>
+ </header>
+ <main id="main-content" className={styles.pageContent} tabIndex={-1}>
+ {children}
+ </main>
+ </div>
+ </div>
+ )
+}
+
+export default BasicLayout
diff --git a/src/components/menu-icon.module.css b/src/components/menu-icon.module.css
new file mode 100644
index 0000000..39bfa21
--- /dev/null
+++ b/src/components/menu-icon.module.css
@@ -0,0 +1,6 @@
+/* Match Spectrum override specificity without depending on an ancestor. */
+.icon.icon {
+ width: calc(1.25rem * var(--s2-scale, 1));
+ height: calc(1.25rem * var(--s2-scale, 1));
+ flex-shrink: 0;
+}
diff --git a/src/components/mobile-navigation.module.css b/src/components/mobile-navigation.module.css
new file mode 100644
index 0000000..4fe082d
--- /dev/null
+++ b/src/components/mobile-navigation.module.css
@@ -0,0 +1,53 @@
+.mobileMenuToggle {
+ display: none;
+}
+
+.mobileNavigation {
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ height: 100dvh;
+ max-width: none;
+ max-height: none;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ box-sizing: border-box;
+ background: var(--bg);
+ color: var(--text);
+ overflow: hidden;
+}
+
+.mobileNavigation[open] {
+ display: flex;
+ flex-direction: column;
+}
+
+.mobileNavigation::backdrop {
+ background: var(--bg);
+}
+
+.mobileNavigationHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-shrink: 0;
+ min-height: 64px;
+ padding: env(safe-area-inset-top) 16px 0;
+ border-bottom: 1px solid var(--border);
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-h);
+}
+
+.mobileNavigationContent {
+ flex: 1;
+ min-height: 0;
+ padding: 16px max(12px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
+}
+
+@media (max-width: 640px) {
+ .mobileMenuToggle {
+ display: block;
+ }
+}
diff --git a/src/components/mobile-navigation.tsx b/src/components/mobile-navigation.tsx
new file mode 100644
index 0000000..abf4f24
--- /dev/null
+++ b/src/components/mobile-navigation.tsx
@@ -0,0 +1,64 @@
+import styles from './mobile-navigation.module.css'
+import { useEffect, useRef, useState } from 'react'
+import { useLocation } from 'react-router'
+import { ActionButton } from '@react-spectrum/s2/ActionButton'
+import CloseIcon from '@react-spectrum/s2/icons/Close'
+import ViewListIcon from '@react-spectrum/s2/icons/ViewList'
+import SidebarMenu from './sidebar-menu'
+
+function MobileNavigation() {
+ const dialogRef = useRef<HTMLDialogElement>(null)
+ const [isOpen, setIsOpen] = useState(false)
+ const { key } = useLocation()
+
+ // Close after all route changes, including selecting the current page.
+ useEffect(() => {
+ dialogRef.current?.close()
+ }, [key])
+
+ useEffect(() => {
+ const media = window.matchMedia('(max-width: 640px)')
+ const closeOnDesktop = () => {
+ if (!media.matches) dialogRef.current?.close()
+ }
+ media.addEventListener('change', closeOnDesktop)
+ return () => media.removeEventListener('change', closeOnDesktop)
+ }, [])
+
+ return (
+ <div className={styles.mobileMenuToggle}>
+ <ActionButton
+ aria-label="打开菜单"
+ aria-haspopup="dialog"
+ aria-expanded={isOpen}
+ aria-controls="mobile-navigation"
+ isQuiet
+ onPress={() => {
+ dialogRef.current?.showModal()
+ setIsOpen(true)
+ }}
+ >
+ <ViewListIcon />
+ </ActionButton>
+ <dialog
+ ref={dialogRef}
+ id="mobile-navigation"
+ className={styles.mobileNavigation}
+ aria-labelledby="mobile-navigation-title"
+ onClose={() => setIsOpen(false)}
+ >
+ <header className={styles.mobileNavigationHeader}>
+ <span id="mobile-navigation-title">菜单</span>
+ <ActionButton aria-label="关闭菜单" isQuiet onPress={() => dialogRef.current?.close()}>
+ <CloseIcon />
+ </ActionButton>
+ </header>
+ <nav className={styles.mobileNavigationContent} aria-label="页面导航">
+ <SidebarMenu />
+ </nav>
+ </dialog>
+ </div>
+ )
+}
+
+export default MobileNavigation
diff --git a/src/components/sidebar-menu.tsx b/src/components/sidebar-menu.tsx
new file mode 100644
index 0000000..604c002
--- /dev/null
+++ b/src/components/sidebar-menu.tsx
@@ -0,0 +1,35 @@
+import iconStyles from './menu-icon.module.css'
+import { useMatches } from 'react-router'
+import { SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, Text } from '@react-spectrum/s2/SideNav'
+import { defaultExpandedKeys, menuItems, type NavigationItem, type PageMetadata } from '../route-config'
+
+function renderItem(item: NavigationItem, level = 0) {
+ const Icon = item.Icon
+ const content = (
+ <>
+ {Icon && <Icon UNSAFE_className={level === 0 ? iconStyles.icon : undefined} />}
+ <Text>{item.title}</Text>
+ </>
+ )
+
+ return (
+ <SideNavItem key={item.id} id={item.id} href={item.path} textValue={item.title}>
+ <SideNavItemContent>
+ {item.path ? <SideNavItemLink>{content}</SideNavItemLink> : content}
+ </SideNavItemContent>
+ {item.children?.map((child) => renderItem(child, level + 1))}
+ </SideNavItem>
+ )
+}
+
+function SidebarMenu() {
+ const currentPage = useMatches().at(-1)?.handle as PageMetadata | undefined
+
+ return (
+ <SideNav aria-label="Pages" selectedRoute={currentPage?.path ?? null} defaultExpandedKeys={defaultExpandedKeys}>
+ {menuItems.map((item) => renderItem(item))}
+ </SideNav>
+ )
+}
+
+export default SidebarMenu
diff --git a/src/components/ui-kit/actions-feedback.tsx b/src/components/ui-kit/actions-feedback.tsx
new file mode 100644
index 0000000..ade8d48
--- /dev/null
+++ b/src/components/ui-kit/actions-feedback.tsx
@@ -0,0 +1,101 @@
+import { useEffect, useRef, useState } from 'react'
+import { Button, Text } from '@react-spectrum/s2/Button'
+import { ActionButton } from '@react-spectrum/s2/ActionButton'
+import { ToggleButton } from '@react-spectrum/s2/ToggleButton'
+import { ActionMenu, MenuItem } from '@react-spectrum/s2/ActionMenu'
+import { Badge } from '@react-spectrum/s2/Badge'
+import { StatusLight } from '@react-spectrum/s2/StatusLight'
+import { ProgressBar } from '@react-spectrum/s2/ProgressBar'
+import { ProgressCircle } from '@react-spectrum/s2/ProgressCircle'
+import { Meter } from '@react-spectrum/s2/Meter'
+import { InlineAlert, Heading, Content } from '@react-spectrum/s2/InlineAlert'
+import { DialogTrigger, Dialog, ButtonGroup } from '@react-spectrum/s2/Dialog'
+import { Popover } from '@react-spectrum/s2/Popover'
+import { Tooltip, TooltipTrigger } from '@react-spectrum/s2/Tooltip'
+import { ToastContainer, ToastQueue } from '@react-spectrum/s2/Toast'
+import AddIcon from '@react-spectrum/s2/icons/Add'
+import EditIcon from '@react-spectrum/s2/icons/Edit'
+import InfoIcon from '@react-spectrum/s2/icons/InfoCircle'
+import styles from './ui-kit.module.css'
+
+export default function ActionsFeedback() {
+ const [message, setMessage] = useState('点击按钮体验交互。')
+ const [selected, setSelected] = useState(false)
+ const [progress, setProgress] = useState(40)
+ const toastClosers = useRef<Array<() => void>>([])
+ useEffect(() => () => { toastClosers.current.forEach((close) => close()) }, [])
+
+ return (
+ <>
+ <section className={styles.section} aria-labelledby="kit-buttons">
+ <h2 id="kit-buttons">按钮与操作</h2>
+ <div className={styles.row}>
+ <Button variant="primary" onPress={() => setMessage('已点击主要按钮。')}>主要按钮</Button>
+ <Button variant="secondary" onPress={() => setMessage('已点击次要按钮。')}>次要按钮</Button>
+ <Button variant="accent" onPress={() => setMessage('已点击强调按钮。')}><AddIcon /><Text>新建</Text></Button>
+ <Button variant="negative" onPress={() => setMessage('这是危险操作的样式示例。')}>危险操作</Button>
+ <Button isDisabled>禁用按钮</Button>
+ </div>
+ <div className={styles.row}>
+ {(['S', 'M', 'L', 'XL'] as const).map((size) => <Button key={size} size={size} variant="secondary" onPress={() => setMessage(`已点击 ${size} 尺寸按钮。`)}>{size}</Button>)}
+ <TooltipTrigger><ActionButton aria-label="编辑示例" onPress={() => setMessage('已点击图标按钮。')}><EditIcon /></ActionButton><Tooltip>编辑示例</Tooltip></TooltipTrigger>
+ <ToggleButton isSelected={selected} onChange={setSelected}>{selected ? '已收藏' : '收藏'}</ToggleButton>
+ <ActionMenu aria-label="更多示例操作" onAction={(key) => setMessage(key === 'copy' ? '已选择复制示例。' : '已选择重命名示例。')}>
+ <MenuItem id="copy">复制</MenuItem><MenuItem id="rename">重命名</MenuItem>
+ </ActionMenu>
+ </div>
+ <p role="status" className={styles.caption}>{message}</p>
+ </section>
+
+ <section className={styles.section} aria-labelledby="kit-status">
+ <h2 id="kit-status">徽标与状态</h2>
+ <div className={styles.row}>
+ <Badge variant="neutral">默认</Badge><Badge variant="informative">信息</Badge><Badge variant="positive">成功</Badge><Badge variant="notice">提醒</Badge><Badge variant="negative">错误</Badge><Badge variant="accent">强调</Badge>
+ </div>
+ <div className={styles.row}>
+ <StatusLight variant="positive">已完成</StatusLight><StatusLight variant="notice">待处理</StatusLight><StatusLight variant="negative">失败</StatusLight><StatusLight variant="neutral">草稿</StatusLight>
+ </div>
+ <div className={styles.grid}>
+ <InlineAlert variant="informative"><Heading>信息提示</Heading><Content>用于展示与当前操作相关的说明。</Content></InlineAlert>
+ <InlineAlert variant="positive"><Heading>成功提示</Heading><Content>这是一条成功状态示例。</Content></InlineAlert>
+ <InlineAlert variant="notice"><Heading>注意事项</Heading><Content>操作前需要留意的提示信息。</Content></InlineAlert>
+ <InlineAlert variant="negative"><Heading>错误提示</Heading><Content>展示问题原因及后续处理方式。</Content></InlineAlert>
+ </div>
+ </section>
+
+ <section className={styles.section} aria-labelledby="kit-progress">
+ <h2 id="kit-progress">进度与容量</h2>
+ <div className={styles.grid}>
+ <ProgressBar label="示例进度" value={progress} />
+ <Meter label="已用容量" value={65} variant="notice" />
+ <ProgressBar label="不确定进度" isIndeterminate />
+ <div className={styles.row}><ProgressCircle aria-label="正在加载" isIndeterminate /><span>加载中</span></div>
+ </div>
+ <div className={styles.row}>
+ <Button variant="secondary" isDisabled={progress === 100} onPress={() => setProgress((value) => Math.min(value + 20, 100))}>增加进度</Button>
+ <Button variant="secondary" onPress={() => setProgress(0)}>重置进度</Button>
+ </div>
+ </section>
+
+ <section className={styles.section} aria-labelledby="kit-overlays">
+ <h2 id="kit-overlays">弹层与通知</h2>
+ <div className={styles.row}>
+ <DialogTrigger>
+ <Button variant="secondary">打开对话框</Button>
+ <Dialog size="S">
+ {({ close }) => <><Heading>对话框示例</Heading><Content>对话框用于需要用户确认的操作。</Content><ButtonGroup><Button variant="secondary" onPress={close}>取消</Button><Button variant="primary" onPress={() => { setMessage('已确认对话框。'); close() }}>确认</Button></ButtonGroup></>}
+ </Dialog>
+ </DialogTrigger>
+ <DialogTrigger>
+ <Button variant="secondary">打开 Popover</Button>
+ <Popover aria-label="Popover 示例"><div className={styles.popover}><InfoIcon /><p>Popover 用于在操作附近展示补充内容,点击外部或按 Esc 关闭。</p></div></Popover>
+ </DialogTrigger>
+ <TooltipTrigger><Button variant="secondary">悬停查看提示</Button><Tooltip>这是一条 Tooltip 提示。</Tooltip></TooltipTrigger>
+ <Button variant="secondary" onPress={() => toastClosers.current.push(ToastQueue.positive('这是一条成功通知。', { timeout: 5000 }))}>成功通知</Button>
+ <Button variant="secondary" onPress={() => toastClosers.current.push(ToastQueue.negative('这是一条错误通知示例。', { timeout: 5000 }))}>错误通知</Button>
+ </div>
+ </section>
+ <ToastContainer />
+ </>
+ )
+}
diff --git a/src/components/ui-kit/navigation-data.tsx b/src/components/ui-kit/navigation-data.tsx
new file mode 100644
index 0000000..5ac2605
--- /dev/null
+++ b/src/components/ui-kit/navigation-data.tsx
@@ -0,0 +1,75 @@
+import { useState } from 'react'
+import { Tabs, TabList, Tab, TabPanel } from '@react-spectrum/s2/Tabs'
+import { Accordion, AccordionItem, AccordionItemTitle, AccordionItemPanel } from '@react-spectrum/s2/Accordion'
+import { Breadcrumbs, Breadcrumb } from '@react-spectrum/s2/Breadcrumbs'
+import { TagGroup, Tag } from '@react-spectrum/s2/TagGroup'
+import { Button } from '@react-spectrum/s2/Button'
+import { Card, CardPreview, Content, Text, Footer } from '@react-spectrum/s2/Card'
+import { TableView, TableHeader, Column, TableBody, Row, Cell, type Selection, type SortDescriptor } from '@react-spectrum/s2/TableView'
+import { StatusLight } from '@react-spectrum/s2/StatusLight'
+import EditIcon from '@react-spectrum/s2/icons/Edit'
+import styles from './ui-kit.module.css'
+
+const initialTags = ['设计', '开发', '文档']
+const rows = [
+ { id: 'buttons', name: '按钮', category: '基础操作', status: '已完成' },
+ { id: 'navigation', name: '导航', category: '页面组织', status: '已完成' },
+ { id: 'form', name: '表单', category: '数据输入', status: '示例' },
+]
+
+export default function NavigationData() {
+ const [tags, setTags] = useState(initialTags)
+ const [selection, setSelection] = useState<Selection>(new Set())
+ const [sort, setSort] = useState<SortDescriptor>({ column: 'name', direction: 'ascending' })
+ const sortedRows = [...rows].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN') * (sort.direction === 'descending' ? -1 : 1))
+
+ return (
+ <>
+ <section className={styles.section} aria-labelledby="kit-navigation">
+ <h2 id="kit-navigation">导航与内容组织</h2>
+ <Breadcrumbs><Breadcrumb href="/">Home</Breadcrumb><Breadcrumb>UI Kit</Breadcrumb></Breadcrumbs>
+ <Tabs aria-label="组件内容示例" defaultSelectedKey="overview">
+ <TabList aria-label="组件内容示例"><Tab id="overview">概览</Tab><Tab id="details">详情</Tab><Tab id="disabled" isDisabled>禁用</Tab></TabList>
+ <TabPanel id="overview"><p className={styles.tabContent}>这是概览面板。点击“详情”切换内容。</p></TabPanel>
+ <TabPanel id="details"><p className={styles.tabContent}>这是详情面板。也可以使用键盘方向键切换标签。</p></TabPanel>
+ </Tabs>
+ <Accordion allowsMultipleExpanded defaultExpandedKeys={['usage']}>
+ <AccordionItem id="usage"><AccordionItemTitle>组件如何交互?</AccordionItemTitle><AccordionItemPanel>点击、聚焦或使用键盘体验组件的不同状态。</AccordionItemPanel></AccordionItem>
+ <AccordionItem id="theme"><AccordionItemTitle>如何查看暗色效果?</AccordionItemTitle><AccordionItemPanel>使用顶栏右上角的设置按钮切换亮色和暗色模式。</AccordionItemPanel></AccordionItem>
+ </Accordion>
+ </section>
+
+ <section className={styles.section} aria-labelledby="kit-tags">
+ <h2 id="kit-tags">标签与卡片</h2>
+ <TagGroup aria-label="可移除的示例标签" onRemove={(keys) => setTags((current) => current.filter((tag) => !keys.has(tag)))}>
+ {tags.map((tag) => <Tag key={tag} id={tag}>{tag}</Tag>)}
+ </TagGroup>
+ {tags.length === 0 && <p className={styles.caption}>标签已清空。</p>}
+ <div><Button variant="secondary" onPress={() => setTags(initialTags)}>恢复标签</Button></div>
+ <div className={styles.cards}>
+ <Card href="/form" aria-label="查看表单示例">
+ <CardPreview><div className={styles.cardPreview}><EditIcon /></div></CardPreview>
+ <Content><Text slot="title">表单组件</Text><Text slot="description">文本输入、选择、日期、颜色与文件控件。</Text></Content>
+ <Footer><StatusLight variant="informative">点击查看表单页面</StatusLight></Footer>
+ </Card>
+ <Card variant="secondary" aria-label="组件展示卡片">
+ <Content><Text slot="title">内容卡片</Text><Text slot="description">使用标题、描述和底部状态组织信息。</Text></Content>
+ <Footer><StatusLight variant="positive">静态展示</StatusLight></Footer>
+ </Card>
+ </div>
+ </section>
+
+ <section className={styles.section} aria-labelledby="kit-table">
+ <h2 id="kit-table">数据表格</h2>
+ <p className={styles.caption}>支持多选,点击“名称”可切换排序。</p>
+ <div className={styles.tableContainer}>
+ <TableView aria-label="组件示例列表" UNSAFE_className={styles.table} selectionMode="multiple" selectedKeys={selection} onSelectionChange={setSelection} sortDescriptor={sort} onSortChange={setSort}>
+ <TableHeader><Column id="name" isRowHeader allowsSorting>名称</Column><Column id="category">分类</Column><Column id="status">状态</Column></TableHeader>
+ <TableBody items={sortedRows}>{(row) => <Row id={row.id}><Cell>{row.name}</Cell><Cell>{row.category}</Cell><Cell>{row.status}</Cell></Row>}</TableBody>
+ </TableView>
+ </div>
+ <p role="status" className={styles.caption}>已选择 {selection === 'all' ? rows.length : selection.size} 项</p>
+ </section>
+ </>
+ )
+}
diff --git a/src/components/ui-kit/ui-kit.module.css b/src/components/ui-kit/ui-kit.module.css
new file mode 100644
index 0000000..d590bb4
--- /dev/null
+++ b/src/components/ui-kit/ui-kit.module.css
@@ -0,0 +1,80 @@
+.section {
+ min-width: 0;
+ display: grid;
+ gap: 24px;
+ padding: 24px;
+ margin-bottom: 24px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+}
+
+.section > h2 {
+ margin: 0;
+ font-size: 20px;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 16px;
+}
+
+.grid,
+.cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
+ gap: 20px;
+ min-width: 0;
+}
+
+.grid > *,
+.cards > * {
+ min-width: 0;
+}
+
+.caption,
+.tabContent,
+.popover p {
+ font-size: 14px;
+ line-height: 1.6;
+ overflow-wrap: anywhere;
+}
+
+.tabContent {
+ padding: 20px 0;
+}
+
+.popover {
+ display: grid;
+ gap: 12px;
+ padding: 16px;
+ width: min(260px, calc(100vw - 64px));
+ box-sizing: border-box;
+}
+
+.cardPreview {
+ display: grid;
+ place-items: center;
+ width: 100%;
+ height: 100px;
+ color: var(--accent);
+ background: var(--social-bg);
+}
+
+.tableContainer {
+ min-width: 0;
+ overflow: auto;
+}
+
+.table.table {
+ height: 280px;
+ min-width: 440px;
+ width: 100%;
+}
+
+@media (max-width: 640px) {
+ .section {
+ padding: 16px;
+ }
+}
diff --git a/src/components/ui-kit/ui-kit.tsx b/src/components/ui-kit/ui-kit.tsx
new file mode 100644
index 0000000..e12012d
--- /dev/null
+++ b/src/components/ui-kit/ui-kit.tsx
@@ -0,0 +1,6 @@
+import ActionsFeedback from './actions-feedback'
+import NavigationData from './navigation-data'
+
+export default function UIKit() {
+ return <><ActionsFeedback /><NavigationData /></>
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..10f75be
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,74 @@
+:root {
+ --text: #6b6375;
+ --text-h: #08060d;
+ --bg: #fff;
+ --border: #e5e4e7;
+ --accent: #aa3bff;
+ --social-bg: rgba(244, 243, 236, 0.5);
+
+ --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
+ --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
+
+ font: 18px/145% var(--sans);
+ letter-spacing: 0.18px;
+ color-scheme: light;
+ color: var(--text);
+ background: var(--bg);
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+
+ @media (max-width: 1024px) {
+ font-size: 16px;
+ }
+}
+
+:root[data-theme='dark'] {
+ color-scheme: dark;
+ --text: #9ca3af;
+ --text-h: #f3f4f6;
+ --bg: #16171d;
+ --border: #2e303a;
+ --accent: #c084fc;
+ --social-bg: rgba(47, 48, 58, 0.5);
+}
+
+#root {
+ width: 100%;
+ min-height: 100svh;
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+h1,
+h2 {
+ font-family: var(--heading);
+ font-weight: 500;
+ color: var(--text-h);
+}
+
+h1 {
+ font-size: 56px;
+ letter-spacing: -1.68px;
+ margin: 32px 0;
+ @media (max-width: 1024px) {
+ font-size: 36px;
+ margin: 20px 0;
+ }
+}
+h2 {
+ font-size: 24px;
+ line-height: 118%;
+ letter-spacing: -0.24px;
+ margin: 0 0 8px;
+ @media (max-width: 1024px) {
+ font-size: 20px;
+ }
+}
+p {
+ margin: 0;
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..098eec5
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,12 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import { RouterProvider } from 'react-router/dom'
+import { router } from './router'
+import '@react-spectrum/s2/page.css'
+
+createRoot(document.getElementById('root')!).render(
+ <StrictMode>
+ <RouterProvider router={router} />
+ </StrictMode>
+)
diff --git a/src/page-about.tsx b/src/page-about.tsx
new file mode 100644
index 0000000..1672795
--- /dev/null
+++ b/src/page-about.tsx
@@ -0,0 +1,16 @@
+import styles from './page.module.css'
+import { Button } from '@react-spectrum/s2/Button';
+
+function About() {
+ return (
+ <section className={styles.page}>
+ <h1>About</h1>
+ <p>A React application built with Vite.</p>
+
+
+ <Button>Save</Button>
+ </section>
+ )
+}
+
+export default About
diff --git a/src/page-form.module.css b/src/page-form.module.css
new file mode 100644
index 0000000..f5ba93f
--- /dev/null
+++ b/src/page-form.module.css
@@ -0,0 +1,23 @@
+.page {
+ width: 100%;
+ max-width: 1280px;
+ box-sizing: border-box;
+ margin-inline: auto;
+ padding: clamp(16px, 3vw, 32px);
+ text-align: start;
+}
+
+.header {
+ margin-bottom: 28px;
+}
+
+.header h1 {
+ font-size: 32px;
+ line-height: 1.4;
+ margin: 0 0 8px;
+}
+
+.header p {
+ font-size: 14px;
+ line-height: 1.6;
+}
diff --git a/src/page-form.tsx b/src/page-form.tsx
new file mode 100644
index 0000000..76e134b
--- /dev/null
+++ b/src/page-form.tsx
@@ -0,0 +1,18 @@
+import { lazy, Suspense } from 'react'
+import styles from './page-form.module.css'
+
+const FormExamples = lazy(() => import('./components/form-examples/form-examples'))
+
+export default function FormPage() {
+ return (
+ <div className={styles.page}>
+ <header className={styles.header}>
+ <h1>表单</h1>
+ <p>Spectrum 表单控件、交互状态与数据校验示例。</p>
+ </header>
+ <Suspense fallback={<p role="status">正在加载表单…</p>}>
+ <FormExamples />
+ </Suspense>
+ </div>
+ )
+}
diff --git a/src/page-home.tsx b/src/page-home.tsx
new file mode 100644
index 0000000..08d3952
--- /dev/null
+++ b/src/page-home.tsx
@@ -0,0 +1,11 @@
+import styles from './page.module.css'
+
+function Home() {
+ return (
+ <section className={styles.page}>
+ <h1>Home</h1>
+ </section>
+ )
+}
+
+export default Home
diff --git a/src/page-not-found.tsx b/src/page-not-found.tsx
new file mode 100644
index 0000000..793c5c4
--- /dev/null
+++ b/src/page-not-found.tsx
@@ -0,0 +1,14 @@
+import styles from './page.module.css'
+import { Link } from 'react-router'
+
+function NotFound() {
+ return (
+ <section className={styles.page}>
+ <h1>404</h1>
+ <p>Page not found.</p>
+ <Link to="/">Back to home</Link>
+ </section>
+ )
+}
+
+export default NotFound
diff --git a/src/page-ui-kit.module.css b/src/page-ui-kit.module.css
new file mode 100644
index 0000000..a0c3481
--- /dev/null
+++ b/src/page-ui-kit.module.css
@@ -0,0 +1,23 @@
+.page {
+ width: 100%;
+ max-width: 1280px;
+ box-sizing: border-box;
+ margin-inline: auto;
+ padding: clamp(16px, 3vw, 32px);
+ text-align: start;
+}
+
+.header {
+ margin-bottom: 28px;
+}
+
+.header h1 {
+ margin: 0 0 8px;
+ font-size: 32px;
+ line-height: 1.4;
+}
+
+.header p {
+ font-size: 14px;
+ line-height: 1.6;
+}
diff --git a/src/page-ui-kit.tsx b/src/page-ui-kit.tsx
new file mode 100644
index 0000000..9b1bb5d
--- /dev/null
+++ b/src/page-ui-kit.tsx
@@ -0,0 +1,13 @@
+import { lazy, Suspense } from 'react'
+import styles from './page-ui-kit.module.css'
+
+const UIKit = lazy(() => import('./components/ui-kit/ui-kit'))
+
+export default function UIKitPage() {
+ return (
+ <div className={styles.page}>
+ <header className={styles.header}><h1>UI Kit</h1><p>Spectrum 组件的外观与交互示例。</p></header>
+ <Suspense fallback={<p role="status">正在加载组件…</p>}><UIKit /></Suspense>
+ </div>
+ )
+}
diff --git a/src/page.module.css b/src/page.module.css
new file mode 100644
index 0000000..3f0c725
--- /dev/null
+++ b/src/page.module.css
@@ -0,0 +1,9 @@
+.page {
+ padding: 32px 20px;
+
+ a {
+ display: inline-block;
+ margin-top: 24px;
+ color: var(--accent);
+ }
+}
diff --git a/src/route-config.ts b/src/route-config.ts
new file mode 100644
index 0000000..9d308d4
--- /dev/null
+++ b/src/route-config.ts
@@ -0,0 +1,82 @@
+import type { ComponentType } from 'react'
+import type { RouteObject } from 'react-router'
+import HomeIcon from '@react-spectrum/s2/icons/Home'
+import InfoCircleIcon from '@react-spectrum/s2/icons/InfoCircle'
+import HelpCircleIcon from '@react-spectrum/s2/icons/HelpCircle'
+import EditIcon from '@react-spectrum/s2/icons/Edit'
+import AppsIcon from '@react-spectrum/s2/icons/Apps'
+import Home from './page-home'
+import About from './page-about'
+import NotFound from './page-not-found'
+import FormPage from './page-form'
+import UIKitPage from './page-ui-kit'
+
+interface ItemMetadata {
+ id: string
+ title: string
+ Icon?: typeof HomeIcon
+ hidden?: boolean
+}
+
+interface PageConfig extends ItemMetadata {
+ path: string
+ Component: ComponentType
+ children?: never
+}
+
+interface GroupConfig extends ItemMetadata {
+ children: NavigationItem[]
+ defaultExpanded?: boolean
+ path?: never
+ Component?: never
+}
+
+export type NavigationItem = PageConfig | GroupConfig
+
+export interface PageMetadata {
+ id: string
+ title: string
+ path: string
+}
+
+// Menu groups organize navigation; page paths are absolute and independent of groups.
+export const routeConfig: NavigationItem[] = [
+ { id: 'home', title: 'Home', path: '/', Icon: HomeIcon, Component: Home },
+ { id: 'form', title: '表单', path: '/form', Icon: EditIcon, Component: FormPage },
+ { id: 'ui-kit', title: 'UI Kit', path: '/ui-kit', Icon: AppsIcon, Component: UIKitPage },
+ {
+ id: 'help',
+ title: 'Help',
+ Icon: HelpCircleIcon,
+ defaultExpanded: true,
+ children: [
+ { id: 'about', title: 'About', path: '/about', Icon: InfoCircleIcon, Component: About },
+ ],
+ },
+ { id: 'not-found', title: 'Page not found', path: '*', Component: NotFound, hidden: true },
+]
+
+function createPageRoutes(items: NavigationItem[]): RouteObject[] {
+ return items.flatMap((item): RouteObject[] => {
+ if (item.children) return createPageRoutes(item.children)
+ const handle: PageMetadata = { id: item.id, title: item.title, path: item.path }
+ return [{ id: item.id, path: item.path, Component: item.Component, handle }]
+ })
+}
+
+function createMenuItems(items: NavigationItem[]): NavigationItem[] {
+ return items.filter((item) => !item.hidden).map((item) =>
+ item.children ? { ...item, children: createMenuItems(item.children) } : item,
+ )
+}
+
+function getExpandedKeys(items: NavigationItem[]): string[] {
+ return items.flatMap((item) => item.children
+ ? [...(item.defaultExpanded ? [item.id] : []), ...getExpandedKeys(item.children)]
+ : [],
+ )
+}
+
+export const pageRoutes = createPageRoutes(routeConfig)
+export const menuItems = createMenuItems(routeConfig)
+export const defaultExpandedKeys = getExpandedKeys(menuItems)
diff --git a/src/router.tsx b/src/router.tsx
new file mode 100644
index 0000000..8128f45
--- /dev/null
+++ b/src/router.tsx
@@ -0,0 +1,11 @@
+import { createBrowserRouter } from 'react-router'
+import App from './App'
+import { pageRoutes } from './route-config'
+
+export const router = createBrowserRouter([
+ {
+ path: '/',
+ Component: App,
+ children: pageRoutes,
+ },
+])
diff --git a/src/theme.ts b/src/theme.ts
new file mode 100644
index 0000000..c05b24d
--- /dev/null
+++ b/src/theme.ts
@@ -0,0 +1,25 @@
+import { createContext, useContext } from 'react'
+
+export type ColorScheme = 'light' | 'dark'
+export const themeStorageKey = 'react-spectrum-color-scheme'
+
+export const ThemeContext = createContext<{
+ colorScheme: ColorScheme
+ setColorScheme: (colorScheme: ColorScheme) => void
+} | null>(null)
+
+export function getInitialColorScheme(): ColorScheme {
+ try {
+ const saved = localStorage.getItem(themeStorageKey)
+ if (saved === 'light' || saved === 'dark') return saved
+ } catch {
+ // Storage may be unavailable; theme switching still works for this session.
+ }
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+}
+
+export function useTheme() {
+ const theme = useContext(ThemeContext)
+ if (!theme) throw new Error('useTheme must be used within ThemeContext')
+ return theme
+}