From 481402e09fe760634b826b09e302e2e1bfec88b1 Mon Sep 17 00:00:00 2001 From: shu Date: Mon, 7 Sep 2026 15:48:36 +0800 Subject: feat: initialize React Spectrum template with layouts and showcases --- src/components/collapsed-navigation.tsx | 54 +++++++ src/components/form-examples/color-file-fields.tsx | 69 +++++++++ .../form-examples/date-number-fields.tsx | 45 ++++++ src/components/form-examples/field-grid.tsx | 10 ++ .../form-examples/form-examples.module.css | 136 ++++++++++++++++++ src/components/form-examples/form-examples.tsx | 91 ++++++++++++ src/components/form-examples/selection-fields.tsx | 68 +++++++++ src/components/form-examples/text-fields.tsx | 28 ++++ src/components/global-settings.module.css | 15 ++ src/components/global-settings.tsx | 41 ++++++ src/components/layout-basic.module.css | 156 +++++++++++++++++++++ src/components/layout-basic.tsx | 67 +++++++++ src/components/menu-icon.module.css | 6 + src/components/mobile-navigation.module.css | 53 +++++++ src/components/mobile-navigation.tsx | 64 +++++++++ src/components/sidebar-menu.tsx | 35 +++++ src/components/ui-kit/actions-feedback.tsx | 101 +++++++++++++ src/components/ui-kit/navigation-data.tsx | 75 ++++++++++ src/components/ui-kit/ui-kit.module.css | 80 +++++++++++ src/components/ui-kit/ui-kit.tsx | 6 + 20 files changed, 1200 insertions(+) create mode 100644 src/components/collapsed-navigation.tsx create mode 100644 src/components/form-examples/color-file-fields.tsx create mode 100644 src/components/form-examples/date-number-fields.tsx create mode 100644 src/components/form-examples/field-grid.tsx create mode 100644 src/components/form-examples/form-examples.module.css create mode 100644 src/components/form-examples/form-examples.tsx create mode 100644 src/components/form-examples/selection-fields.tsx create mode 100644 src/components/form-examples/text-fields.tsx create mode 100644 src/components/global-settings.module.css create mode 100644 src/components/global-settings.tsx create mode 100644 src/components/layout-basic.module.css create mode 100644 src/components/layout-basic.tsx create mode 100644 src/components/menu-icon.module.css create mode 100644 src/components/mobile-navigation.module.css create mode 100644 src/components/mobile-navigation.tsx create mode 100644 src/components/sidebar-menu.tsx create mode 100644 src/components/ui-kit/actions-feedback.tsx create mode 100644 src/components/ui-kit/navigation-data.tsx create mode 100644 src/components/ui-kit/ui-kit.module.css create mode 100644 src/components/ui-kit/ui-kit.tsx (limited to 'src/components') 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 && }{item.title} + + return item.children ? ( + + {content} + {item.children.map(renderMenuItem)} + + ) : ( + {content} + ) +} + +function CollapsedNavigation({ activePageId }: { activePageId?: string }) { + return ( +
+ {menuItems.map((item) => { + const Icon = item.Icon + const icon = Icon ? : item.title.slice(0, 1) + + return item.children ? ( +
+ + + {icon} + {item.title} + + {item.children.map(renderMenuItem)} + +
+ ) : ( + + {icon} + + ) + })} +
+ ) +} + +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([]) + const [fileError, setFileError] = useState('') + return ( + <> +
+

颜色选择

+

输入框、色板、色轮和滑块共享同一个颜色值。

+ + { if (next) setColor(next) }} /> + + +
+ 预设色板 + + + +
+
+
+
二维颜色面板
+
色轮
+
+ + {color.toString('hexa')} +
+
+

文件选择与拖放

+ !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('无法读取文件,请重新选择。') + } + }}> +
+

将文件拖放到这里,或从设备中选择。

+ { if (selected) { setFiles(Array.from(selected)); setFileError('') } }}> + + +

仅在本页预览文件信息,不上传。

+
+
+ {fileError &&

{fileError}

} +
+ {files.length ?
    {files.map((file, index) =>
  • {file.name} · {Math.ceil(file.size / 1024)} KB
  • )}
:

尚未选择文件。

} +
+ {files.length > 0 && } + ({ name, size, type })))} /> +
+ + ) +} 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(null) + return ( + <> +
+

数字与滑块

+ + + + + + + +
+
+

日期与时间

+ + + + + + + +
+
独立日历 setDay(value?.toString() ?? '')} />
+
独立范围日历
+
+
+ + ) +} 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 ( +
+ {Children.map(children, (child) =>
{child}
)} +
+ ) +} 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(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>({}) + const [result, setResult] = useState | 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 ( +
+
+ { if (key === 'S' || key === 'M' || key === 'L' || key === 'XL') setSize(key) }}> + SMLXL + + 标签放在侧边 + 禁用表单 +
+ {sideLabels && isCompact &&

当前宽度较窄,已自动使用顶部标签。

} +
{ 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)) + }}> +
+ Spectrum 表单示例 + + + + +
+

校验与提交

+

用户名和邮箱为必填项。可以直接提交,体验错误提示和焦点定位。

+ 我已了解这是本地表单示例 + + + + +
+
+
+
+

提交结果

+

{result ? '校验通过,以下为本次提交的数据。' : '填写表单并提交后,在这里查看结果。'}

+ {result &&
{JSON.stringify(result, null, 2)}
} +
+
+ ) +} + +export default function FormExamples() { + const [version, setVersion] = useState(0) + return 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(new Set(['personal'])) + const [view, setView] = useState('list') + const [format, setFormat] = useState(new Set()) + const [favorite, setFavorite] = useState(false) + const [partial, setPartial] = useState(true) + const [partialSelected, setPartialSelected] = useState(false) + return ( +
+

选择控件

+ + + 上海北京深圳 + + + {(item) => {item.name}} + + + 邮件电话 + + + 设计开发研究 + +
+ 订阅邮件 + { setPartial(false); setPartialSelected(value) }}>部分选中状态 + 开启通知 +
+
+ 分段选择 + + 列表网格 + + +
+
+ 切换按钮组 + + 粗体斜体 + + + 收藏 + +
+
+
+ 卡片选择 + + 个人版个人工作空间 + 团队版多人协作空间 + + +
+
+ ) +} 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 ( +
+

文本输入

+ + 用户名可使用字母、数字和下划线。} + validate={(value) => value && !/^[a-zA-Z0-9_]+$/.test(value) ? '只能包含字母、数字和下划线。' : null} /> + + + + + +