diff options
| author | shu <nvcsadjkojf@outlook.com> | 2026-09-07 15:48:36 +0800 |
|---|---|---|
| committer | shu <nvcsadjkojf@outlook.com> | 2026-09-07 15:48:36 +0800 |
| commit | 481402e09fe760634b826b09e302e2e1bfec88b1 (patch) | |
| tree | 9b55025bfcd6acd2c5d795f057de27028b422603 /src/components/form-examples | |
feat: initialize React Spectrum template with layouts and showcases
Diffstat (limited to 'src/components/form-examples')
| -rw-r--r-- | src/components/form-examples/color-file-fields.tsx | 69 | ||||
| -rw-r--r-- | src/components/form-examples/date-number-fields.tsx | 45 | ||||
| -rw-r--r-- | src/components/form-examples/field-grid.tsx | 10 | ||||
| -rw-r--r-- | src/components/form-examples/form-examples.module.css | 136 | ||||
| -rw-r--r-- | src/components/form-examples/form-examples.tsx | 91 | ||||
| -rw-r--r-- | src/components/form-examples/selection-fields.tsx | 68 | ||||
| -rw-r--r-- | src/components/form-examples/text-fields.tsx | 28 |
7 files changed, 447 insertions, 0 deletions
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> + ) +} |
