Skip to content

Commit 7d11b15

Browse files
committed
upd
1 parent 405e510 commit 7d11b15

1 file changed

Lines changed: 138 additions & 68 deletions

File tree

docs/react-v9/contributing/rfcs/react-components/convergence/headless-components.md

Lines changed: 138 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@
66

77
## Summary
88

9-
Introduce `@fluentui/react-headless`: unstyled components built from
10-
`use${ComponentName}Base_unstable` + `render${ComponentName}_unstable`.
9+
We already have base hooks, render functions, and context value hooks.
10+
This RFC proposes the next abstraction layer: fully featured unstyled primitives for all
11+
convergence components so consumers do not need to wire `useButtonBase` + `renderButton`
12+
for every usage, and so internal component state is exposed as stable `data-*` attributes
13+
for CSS targeting without direct hook access.
14+
15+
Introduce `@fluentui/react-headless-components`: unstyled components built from
16+
`use${ComponentName}Base` + `render${ComponentName}`, with stable state
17+
`data-*` attributes emitted by headless state hooks.
1118

1219
Goal: give teams a supported, low-boilerplate path to Fluent behavior/accessibility with custom
1320
visual design.
@@ -17,7 +24,7 @@ Headless components include:
1724
- behavior and ARIA from base hooks
1825
- existing slot API
1926
- forward refs
20-
- stable state `data-*` attributes on root slots
27+
- stable state `data-*` attributes on slots
2128

2229
Headless components exclude:
2330

@@ -33,80 +40,137 @@ Use styled components when teams want Fluent visuals with branding tweaks.
3340
Base hooks solve logic reuse but leave two gaps:
3441

3542
1. Wiring gap: consumers repeat hook + render plumbing for each component.
36-
2. Styling contract gap: there is no stable CSS-targeting contract for component state.
43+
2. State visibility gap at the component abstraction: when consumers use a full component
44+
(`<Button />`) they do not get direct state access like they do in hooks, so they cannot
45+
reliably style based on internal state.
46+
47+
This RFC closes both:
48+
49+
- #1 via pre-wired headless components for all convergence components, removing repetitive
50+
hook + render wiring from app code
51+
- #2 via stable `data-*` attributes emitted by state hooks, keeping full-component DX
52+
while preserving state-driven styling
3753

38-
This RFC closes both via pre-wired headless components and stable state attributes.
54+
## Decision Drivers
55+
56+
- reduce repeated hook + render wiring for common component usage
57+
- provide a stable, documented state-to-attribute contract for CSS targeting
58+
- preserve accessibility behavior parity with styled components
3959

4060
## Proposal
4161

4262
### Package
4363

44-
Ship headless components from `@fluentui/react-headless`.
64+
Ship headless components from `@fluentui/react-headless-components`.
4565

4666
```tsx
47-
import { Button, Checkbox } from '@fluentui/react-headless';
67+
import { Button, Checkbox } from '@fluentui/react-headless-components';
4868
```
4969

70+
All convergence components are in scope. Simple components (Button, Checkbox, RadioButton,
71+
Toggle, Badge) ship first; compound/composite components (Menu, Dialog, Combobox, Select)
72+
follow in subsequent phases.
73+
5074
Base hooks remain available from owning packages.
5175

5276
### Composition Model
5377

54-
Each headless component is hook + render only.
78+
Each headless component is hook + render only (and option context if it's a composite component).
5579

5680
```tsx
57-
import { useButtonBase_unstable, renderButton_unstable } from '@fluentui/react-button';
81+
import { useButtonBase, renderButton } from '@fluentui/react-headless-components';
5882

5983
export const Button = React.forwardRef((props, ref) => {
60-
const state = useButtonBase_unstable(props, ref);
61-
return renderButton_unstable(state);
84+
const state = useButtonBase(props, ref);
85+
return renderButton(state);
6286
});
6387
```
6488

65-
### State Styling Contract (`data-*`)
89+
This abstraction preserves the existing base architecture but removes repetitive wiring from app
90+
code. Note: headless components wrap existing hooks — they introduce no new behavior or
91+
reimplemented logic.
6692

67-
State attributes are authored in base hooks and emitted by both headless and styled variants.
93+
Compound components wire sub-components and their shared context the same way styled variants
94+
do; the difference is that Griffel styles are omitted. Sub-components are exported from the
95+
package root alongside the parent (e.g. `Menu`, `MenuTrigger`, `MenuPopover`, `MenuList`,
96+
`MenuItem`).
6897

69-
Core attributes:
98+
### State To `data-*` Mapping (Primary Contract)
7099

71-
| Attribute | Values |
72-
| --------------------- | ---------------------------------- |
73-
| `data-disabled` | presence |
74-
| `data-focusable` | presence |
75-
| `data-checked` | `"true"` \| `"false"` \| `"mixed"` |
76-
| `data-selected` | presence |
77-
| `data-expanded` | `"true"` \| `"false"` |
78-
| `data-open` | `"true"` \| `"false"` |
79-
| `data-orientation` | `"horizontal"` \| `"vertical"` |
80-
| `data-icon-position` | `"before"` \| `"after"` |
81-
| `data-icon-only` | presence |
82-
| `data-label-position` | `"before"` \| `"after"` |
100+
Headless state hooks map internal state to stable `data-*` attributes. Components then render
101+
those attributes on slots so styling remains possible without direct hook state access.
83102

84-
Rules:
103+
Example (simplified):
85104

86-
- these attributes represent base state only
87-
- no design-state attributes (no appearance/size/shape)
88-
- removal/rename is breaking (major)
89-
- new attributes must be reviewed and documented in a shared schema file
90-
- collisions with consumer-provided attributes are owned by base-hook defaults
105+
```tsx
106+
const state = useButtonBase(props, ref);
107+
108+
// stringifyDataAttribute: returns undefined (omits the attribute) for falsy presence
109+
// attributes, or the string value ("true"/"false"/enum) for boolean/tri-state attributes.
110+
Object.assign(state.root, {
111+
'data-disabled': stringifyDataAttribute(state.disabled),
112+
'data-disabled-focusable': stringifyDataAttribute(state.disabledFocusable),
113+
'data-icon-only': stringifyDataAttribute(state.iconOnly),
114+
});
115+
116+
return state;
117+
```
91118

92-
### Compound Components
119+
Consumer styling:
120+
121+
```css
122+
.myButton[data-disabled] {
123+
opacity: 0.5;
124+
}
125+
126+
.myButton[data-icon-only] {
127+
padding-inline: 0.5rem;
128+
}
129+
```
93130

94-
Compound components are provided as headless wrappers over existing base-context patterns.
131+
or with TailwindCSS:
95132

96133
```tsx
97-
<Menu>
98-
<MenuTrigger>
99-
<Button>Open</Button>
100-
</MenuTrigger>
101-
<MenuPopover>
102-
<MenuList>
103-
<MenuItem>Cut</MenuItem>
104-
</MenuList>
105-
</MenuPopover>
106-
</Menu>
134+
<Button className="data-[disabled]:opacity-50 data-[icon-only]:px-2" />
107135
```
108136

109-
Provider misuse (for example `MenuPopover` outside `Menu`) throws clear development errors.
137+
These attributes are authored in state hooks in `@fluentui/react-headless-components` and are
138+
treated as the stable styling surface for headless primitives.
139+
140+
Core attributes:
141+
142+
| Attribute | Values |
143+
| ------------------------- | ---------------------------------- |
144+
| `data-disabled` | presence |
145+
| `data-disabled-focusable` | presence |
146+
| `data-focusable` | presence |
147+
| `data-checked` | `"true"` \| `"false"` \| `"mixed"` |
148+
| `data-selected` | presence |
149+
| `data-expanded` | `"true"` \| `"false"` |
150+
| `data-open` | `"true"` \| `"false"` |
151+
| `data-orientation` | `"horizontal"` \| `"vertical"` |
152+
| `data-icon-position` | `"before"` \| `"after"` |
153+
| `data-icon-only` | presence |
154+
| `data-label-position` | `"before"` \| `"after"` |
155+
156+
Attribute emission rules:
157+
158+
- presence attributes are emitted only when the state is true; otherwise omitted
159+
- boolean-valued attributes are always emitted as `"true"` or `"false"`
160+
- tri-state attributes (for example `data-checked`) use the declared enum values
161+
- attributes are emitted on the root slot unless a documented component exception exists
162+
163+
Rules:
164+
165+
- these attributes represent base state only
166+
- no design-state attributes (no appearance/size/shape)
167+
- removal/rename is breaking (major)
168+
- adding a new attribute is non-breaking for CSS selectors; it may affect snapshot tests, which is acceptable
169+
- data attributes must be documented on the components documentation page (Storybook docsite)
170+
- base state attributes are reserved; if consumers provide the same `data-*` attribute, the base-hook value wins —
171+
this prevents state misrepresentation (a disabled button must not appear enabled regardless of consumer props)
172+
- precedence must be deterministic: apply reserved base-state attributes after consumer root props are resolved
173+
- `data-*` attributes are emitted as plain DOM attributes and are SSR-safe; no hydration concerns
110174

111175
## Non-Goals
112176

@@ -126,44 +190,50 @@ Validation bar:
126190
- focus management for overlays and composites
127191
- automated a11y checks in component tests
128192

129-
## Versioning and Compatibility
130-
131-
- `@fluentui/react-headless` depends on the source component packages it re-exports from.
132-
- API drift is blocked by CI validation.
133-
- breaking base-hook changes require coordinated major handling in headless exports.
134-
135-
## Migration
136-
137-
- migration path is headless -> styled if teams converge on Fluent visuals later
138-
- import-only switch is the primary path where slot structure/props are compatible
139-
- styled -> headless is not a guaranteed no-refactor path
140-
141193
## Testing Strategy
142194

143-
Tests live with `react-headless` and cover:
195+
Tests live with `react-headless-components` and cover:
144196

145197
- data attribute correctness across state combinations
146198
- slot API parity with styled variants
147199
- compound-component context/ref behavior
148200
- accessibility and keyboard interaction
149201

150-
## Rollout
151-
152-
- Phase 1: Button, Checkbox, Switch, Radio
153-
- Phase 2: Menu, Dialog, Popover
154-
- Phase 3: remaining interactive v9 components
155-
- each phase exits only after parity tests and accessibility checks are green
156-
157202
## Alternatives Considered
158203

159204
### State classes
160205

161-
Rejected: creates naming-coupling with styled layer and is less ergonomic than `data-*` selectors.
206+
```tsx
207+
<Button disabled disabledFocusable>
208+
Button
209+
</Button>
210+
211+
// Renders:
212+
// <button class="fui-Button--disabled fui-Button--disabledFocusable">Button</button>
213+
```
214+
215+
Rejected: creates naming coupling between behavior state and styling implementation details,
216+
encourages internal class contracts, and diverges from a simple selector-based public contract.
162217

163218
### Render props / callback className API
164219

165-
Rejected: splits API from styled components and adds migration friction.
220+
```tsx
221+
<Button className={state => `fui-Button ${state.disabled ? 'fui-Button--disabled' : ''}`}>Save</Button>
222+
```
223+
224+
Rejected: introduces a different composition API from existing Fluent components, increases
225+
verbosity, and makes migration between headless and styled variants harder.
226+
227+
### Style callback prop
228+
229+
```tsx
230+
<Button styles={state => ({ root: { opacity: state.disabled ? 0.5 : 1 } })} />
231+
```
232+
233+
Rejected: recreates a runtime styling API surface, increases API complexity, and does not align
234+
with the goal of exposing state through standard DOM selectors.
166235

167236
## Decision
168237

169-
Proceed with `@fluentui/react-headless` and data attributes as the stable state-styling contract.
238+
Proposed direction: proceed with `@fluentui/react-headless-components` and `data-*` attributes as the stable
239+
state-styling contract.

0 commit comments

Comments
 (0)