|
1 | 1 | # Component Architecture |
2 | 2 |
|
| 3 | +[ADR 0003](../adr/0003-react-aria-is-the-primitive-foundation.md), |
| 4 | +[ADR 0006](../adr/0006-components-support-controlled-and-uncontrolled-modes.md) and |
| 5 | +[ADR 0007](../adr/0007-component-api-naming-conventions.md) own the reasoning behind this file. |
| 6 | + |
3 | 7 | ## Hard Rules |
4 | 8 |
|
5 | 9 | | Rule | Violation | |
6 | 10 | | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | |
| 11 | +| Start a new interactive component from React Aria Components or its hooks | A `div` with an `onClick` and a `role` | |
| 12 | +| Let React Aria own focus traps, roving tabindex, type-ahead and ARIA wiring | Hand-rolling any of them | |
7 | 13 | | Expose styling props, an appendable `className` and a `ref` on the root element | Skipping `useStyledSystem` in a new component | |
8 | 14 | | Name a prop bag after the child it lands on — `<child>Props` | A bespoke `extraProps` for the inner input | |
9 | 15 | | Reuse the shared placeholders for slot content | A component-specific `CardHeader` instead of `Header` | |
10 | 16 | | Ship brand defaults so a consumer sets as few props as possible | A required `variant` prop | |
11 | 17 | | Set a child's appearance from the parent through `SlotProvider` and the child's context | Leaving the consumer to style a composed child | |
| 18 | +| Expose `default*` alongside the controlled prop on a stateful component | A controlled-only prop pair on something new | |
| 19 | +| Route state through `useControlledState`, or the React Aria state hook that wraps it | Hand-writing the controlled/uncontrolled branch | |
12 | 20 | | Leave `stopPropagation` alone; with react-aria's `useKeyboard`, call `event.continuePropagation()` for keys you do not handle | Swallowing `Escape` without meaning to | |
13 | 21 |
|
| 22 | +`InputGroup` calls `preventDefault` deliberately, to forward focus to its inner input — the one |
| 23 | +sanctioned exception. `ActionBar` is the only `useKeyboard` site, and the only controlled-only |
| 24 | +component. Mixing modes on one prop is a silent bug: the controlled prop wins and `default*` is |
| 25 | +ignored, with no React warning, because React never sees these props. |
| 26 | + |
| 27 | +## Naming |
| 28 | + |
| 29 | +| Kind | Convention | Never | |
| 30 | +| -------------------------- | ------------------------------------------ | ----------------------------- | |
| 31 | +| Boolean | `isOpen`, `isDisabled`, `isFluid` | `open`, `disabled` | |
| 32 | +| Event handler | `onPress`, `onChange`, `onSelectionChange` | `onClick` | |
| 33 | +| Uncontrolled initial value | `defaultOpen`, `defaultValue` | `initialOpen` | |
| 34 | +| Element swap | `elementType` | `as`, `component`, `renderAs` | |
| 35 | +| Outer element ref | `ref` | `rootRef`, `containerRef` | |
| 36 | + |
| 37 | +`as` exists on `Box` alone; named inner refs (`inputRef`, `inputStartRef`) are deliberate where there |
| 38 | +are several focusable targets. `onPress` receives a React Aria `PressEvent`, not a native event. |
| 39 | + |
| 40 | +Read prop names from `packages/components/src/<group>/src/<Component>.tsx` — TypeScript is |
| 41 | +authoritative. The generated API JSON under `apps/docs/dist/ai-docs/` is a build artifact and is wrong |
| 42 | +for `Select`, `MultiSelect` and `ComboBox`. |
| 43 | + |
| 44 | +## Styling escalation |
| 45 | + |
| 46 | +Style props first, then an `UNSAFE_*` prop, then a CSS module. `UNSAFE_*` is a whitelist of specific |
| 47 | +props, not a universal prefix — see [ADR 0005](../adr/0005-styling-uses-style-props-and-native-css.md) |
| 48 | +before reaching for it. `UNSAFE_className` and `UNSAFE_style` do not exist. |
| 49 | + |
| 50 | +## Composition |
| 51 | + |
14 | 52 | The shared placeholders are `Header` (`header/`), `Content` and `Footer` (`layout/`), and `Text` |
15 | | -(`typography/text/`), reused through their contexts in 27, 8, 8 and 3 files. `DisclosureHeader` and |
16 | | -`CalendarHeader` are the sanctioned exceptions — a header carrying its own interactive behavior. |
| 53 | +(`typography/text/`). `DisclosureHeader` and `CalendarHeader` are the sanctioned exceptions — a header |
| 54 | +carrying its own interactive behavior. A wrapper is a normal composition unit (`CheckboxField`, |
| 55 | +`PopoverTrigger`); what to avoid is a wrapper whose only purpose is renaming props. |
17 | 56 |
|
18 | 57 | `Callout` is the worked example for parent-driven appearance: it wraps children in a `SlotProvider` |
19 | | -that sets `ButtonContext` and `LinkButtonContext` to `variant: "secondary"`. `Popover`, `Accordion`, |
| 58 | +setting `ButtonContext` and `LinkButtonContext` to `variant: "secondary"`. `Popover`, `Accordion`, |
20 | 59 | `Modal`, `ComboBox` and `Select` do the same. `Card` does **not** — it wires no context at all, so |
21 | 60 | treat it as a gap rather than a pattern to copy. |
22 | 61 |
|
23 | 62 | ## Goals for new API surface |
24 | 63 |
|
25 | | -These hold for anything new. Existing components diverge, so do not "fix" the named cases — several |
26 | | -are locked in by public types and would be breaking changes. |
| 64 | +These hold for anything new. Existing components diverge; do not "fix" the named cases, as several are |
| 65 | +locked in by public types and would be breaking changes. |
27 | 66 |
|
28 | | -| Goal | Where the codebase diverges | |
29 | | -| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | |
30 | | -| Give a wrapper or nested element a prop bag and a `ref` | 8 components render an internal wrapper exposing nothing, incl. `Popover`, `Radio`, `Checkbox`, `Tile`, `ListBoxItem` | |
31 | | -| Give every native and react-aria event a callback prop | `MenuTrigger` wires an internal `onPressStart` a consumer cannot observe | |
32 | | -| Forward the original event arguments to the consumer's handler | `Alert`'s three `on*ButtonClick` props and `TextField.onClear` are typed `() => void` | |
33 | | -| Take rendered content as children in a slot, not as a prop | 18 shipped `ReactNode` props, incl. `prefix`, `footer`, `icon`, `description` | |
| 67 | +| Goal | Where the codebase diverges | |
| 68 | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | |
| 69 | +| Give a wrapper or nested element a prop bag and a `ref` | 8 components render an internal wrapper exposing nothing, incl. `Popover`, `Radio`, `Tile` | |
| 70 | +| Give every native and react-aria event a callback prop | `MenuTrigger` wires an internal `onPressStart` a consumer cannot observe | |
| 71 | +| Forward the original event arguments to the consumer's handler | `Alert`'s three `on*ButtonClick` props and `TextField.onClear` are typed `() => void` | |
| 72 | +| Take rendered content as children in a slot, not as a prop | 18 shipped `ReactNode` props, incl. `prefix`, `footer`, `icon` | |
34 | 73 |
|
35 | 74 | ## Mobile |
36 | 75 |
|
37 | 76 | Where the native mobile experience diverges sharply from the web one, add a sibling built on the |
38 | | -native element to `packages/styled-system/src/html-wrappers/html.ts`, rather than emulating native |
39 | | -behavior inside the richer component. A name that collides with a Hopper component takes an `Html` |
40 | | -prefix — `HtmlButton`, `HtmlHeader` — so a native select would be `HtmlSelect`. None exists yet. |
| 77 | +native element to `packages/styled-system/src/html-wrappers/html.ts`. A name colliding with a Hopper |
| 78 | +component takes an `Html` prefix — so a native select would be `HtmlSelect`. None exists yet. |
41 | 79 |
|
42 | 80 | `packages/components/src/html-elements/` is documentation previews only; nothing there is exported. |
0 commit comments