aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src/components/form-examples
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/form-examples')
-rw-r--r--src/components/form-examples/color-file-fields.tsx69
-rw-r--r--src/components/form-examples/date-number-fields.tsx45
-rw-r--r--src/components/form-examples/field-grid.tsx10
-rw-r--r--src/components/form-examples/form-examples.module.css136
-rw-r--r--src/components/form-examples/form-examples.tsx91
-rw-r--r--src/components/form-examples/selection-fields.tsx68
-rw-r--r--src/components/form-examples/text-fields.tsx28
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>
+ )
+}