diff options
Diffstat (limited to 'src')
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 +} |
