Day 02
Slots
slots允许你指定一个元素,该元素在组件的整体集合中承担特定角色。
在构建复杂、由多个需要协同工作的组件组成的 UI 库时,一种非常灵活且易用的设计方式是:将组合组件模式(Compound Components Pattern)与插槽(Slot)模式结合使用。
背景与目的
在 UI 库中,常见的组件会由多个内部子组件构成,比如一个 TextField 组件可能包含 <Label> 和 <Input> 两个部分。如果每次都要求用户手动传递相关联的属性(例如 <label> 的 htmlFor 和 <input> 的 id),不仅容易出错,还会增加使用成本。
为了让 API 更加灵活和友好,我们通过隐藏内部耦合(但又能协同工作)的方式来实现:顶层组件负责生成需要共享的 props(例如自动生成的 id),内部子组件再从上下文中拿到相应的 props 并进行合并。
任务
实现一个插槽上下文 SlotContext 以及一组基于插槽的组件,使其能够:
- 接收一个
slots对象,定义各个插槽名称与其默认 props(例如label: { htmlFor }、input: { id })。 - 通过 React 的 Context API,将这些默认 props 传递给对应的子组件插槽。
- 允许子组件通过自定义 Hook(本题为
useSlotProps)获取并合并默认 props 与用户传入的 props(用户传入优先)。 - 当在上下文之外使用插槽组件时,给出明确的错误提示,避免静默失败。
组件 API
| 组件 | 主要作用 | props |
|---|---|---|
<TextField id?> | 建立插槽上下文,为 label/input 注入默认关联 props | id?: string(可选,默认自动生成) |
<Label ...labelProps> | 自动从上下文获得 htmlFor,可传入 props 覆盖/追加 | 任意 label 支持的属性 |
<Input ...inputProps> | 自动从上下文获得 id,支持标准 input 属性 | 任意 input 支持的属性 |
错误边界:
| 场景 | 错误提示 |
|---|---|
在 TextField 外部使用 Label / Input | SlotContext must be used in TextField component |
示例
预览: Day-02 slots
1. 基础用法
tsx
<TextField>
<Label>用户名</Label>
<Input placeholder="请输入用户名" />
</TextField>2. 自定义 id
tsx
<TextField id="email">
<Label>邮箱</Label>
<Input type="email" placeholder="you@example.com" />
</TextField>3. 不同状态
Invalid
tsx
<TextField>
<Label>用户名</Label>
<Input placeholder="invalid input" aria-invalid />
</TextField>Disabled
tsx
<TextField>
<Label>用户名</Label>
<Input placeholder="请输入用户名" disabled />
</TextField>Readonly
tsx
<TextField>
<Label>用户名</Label>
<Input placeholder="readonly input" readOnly value='Readonly' />
</TextField>基础样式
Label
css
text-sm font-medium text-gray-700 dark:text-gray-200'Input
css
'block w-full rounded-md border px-3 py-2 sm:text-sm leading-6',
'bg-white text-gray-900 placeholder:text-gray-400 shadow-sm',
'border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none',
'disabled:opacity-60 disabled:cursor-not-allowed',
'transition-colors duration-200 ease-out',
'dark:bg-neutral-900 dark:text-gray-100 dark:placeholder:text-gray-500 dark:border-neutral-700',
'aria-[invalid=true]:border-red-500 aria-[invalid=true]:focus:border-red-500 aria-[invalid=true]:focus:ring-red-500/50',测试代码
tsx
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Input, Label, TextField } from './TextField'
import '@testing-library/jest-dom'
describe('textField · Slots 组件', () => {
describe('基础关联行为', () => {
it('在不传 id 时,Label 应自动关联到 Input(htmlFor === input.id)', () => {
render(
<TextField>
<Label>用户名</Label>
<Input placeholder="请输入用户名" />
</TextField>,
)
const label = screen.getByText('用户名')
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('placeholder', '请输入用户名')
const htmlFor = label.getAttribute('for') || label.getAttribute('htmlFor')
expect(htmlFor).toBeTruthy()
expect(input).toHaveAttribute('id', htmlFor as string)
// 通过可访问性查询验证 label 正确关联到了 input
expect(screen.getByLabelText('用户名')).toBe(input)
})
it('当 TextField 传入自定义 id 时,应优先使用该 id', () => {
render(
<TextField id="email">
<Label>邮箱</Label>
<Input type="email" placeholder="you@example.com" />
</TextField>,
)
const input = screen.getByRole('textbox')
const label = screen.getByText('邮箱')
const htmlFor = label.getAttribute('for') || label.getAttribute('htmlFor')
expect(input).toHaveAttribute('id', 'email')
expect(htmlFor).toBe('email')
expect(screen.getByLabelText('邮箱')).toBe(input)
})
})
describe('子组件 props 优先合并', () => {
it('当子组件传入 id/htmlFor,应覆盖上下文中的默认值', () => {
render(
<TextField id="user">
<Label htmlFor="custom">用户名</Label>
<Input id="custom" placeholder="覆盖默认 id" />
</TextField>,
)
const input = screen.getByRole('textbox')
const label = screen.getByText('用户名')
const htmlFor = label.getAttribute('for') || label.getAttribute('htmlFor')
expect(input).toHaveAttribute('id', 'custom')
expect(htmlFor).toBe('custom')
expect(screen.getByLabelText('用户名')).toBe(input)
})
it('应透传其他原生属性(如 disabled、aria-invalid、className 等)', () => {
render(
<TextField>
<Label className="custom-label">用户名</Label>
<Input placeholder="invalid input" aria-invalid disabled className="custom-input" />
</TextField>,
)
const input = screen.getByRole('textbox')
const label = screen.getByText('用户名')
expect(input).toBeDisabled()
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(input.className).toContain('custom-input')
expect(label.className).toContain('custom-label')
})
})
describe('错误边界', () => {
it('在 TextField 外部使用 Label 应报错', () => {
// 期望实现:useSlotProps 在无上下文时抛出明确错误
expect(() => render(<Label>用户名</Label>)).toThrow(
'SlotContext must be used in TextField component',
)
})
it('在 TextField 外部使用 Input 应报错', () => {
expect(() => render(<Input />)).toThrow(
'SlotContext must be used in TextField component',
)
})
})
})