# XAUI Native — full documentation > Every @xaui/native component page, in one file. The index is https://ui.xtartapp.com/llms.txt. # Accordion A list of rows that open. Replaces the legacy `ExpansionPanel`. ## Import ```tsx import { Accordion } from '@xaui/native/accordion' ``` ## Anatomy ```tsx … … ``` - **`Accordion`** — the container. Holds what is open, resolves every slot's style, and draws the separators between its children. - **`Accordion.Item`** — one row. It owns the height animation, because the height that changes is the row's. - **`Accordion.Trigger`** — the part you press. Carries `expanded` for a screen reader. - **`Accordion.Indicator`** — the chevron, turning with the panel. - **`Accordion.Content`** — the panel. Mounted when open, absent when not. ## Usage ### Basic ```tsx Livraison Sous trois jours ouvrés. ``` A stringifiable child of the trigger is wrapped in a `Text` for you (R3) and takes the type the recipe put on the row. Anything else is yours to place — the trigger is a row of views, and a bare string in one is a crash on React Native. ### Several at a time ```tsx … ``` The value becomes a list, in the order the rows were opened. ### Always one open ```tsx … ``` Pressing the open row refuses rather than closing it, and `onValueChange` never fires for a change that did not happen. That is a set of tabs wearing an accordion. ### A row that paints its own state ```tsx {({ isExpanded }) => ( <> Facturation … )} ``` The escape hatch for a row whose whole appearance changes when it opens, without wiring `useAccordionItem` yourself. ### Controlled ```tsx const [value, setValue] = useState('a') setValue(next as string)}>… ``` Which half is controlled is decided on the first render and then held — a component that changes hands mid-life produces a bug nobody can read from the call site. ### Style as props ```tsx ``` Full RN names, full RN values (R14). Every node takes them. ### Everything else goes through `style` A gradient container, a tinted shadow, a transform — `style` is the last word. ## Props ### `Accordion` | prop | type | default | description | | --------------- | --------------------------------- | -------- | ---------------------------------------- | | `variant` | `AccordionVariant` | `ghost` | The `Card`'s four levels | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `md` | Row inset, gap and type | | `radius` | `RadiusKey` | — | Overrides the container radius | | `color` | `string` | — | The tint (R7) — a raw value | | `selectionMode` | `'single' \| 'multiple'` | `single` | One row open, or as many as asked | | `value` | `string \| readonly string[]` | — | Controlled | | `defaultValue` | `string \| readonly string[]` | — | Uncontrolled | | `onValueChange` | `(value: AccordionValue) => void` | — | Fires only on an actual change | | `isDisabled` | `boolean` | `false` | Dims the container, stops every row | | `isCollapsible` | `boolean` | `true` | Whether the open row can be closed | | `hasSeparator` | `boolean` | `true` | The hairline between rows | | `asChild` | `boolean` | `false` | Renders the caller's element as the root | ### `Accordion.Item` `value` (required), `isDisabled`. `children` may be a function taking `{ isExpanded, isDisabled, value }`. ### `Accordion.Trigger` Everything `Pressable` takes, plus `ViewStyle` as props. `asChild` renders the caller's element as the row. ### `Accordion.Indicator` `as`, `size`, `color`. Defaults to the chevron the library ships from `system/icon`. ### `Accordion.Content` Everything `View` takes, plus `ViewStyle` as props. ## Motion **The height is never measured.** The panel is mounted or it is not, and Reanimated's layout transition animates the row between the two — `LinearTransition.springify()` on The reference implementation's numbers, damping 140 against stiffness **1600**. Stiffer than the chevron's 1000 on purpose: a height is a longer distance than a rotation, and at the chevron's stiffness the same damping makes a long panel take almost half a second to settle. Measuring it ourselves would mean a hidden pass on every open, and a panel whose content grows afterwards — an image loading, a list filling — would be stuck at the height it had when it was measured. `overflow: 'hidden'` on the row is what turns the mounted panel into one unrolling rather than content drawn outside its row from the first frame. The container carries the same transition. Without it the accordion's own height jumps to its new total in one frame while the rows inside it are still animating. **The content fades**, 200 ms each way. The height is already moving underneath it, and two things travelling at once reads as the panel fighting itself. **The chevron** turns 0 → −180° on the `Select`'s spring — damping 140, stiffness 1000, mass 4. It is a worklet, so it keeps turning while the panel's content mounts. ## The four levels | ours | The reference implementation | fill | | ----------- | ---------------------------- | ------------------ | | `primary` | `surface` | `surface` | | `secondary` | — | `surfaceSecondary` | | `tertiary` | — | a border, no fill | | `ghost` | `default` | none — the default | The tokens are the `Card`'s — an accordion in `primary` **is** a card with rows in it, and two containers that look alike but are declared apart drift. Only the names differ, and they differ on purpose: the ladder descends in one direction, which `default` sitting in the middle of the `Card`'s order does not. ## The corner It moves with `size`, as the `Card`'s does, and sits one level below the `Card`'s at every step: | `size` | corner | the `Card` at the same size | | ------ | ------ | --------------------------- | | `xs` | 9 | 12 | | `sm` | 12 | 18 | | `md` | 18 | 24 | | `lg` | 24 | 36 | A card wraps its content with padding on all four sides, so a large corner curves through empty space. An accordion's rows run edge to edge, and the same corner curves through the first and last row's own text. `radius` overrides it, on both layers at once. ## Two layers on the root The root renders one view inside itself, and it is the only place in the library where a root does. A single layer cannot both cast a shadow and clip its children on iOS — `overflow: 'hidden'` sets `masksToBounds`, which takes the layer's own shadow with it — and this component needs both: `primary` is lifted, and a pressed row has to be cut against the card's rounded corner rather than painting over it, square, for as long as a finger is on it. So the outer layer carries the shadow, the border and the corner; the inner one carries the same corner and the clip. Both take the layout transition, because without it on the outer one the accordion's height jumps to its new total a frame ahead of the rows inside it. ## The separators Drawn by the root, **between** its children — never by a row. A row that drew its own would draw one under the last item too, and every accordion would start by hiding it. They are built from `Children.toArray`, which drops nulls, so a conditionally rendered row cannot leave a hairline hanging where nothing is. In `primary`, `secondary` and `tertiary` the container is inset from its own edge and the separators are inset with it. `ghost` has no edge to be inset from, so its rows run the full width and the hairline runs with them — the difference between a list on a page and a list in a box. ## Accessibility The trigger is a `button` carrying `expanded`, which is the only thing telling a screen reader whether the panel under it is open: the chevron says nothing out loud. ## Migration from `@xaui/native-legacy` The legacy components are `ExpansionPanel` and `ExpansionPanelItem`. | Legacy | v1 | | -------------------------- | ---------------------------------------------- | | `` | `` | | `` | `` | | `title="…"` | a stringifiable child of `` | | the item's `children` | `` | | `isExpanded` per item | `value` / `defaultValue` on the root | | `allowMultiple` | `selectionMode="multiple"` | | `themeColor="primary"` | `color={theme.colors.accent}` | | `customAppearance={{ … }}` | `style` on the slot that key named | **The open state moves to the root.** Legacy asked each item whether it was open, which is what made "only one at a time" the caller's problem. One value on the container is what `selectionMode` needs to mean anything. # AgendaCalendar One week, and what is on it. ## Import ```tsx import { AgendaCalendar } from '@xaui/native/agenda-calendar' ``` `AgendaCalendar.Week` pages on a horizontal drag, which runs on `react-native-gesture-handler` — an **optional** peer of this package. Leaving that default on needs the library installed and a `` above the strip; `swipeable={false}` removes both. ## Anatomy ```tsx Today ``` | slot | what it is | | ------------------------------- | ----------------------------------------------- | | `AgendaCalendar` | The card, the state, the resolved style | | `AgendaCalendar.Header` | The row above the strip | | `AgendaCalendar.Title` | The month the week is in | | `AgendaCalendar.Nav` | The cluster on the trailing end | | `AgendaCalendar.PreviousButton` | A week back. Dead at the bounds | | `AgendaCalendar.NextButton` | A week forward | | `AgendaCalendar.TodayButton` | Back to this week. It does not choose today | | `AgendaCalendar.Weekdays` | The seven column headings | | `AgendaCalendar.Week` | The seven days | | `AgendaCalendar.Day` | One day, and the mark under it | | `AgendaCalendar.MonthPicker` | The twelve months, in a row where the days were | | `AgendaCalendar.YearPicker` | The years, in a row where the days were | **Unlike the `Calendar`, this one draws its own card**: a `surface` ground, a corner and its padding. A strip is a thing you put on a screen; a month grid is a thing you put in a sheet. ## It is a `Calendar` folded down to a week **And the row of marks is the whole difference.** A strip of seven numbers is a date picker. A strip of seven numbers with marks under some of them is an agenda. ```tsx ``` `events` is a list rather than a predicate, because a list is what a caller has: a month of events mapped to their dates. It is read **by day**, so the time each one carries is ignored, and it is turned into a set once per change rather than scanned per cell. **The cells are the `Calendar`'s own style**, resolved through `calendarRecipe` rather than a second table. A strip and a month showing two different discs for the same chosen day is what that sharing exists to prevent — and the two sit one above the other the moment a caller expands one into the other, which is what the chevron beside the title is usually for. ## Why it is a component rather than a prop **It steps by weeks.** That is a different unit from the `Calendar`'s month, so the state under it is different too: a `layout="week"` prop would have had to mean "and now `month` is a week", which is a prop that changes what another prop means. Everything genuinely shared — the cell, the disc, the mark, the column headings, the date arithmetic — is shared. The API is not, because the two do not do the same thing. ## No day is ever "outside" All seven are on screen and all seven are choosable. A strip that greyed out the two days belonging to next month would be greying out days it is showing. Only `minValue` / `maxValue` make a day inert. The chevrons go dead when the week they would reach has no selectable day in it — the `Calendar`'s rule, one unit down. ## Swipe to page the week A horizontal drag on `AgendaCalendar.Week` turns the week — **left for the next, right for the previous** — so the chevrons are a shortcut rather than the only way. The strip follows the finger a little and springs back; past ~48pt, or a fast flick, the week changes. A drag that would land on a week with no selectable day does nothing, exactly like the dead chevron. A tap on a day never starts a drag, and a vertical drag is handed straight back to whatever scrolls the page. `swipeable={false}` turns the gesture off — and with it the `react-native-gesture-handler` requirement. ## The week and the day are two pieces of state ```tsx ``` Paging weeks is not choosing a day, for the reason `calendar.md` gives about months. ## Today, in one press `AgendaCalendar.TodayButton` brings today's week on screen, closes any month or year row that is open, **and chooses today** — the same three things legacy's dialog did. A button that dropped you on the right week and left you to find the day would be half a button. `select` still honours the bounds, so an out-of-range today moves the strip without choosing. It goes dead, and reads dead, only when there is nothing left for it to do: today's week is showing, the day strip is the view, and today is the chosen day. Page away, open a picker, or choose another day and it comes back to life. The word is the caller's — "Today", "Aujourd'hui", "Hoy" — and R3 wraps a text child into the label. ## Changing the month and the year ```tsx const [view, setView] = useState('week') setView(v => (v === 'week' ? 'year' : 'week'))}> Today {view === 'year' ? ( ) : view === 'month' ? ( ) : ( <> )} ``` `view` is a third piece of state, controlled the way `value` and `week` are. The pickers are **rows in the strip's place**, not a grid over it — they scroll sideways where the days were, the way the strip itself does. The walk is the `Calendar`'s, one unit down: the title opens the **years**, a year opens that year's months, a month lands back on the week that holds it. **One pair of chevrons pages all three.** `PreviousButton` / `NextButton` step whatever `view` is showing — a week, a month, or a year — and go dead when the unit they would reach has no selectable day in it. The caller reads `view` to render one of the three; the pickers only ever step it. ## The title names the middle day's month A week can straddle two. The **fourth of seven** is always in the majority month, and it is the only rule that does not call a week with six September days in it "August". ## Sizes | `size` | Cell | Nav button | Pill | | ------ | ---- | ---------- | ----- | | `sm` | 36 | 32 | 12/16 | | `md` | 40 | 36 | 14/20 | | `lg` | 44 | 40 | 16/24 | The cell is the `Calendar`'s at the same `size`, so a strip and a month at `md` line up. ## Variants and colour The `Calendar`'s four, on the same tokens, because the chosen day is the same day. **The Today pill answers the same `variant`**: the variant is the card's one level of emphasis, and a strip whose chosen day is a soft wash under a pill that kept a hard accent border is two variants on one component. | `variant` | Chosen day | Today pill | | ----------- | ---------------- | -------------------------------- | | `primary` | Accent disc | Bordered, the word in the accent | | `secondary` | Soft accent disc | Soft accent fill | | `tertiary` | Neutral disc | Neutral fill | | `ghost` | Inverted disc | The word alone | The emphasis runs the other way round from a `Button`'s, and on purpose: `primary` outlines rather than fills, because the pill sits between two bare chevrons and a filled accent there would read as the primary action of the whole card. The accent goes on the word instead. `color` is a raw value (R7) and reaches the disc, the mark on it, **and whatever the variant gave the pill** — the border and the word on `primary`, the fill on `secondary` and `tertiary`, the word on `ghost`. ## Cells of your own `AgendaCalendar.Week` takes a function, exactly as `Calendar.Grid` does and for the same reason: seven cells are generated from a date rather than written. ```tsx {date => ( {date.getDate()} )} ``` ## Props ### `AgendaCalendar` Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | ---------------- | --------------------------------- | ------------ | ---------------------------- | | `variant` | `CalendarVariant` | `'primary'` | The chosen day and the pill | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The cell and the controls | | `radius` | `RadiusKey` | `'2xl'` | The card's corner | | `color` | `string` | — | The disc, its mark, the pill | | `value` | `Date` | — | Controlled | | `defaultValue` | `Date` | — | | | `onValueChange` | `(value: Date) => void` | — | Midnight, local time | | `week` | `Date` | — | Any day in the visible week | | `defaultWeek` | `Date` | the value's | | | `onWeekChange` | `(week: Date) => void` | — | | | `view` | `AgendaCalendarView` | — | Controlled strip on screen | | `defaultView` | `AgendaCalendarView` | `'week'` | Uncontrolled starting strip | | `onViewChange` | `(v: AgendaCalendarView) => void` | — | Fires when a picker steps it | | `events` | `Date[]` | — | The days that carry a mark | | `minValue` | `Date` | — | Compared by day | | `maxValue` | `Date` | — | | | `firstDayOfWeek` | `0`–`6` | the locale's | | | `locale` | `string` | the device's | | | `isDisabled` | `boolean` | `false` | | | `asChild` | `boolean` | `false` | | ### `AgendaCalendar.Week` `children` (a node or a `(date) => node` function), plus `swipeable` (`@default true`) — the horizontal drag that pages the week — and everything `View` takes with the `ViewStyle` keys as props (R14). ### `AgendaCalendar.Day` `date` (required), `children`, `isDisabled`, plus everything `PressableFeedback` takes and the `ViewStyle` keys as props (R14). ### `AgendaCalendar.MonthPicker` Everything `ScrollView` accepts, plus `format` — `'long'` (`"septembre"`) or `'short'` (`"sept."`, the default), clipped to a line since the row scrolls sideways. ### `AgendaCalendar.YearPicker` Everything `ScrollView` accepts, plus `firstYear` / `lastYear` — the ends of the row, defaulting to fifty each way, or to the bounds' own years when the strip is bounded. ## Extending it `useAgendaCalendar()` is exported (R10) and carries the seven days, the chosen one, `hasEvent`, `view` / `setView`, and the moves — `goByWeeks`, `goByMonths`, `goByYears`, `page` (whichever the view shows), `goToYear`, `goToMonthInYear`, `goToToday` and `select` — plus `isOnToday` and `isTodayResolved`. Enough to write a strip of your own: names under the numbers, a count instead of a dot, a second mark in another colour. Outside an `` it throws by name. ## Accessibility - Each day is a `button` labelled with its full date, and a marked day says so in its label: a dot is invisible to a screen reader otherwise. The word is one language, which is the honest limit — a caller with two passes `accessibilityLabel`, and that wins. - The weekday row is hidden from the accessibility tree; each day announces its own date. - The chevrons need an `accessibilityLabel` from the caller, for the `Calendar`'s reason. # Alert A message the interface has to make sure is read — an outcome, a warning, a failure. ## Import ```tsx import { Alert } from '@xaui/native/alert' ``` ## Anatomy ```tsx ``` - **`Alert`** — the root. A `View` with `accessibilityRole="alert"`, laid out as a row of three columns. It resolves the recipe once and publishes the resolved styles to its slots. A string child is wrapped in an `Alert.Description` automatically. - **`Alert.Icon`** — the mark of what the message is. It takes the alert's size and colour without being told either. **It renders a box**, unlike `Button.Icon` and `Chip.Icon`: an alert's icon sits beside a block of text, and lining the glyph up with the first line's cap-height means offsetting it by half the leading — which needs a node. - **`Alert.Content`** — the middle column, and the one that takes the width the other two leave. It exists because the root is a row: without it the title and the description would be laid out _beside_ the icon rather than under each other. - **`Alert.Title`** — the heading. It wraps; `numberOfLines` is a prop for a title that has to fit a row. - **`Alert.Description`** — the message. It sits behind the title on a fraction of the title's own colour. - **`Alert.Close`** — the dismiss affordance, and **the only control an alert contains**. **No slot carries a margin** (R4). What separates the three columns is the root's `gap`, and what separates the title from its description is the content's own — so JSX order is screen order and a leading cross is a matter of where you wrote the slot. **The alert is never a control.** There is no `isPressable` and no press behaviour on the type: an alert reports, and what you press is its `Alert.Close`. ## Usage ### Basic ```tsx Le fichier dépasse la taille autorisée. Facture payée Le reçu part par courriel dans un instant. ``` ### Dismissible ```tsx Connexion perdue Les modifications sont enregistrées localement. ``` The alert does not hide itself. Mounting is the caller's, which is why there is no `isVisible`, no `onVisibleChange` and no exit animation baked in — a component that unmounts itself cannot be animated out by whatever holds it. ### An action inside the message ```tsx Mise à jour disponible Une nouvelle version est prête. ``` ### Single line ```tsx Profil mis à jour ``` The root aligns its columns to the top, because an alert's message can run to three lines and an icon floating beside the middle of a paragraph reads as decoration. An alert with no description centres itself with one style prop, and drops the icon's optical offset with another. ### An icon that disagrees with its alert ```tsx Profil enregistré ``` This is the reference implementation's alert written out: a neutral surface with the status carried by the icon alone. There it is what `status` does; here the variant decides both the surface and the foreground, so an icon that has to disagree says so with a raw `color` (R7). ### As another element ```tsx … ``` The caller's element **is** the alert, so the auto-wrap does not apply — write the slots yourself inside it. ### Sizes `size` drives the padding, the gaps, the radius and the type — **never a height**. An alert is a surface: it is as tall as the message it carries. | `size` | Padding | Gap | Title | Description | Icon | Icon offset | | ------ | ------- | --- | ----- | ----------- | ---- | ----------- | | `xs` | 8 | 8 | 12/16 | 12/16 | 14 | 1 | | `sm` | 10 | 10 | 14/20 | 12/16 | 16 | 2 | | `md` | 12 | 12 | 16/24 | 14/20 | 18 | 3 | | `lg` | 16 | 14 | 18/28 | 16/24 | 20 | 4 | `md` is the reference implementation's alert measured: 12pt of padding, a 12pt gap, a 24pt radius, a 16/24 title above a 14/20 description, an 18pt icon. The icon's offset is **half the title's leading** rather than their hard-coded 3.5px — it lands on the same 3 at `md`, and stays right at the other three sizes. ### Variants Nine names. An alert is the one component that is **both** a surface and a report, so it takes the `Card`'s vocabulary for its neutral level and the `Chip`'s for the rest. | `variant` | Background | Text | Shadow | | -------------- | ------------- | ----------------------- | ------ | | `default` | `surface` | `surfaceForeground` | yes | | `primary` | `accent` | `accentForeground` | — | | `secondary` | `accentSoft` | `accentSoftForeground` | — | | `success` | `success` | `successForeground` | — | | `success-soft` | `successSoft` | `successSoftForeground` | — | | `warning` | `warning` | `warningForeground` | — | | `warning-soft` | `warningSoft` | `warningSoftForeground` | — | | `danger` | `danger` | `dangerForeground` | — | | `danger-soft` | `dangerSoft` | `dangerSoftForeground` | — | `default` is the `Card`'s `default`, token for token — the surface fill and the surface shadow, which is the reference implementation's alert root exactly. The elevation belongs to that one variant: a tinted alert already separates itself by its fill, and a shadow under it would read as dirt. `tertiary` and `ghost` are absent: an alert without a surface is a paragraph. The outlined alert is `default` with `borderWidth` and `borderColor` as style props. **The icon takes the title's colour**, not a status colour of its own — on a `danger` alert the readable colour is the one the title already uses, and on a `danger-soft` one that token _is_ the red. ### Colour ```tsx Sprint 12 ouvert ``` A raw tint, never a token (R7). It paints the fill, and its OKLab-contrasted slice paints the title, the icon and the cross — so a brand-coloured alert stays readable without being told a second colour. It resolves outside the style cache, which is why it must be a hex value. ### Style as props Every node takes its own style keys as props (R14) — full React Native names, full React Native values, no hidden scale: ```tsx … ``` ### Everything else goes through `style` - **A gradient fill, a tinted shadow, a coloured left edge** — the variant names one token for the surface, and a second one is not a variant. - **`overflow: 'hidden'`** is not set on the root, deliberately: on iOS it clips the node's own shadow, and a `default` alert would lose the elevation its variant just gave it. ## Props ### `Alert` Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | ------------------------- | ----------------------------------- | ----------- | ----------------------------------------------------------- | | `variant` | `AlertVariant` | `'default'` | The nine names above | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Padding, gaps, radius, type. Never a height | | `radius` | `RadiusKey` | — | Overrides the radius `size` implies | | `color` | `string` | — | A hex tint, placed by the variant | | `isDisabled` | `boolean` | `false` | Dims, and reaches the close inside | | `asChild` | `boolean` | `false` | Merge into the single child instead of rendering | | `accessibilityRole` | `AccessibilityRole` | `'alert'` | Overridable | | `accessibilityLiveRegion` | `'none' \| 'polite' \| 'assertive'` | `'polite'` | Android announces it on change. `'none'` for a static alert | ### `Alert.Icon` The three forms of `Icon` — `as`, a raw SVG child, `source` — plus the `ViewStyle` keys of its box as props (R14). `size` and `color` default to what the root resolved. **No default glyph**, where the reference implementation ships three. XAUI publishes no icon set — `@xaui/icons` was deleted in P0 — so the icon is always yours, and the alert's job is to size and colour it rather than to choose it. ### `Alert.Content` Everything `View` accepts, plus the `ViewStyle` keys as props (R14). It takes the width the icon and the close leave. ### `Alert.Title`, `Alert.Description` Everything `Text` accepts, plus the `TextStyle` keys as props (R14). Both wrap. ### `Alert.Close` Everything the shared `CloseButtonBase` accepts — `accessibilityRole` is `'button'` and `hitSlop` is `8` by default, and `isDisabled` falls back to the alert's. With no children it draws its own cross, so a dismissible alert works in a project that has installed no icon set. ## Accessibility - The root is `accessibilityRole="alert"` and `accessibilityLiveRegion="polite"` by default: on Android an alert that _appears_ announces itself instead of waiting to be found, and queues behind whatever is being read rather than cutting it off. An alert that is part of the page rather than a response to something sets `'none'`. - **`Alert.Close` needs an `accessibilityLabel`**, and warns in development without one. A cross is not text, and the title beside it names the message, not the action. - **The icon is decoration.** An alert whose meaning is carried only by its colour or its glyph is unreadable to a screen reader and to anyone who does not distinguish the hue — put it in the title. - `Title` and `Description` are real `Text`, so they are read in order and they reflow at large font sizes rather than truncating. ## Migration from `@xaui/native-legacy` | Legacy | v1 | | --------------------------------- | ------------------------------------------------------------- | | `title="…"` | `…` | | `description="…"` | `…`, or a string child | | `icon={}` | `` — sized and coloured by the alert | | `hideIcon` | removed — do not write the slot | | `themeColor="success"` | `variant="success-soft"` (or `"success"` for the solid fill) | | `themeColor="primary"` | `color={theme.colors.accent}` | | `variant="flat"` / `"faded"` | the `-soft` slice | | `variant="solid"` | the full slice: `variant="danger"` | | `variant="bordered"` | `variant="default"` + `borderWidth` / `borderColor` | | `variant="glass"` | removed — a theme, not an alert prop. Out of 1.0 | | `radius={8}` | `radius="sm"`, or `borderRadius={8}` as a style prop | | `isClosable` / `closeButton` | `` — composed, so you place it | | `isVisible` / `onVisibleChange` | removed — the caller mounts and unmounts the alert | | `onClose` | `onPress` on `Alert.Close` | | `style` | `style` on the root | | `titleStyle` / `descriptionStyle` | `style` on `Alert.Title` / `.Description` | | `children` | anything inside `Alert.Content`, after the description | # AreaChart The [`LineChart`](../line-chart/line-chart.md) with the ground under it filled. ```tsx import { AreaChart } from '@xaui/native/area-chart' ``` Everything shared is in [`chart.md`](../chart/chart.md). ## Stacked and overlaid answer different questions **Stacked** reads as a total split into parts. **Overlaid** reads as several quantities compared. A stacked chart whose parts are not parts of one whole is a chart that adds unrelated numbers together — which is why it is a prop a caller sets rather than what happens by default the moment there are two series. The stack is summed into the data before it is scaled, so the plot's own domain reaches the top of it: a stack whose top leaves the axis is a stack drawn outside its own chart. The rows are copied, never written to, and the totalled column is prefixed so it cannot collide with one of the caller's. Series are painted back to front, so the shortest is not hidden by the tallest. ## The fill is a gradient, and a light one The ink belongs at the line, where the number is; the further from it, the less there is to say. So the fill runs from **under a fifth** of the colour at the line to nothing at the axis. Kept that light on purpose. The line carries the number and the fill only says which side of it is "under" — anything heavier competes with the line, and two overlaid series stop being two, because the lower one reads as a shadow of the upper rather than as its own quantity. ## Its own props | Prop | Type | Default | Notes | | ------------- | ------------------------ | ------------ | ------------------------ | | `curve` | `'monotone' \| 'linear'` | `'monotone'` | | | `isStacked` | `boolean` | `false` | Total split into parts | | `strokeWidth` | `number` | `2` | `0` leaves the fill bare | # Autocomplete A field that opens a list you search. ## Import ```tsx import { Autocomplete } from '@xaui/native/autocomplete' ``` ## Usage ```tsx État Californie Texas Aucun résultat Cinquante États, une seule ligne. ``` ## Anatomy | slot | what it is | | -------------------------- | ------------------------------------------ | | `Autocomplete` | The column, and the resolved style | | `Autocomplete.Label` | What the field is for | | `Autocomplete.Trigger` | The control — a `combobox` | | `Autocomplete.Value` | The chosen row's label, or the placeholder | | `Autocomplete.Indicator` | The chevron, turning with the panel | | `Autocomplete.Overlay` | The backdrop. Optional | | `Autocomplete.Content` | The panel | | `Autocomplete.Search` | The field you type in | | `Autocomplete.Item` | One result | | `Autocomplete.Empty` | What the panel says when nothing matches | | `Autocomplete.Description` | The hint under the field | | `Autocomplete.Error` | What is wrong with the choice | ## The root is the column, not the field A `View` that stacks `Autocomplete.Label`, the trigger and `Autocomplete.Description` / `.Error` with one `gap`, so JSX order is screen order. It is the column and the help lines that make this a field on a form rather than a button that opens a list — and they are the `TextField`'s, token for token, so a text field and an autocomplete on the same form read as one control. The `DatePicker` borrows the same three slots for the same reason. `isInvalid` paints the trigger's border and turns the label and the description `danger`; it never mounts or unmounts `Autocomplete.Error`. A slot that silently renders nothing is one you cannot debug, so you write the condition yourself: ```tsx État …{error ? {error} : null} ``` `Autocomplete.Trigger` stays the field: it keeps its own `ref` — it is the node the panel measures — and its own a11y props. `Autocomplete.Overlay` and `Autocomplete.Content` portal out, so they add nothing to the column. ## It is not a `Select` A select is for a list you **read**: a dozen options, all of them visible, and choosing is recognising one. An autocomplete is for a list you **cannot** read — fifty states, four thousand cities — where choosing is _finding_, and the field you type in is the control rather than an extra row in a menu. Same trigger, same panel, same rows; a different thing to do with them. So the two share their style **by construction**: the trigger, the panel and the rows resolve through `selectRecipe`, and only the search box and the empty line are this component's own. A second table would be two to keep in step, and the drift would show as a select and an autocomplete side by side in a form with fields half a shade apart. The rows are plainer than a select's for the same reason. No description, no check: a check beside the row already chosen is of no use in a list you reached by searching for something else. ## The search is the control `Autocomplete.Search` lives **inside** `Autocomplete.Content`, and the panel pins it above its scroller so it stays put while the results move under it. It takes focus as the panel opens — an autocomplete you have to tap twice before you can type into it is a select with a spare row — and `autoFocus={false}` turns that off for a panel short enough that the keyboard would cover it. **The query goes with the panel.** Closing clears it, because a search that survived its own closing would mean the list is already filtered the next time it opens, by a word nobody can see. `query` / `defaultQuery` / `onQueryChange` if you would rather own it. ### What matching means `geneve` finds **Genève**, and `genève` finds **Geneve** — diacritics are folded and case is dropped, both ways. `york` finds **New York**: it matches any word rather than the first, because a long list is searched by whichever word someone remembers, not by the one that happens to come first. An empty query keeps everything, which is what makes the field's resting state the whole list rather than none of it. ## Filtering is by row, and only the rows it can see `Autocomplete.Content` drops the rows that do not survive the query. It reads them the way `Autocomplete.Value` reads the chosen label — off the **elements**, before any of them mounts — but only its **direct** children. Walking deeper to _read_ a label changes nothing; dropping a row nested inside a component of your own would mean rebuilding that component's children for it, and a filter that silently rewrote your tree is worse than one that leaves it alone. A row wrapped in something of yours is a row the search will not hide. A row with no readable label is never hidden either — hiding it would make a custom row disappear the moment anyone typed. ## `Autocomplete.Empty` renders instead of the results And only when nothing matched. A panel that filtered its last row away and showed an empty box reads as a control that has broken rather than as a search that found nothing. ## Props | prop | type | default | description | | --------------- | --------------------------------------------------- | --------- | --------------------------- | | `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'ghost'` | `primary` | The field's four levels | | `size` | `'sm' \| 'md' \| 'lg'` | `md` | The control's scale | | `radius` | `RadiusKey` | — | Overrides the corner | | `color` | `string` | — | The tint (R7) — a raw value | | `value` | `string` | — | Controlled selection | | `defaultValue` | `string` | — | Uncontrolled | | `onValueChange` | `(value: string) => void` | — | Fires on every choice | | `isOpen` | `boolean` | — | Controlled open state | | `defaultOpen` | `boolean` | `false` | Uncontrolled | | `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and close | | `query` | `string` | — | Controlled query | | `defaultQuery` | `string` | `''` | Uncontrolled | | `onQueryChange` | `(query: string) => void` | — | Fires on every keystroke | | `isDisabled` | `boolean` | `false` | Stops the control | | `isInvalid` | `boolean` | `false` | The field's error state | `size` is the **control's** scale and the panel is not the control: a `lg` trigger opening `lg` rows is a menu that fills the screen. Only the type in the search box follows, so what you type reads at the size of what you will pick. `ref`, `style` and R14's style props on the root dress the **column**. The control is `Autocomplete.Trigger`, which takes its own — including `testID` and the a11y props, since it is the node a screen reader stops on. ## Accessibility The trigger is a **`combobox`** rather than a button — the control opens a list you type into, and that is the role that says so. It carries `expanded` and, when `isInvalid`, `aria-invalid`. The search field is a `search`. Both stay overridable (R9). The trigger points at `Autocomplete.Label` and `Autocomplete.Description` through `aria-labelledby` and `aria-describedby`, which is what makes a screen reader announce "État, liste déroulante" rather than reading the chosen row and leaving the question unsaid. The ids come from the root, so mounting the two slots is all it takes; your own `aria-labelledby` on the trigger still wins. The overlay announces nothing: it is the absence of the panel, and a screen reader saying "button" over the whole screen is worse than saying nothing. # Avatar A person or a thing, in a circle. ## Import ```tsx import { Avatar } from '@xaui/native/avatar' ``` ## Anatomy ```tsx ``` - **`Avatar`** — the frame. It resolves the recipe once (R5) and publishes it to its slots. - **`Avatar.Image`** — the photo. Absolutely positioned over the fallback. - **`Avatar.Fallback`** — what shows when there is no photo, and what shows behind one that is still loading. - **`Avatar.Initials`** — the letters. The fallback inserts one around a stringifiable child, so it is rarely written by hand. ## Usage The whole component, most of the time: ```tsx AT ``` With a photo, and initials underneath it: ```tsx AT ``` A glyph instead of letters — and it needs no props: ```tsx ``` A logo, squared: ```tsx ``` ## Props ### `Avatar` | Prop | Type | Default | | --------- | ------------------------------ | ----------- | | `variant` | `AvatarVariant` — eleven names | `'default'` | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | | `radius` | `RadiusKey` | `'full'` | | `color` | `string` — a raw hex tint (R7) | — | | `asChild` | `boolean` | `false` | | `style` | `StyleProp` | — | ### `Avatar.Image` Every `Image` prop, plus `animation?: boolean` (default `true`) and the `ImageStyle` keys as props (R14). ### `Avatar.Fallback` · `Avatar.Initials` Every `View` / `Text` prop, plus that node's style keys as props (R14). ### `variant` colours the frame The `Chip`'s eleven names, meaning here what they mean there — an avatar is a token _about_ a person or a thing, which is the category the `Chip` established. The three status families are here because an avatar reports as often as it identifies: a red frame for the account that failed to sync, a green one for the person who is online. It is the reference implementation's `variant × color` matrix said once. Their `default` is this `default`, and their `soft` crossed with five colours is `secondary` plus the five `-soft` names. ### `size` sets both sides Thirty-two, forty, forty-eight and sixty-four — the reference implementation's three steps plus the `xs` our ladder adds below them, which is the size an avatar is inside a `Chip` or a list row. An avatar is a square before it is a circle, so there is one measurement rather than a width and a height that can drift apart. The glyph inside the fallback runs ahead of the initials at the top of the scale — 12, 14, 16, 20 against 12, 12, 14, 16 — because two letters fill a circle that one person-icon has to sit inside with air around it. ### `radius` — a circle at every size Where the reference implementation fixes one large radius for all three sizes, which makes their small avatars round and their large ones squircles. `full` says the shape the name means once, and `radius` is still there for the logo that wants a square. ## Notes ### The fallback is not a state, it is the layer underneath `Avatar.Image` is absolutely positioned over `Avatar.Fallback`, and an `Image` with nothing decoded yet draws nothing. So the initials show while the photo loads and **stay if the URL is wrong** — with no load-state machine, no `onError` to remember, and nothing to get out of sync. The reference implementation runs a status enum for this; a stacking order says the same thing and cannot disagree with itself. JSX order between the two is therefore free. Write the image first, as the anatomy reads. ### No default glyph XAUI publishes no icon set — `@xaui/icons` was deleted in P0 — so the mark is always the caller's. What `Avatar.Fallback` does instead is publish the frame's resolved size and colour to `IconContext`, so an `Icon` written inside it needs no props at all. ### The fade `Avatar.Image` fades in over 200ms on `onLoad` — the reference implementation's timing. It runs off a shared value rather than off a mount animation, because the node has to be mounted from the first render or it never fetches: the moment worth animating is the decode, not the mount. `animation={false}` skips it and mounts no worklet. ## Accessibility No default `accessibilityRole`: a face is an image, and what a screen reader should read is the name beside it — announcing "image" on every row of a list is noise. Give the avatar an `accessibilityLabel` when it really is the only label there is. `Avatar.Initials` is hidden from the reader for the same reason: two letters standing in for a photo are decoration, and "A T" on every row is noise. Both stay overridable (R9). ## Migration | Legacy | v1 | | ---------------------------------------- | -------------------------------------------------- | | `` | `` | | `` | `AT` | | `` | `` | | `` | `` | | `` | `` or its style props | # Badge A count, or the fact that there is one. ## Import ```tsx import { Badge } from '@xaui/native/badge' ``` ## Anatomy ```tsx ``` **One node and no slots**, per the plan. A badge is a mark: whatever is inside it is one line of two or three characters, and a slot would be a name for a `Text` this component can just as well insert itself (R3). ## Usage ```tsx 3 Payé ``` Hanging off what it counts: ```tsx 12 ``` At the end of a list row, in flow, where no placement is wanted: ```tsx Boîte de réception 128 ``` ## Props | Prop | Type | Default | | ----------- | ------------------------------------------------------------ | ---------- | | `variant` | `BadgeVariant` — the `Chip`'s eleven names | `'danger'` | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | | `radius` | `RadiusKey` | capsule | | `color` | `string` — a raw hex tint (R7) | — | | `isDot` | `boolean` | `false` | | `placement` | `'top-end' \| 'top-start' \| 'bottom-end' \| 'bottom-start'` | — | | `asChild` | `boolean` | `false` | | `style` | `StyleProp` | — | Plus every `ViewStyle` key as a prop (R14) and every `View` prop. ### `danger` is the default The only component in the library whose default is not the first name in its ladder. A badge is overwhelmingly the count of something that wants attention — unread, failed, overdue — and a red one is what a caller writing `3` means. The accent count is a `variant` away. ### It is not a small `Chip` A chip holds a word and hugs it; a badge holds a count and is round unless the count is too wide to be. That is the `minWidth` equal to the height — one digit is a circle, two are a capsule — and it is why the label stays at 12pt through three of the four sizes: a count that grows with its badge stops being a count. The four heights sit below the `Chip`'s: 16, 18, 20 and 24 against its 20, 24, 28 and 36. ### `isDot` — the bare circle No label, no padding, a fixed diameter on its own ladder (6, 8, 10, 12) rather than the height, because a 20pt circle beside a 16pt icon is not a dot. It is what a badge is when the fact that there is _something_ is the whole message. Children are not rendered while it is set: a dot is the absence of a label. ### `placement` — the parent becomes whatever the badge decorates Unset, the badge is in flow and the parent lays it out like any other node, which is what a badge at the end of a list row wants. Set, it is absolutely positioned in that corner, pulled out by half its own height on each axis so its centre lands on the corner it marks. It is a prop rather than four style keys at the call site because the offset is derived from the badge's own `size`, and that arithmetic belongs with the measurements. R13 is the other half: the keys are `start` and `end`, so a badge on the trailing corner mirrors in RTL instead of staying put. The insets are computed **outside the style cache**, and they have to be: in flow the node is `position: 'relative'`, where an inset is a nudge rather than a placement, so a cached style carrying `top: -10` would shift every badge that has no placement at all. ## Accessibility No default `accessibilityRole`: a badge is a label, and what a screen reader reads is the text inside it. What it cannot read is the **subject** — "3" beside a bell means three notifications only to someone who can see the bell — so a placed badge is one of the few places in this library where an `accessibilityLabel` is not optional. ## Migration | Legacy | v1 | | ------------------------------------ | ------------------------------------------- | | `` | `3` | | `` | `` | | `` | `` | | `` | `` (RTL-safe) | | `{children}` wrapping | the parent wraps; the badge is placed on it | # BarChart A bar per row, and a bar per series inside it once there is more than one. ```tsx import { BarChart } from '@xaui/native/bar-chart' ``` Everything shared is in [`chart.md`](../chart/chart.md). ## Grouped compares, stacked totals Stacked is the default, because a bar chart with two series is usually one quantity split. The [`AreaChart`](../area-chart/area-chart.md) makes the same distinction in the same words. Grouped divides the slot the row already has, rather than introducing a second scale: the group **is** the slot, and each series takes an equal share of it. Only the topmost segment of a stack is rounded. A corner between two segments is a gap in a bar that is meant to read as one. ## The corner is a capsule, and it is computed `radius` is a **raw number in points**, not a `RadiusKey`: a bar's corner is bounded by its own width, not by the container scale, and a `2xl` on a six-point bar would be a circle. Unset, it is **under a third of the bar's width**, computed from the slot the scale gave it so it holds at four bars and at forty. A third rather than a half, and the half is what a capsule is: a capsule reads as a shape rather than as a measurement — the eye stops at the dome instead of at the value it marks — and a narrow bar, one of three in a group, becomes a pill with no flat top at all. `barPath` clamps it to the bar's own **height** as well, which is what keeps the shortest bar a stadium rather than a shape whose two arcs cross and render as a knot. That case is not exotic: it is the first bar of every chart that starts near zero. ## Its own props | Prop | Type | Default | Notes | | ----------- | --------- | ------------ | ------------------------------------ | | `isGrouped` | `boolean` | `false` | Side by side rather than stacked | | `radius` | `number` | half the bar | Points, clamped to the bar's own box | | `gap` | `number` | `0.4` | The share of a slot left empty | # BottomSheet A surface that comes up from the bottom edge and can be thrown back down. ## Import ```tsx import { BottomSheet } from '@xaui/native/bottom-sheet' ``` `react-native-gesture-handler` is an **optional** peer of this package, and this component needs it. It is imported here and in the `Slider`, so an app that uses neither never pays for it. ## Anatomy ```tsx … … … … ``` - **`BottomSheet`** — state and resolved style. **It renders no node.** - **`BottomSheet.Trigger`** — what brings it up. - **`BottomSheet.Overlay`** — the backdrop. It dims, and closes on a press. - **`BottomSheet.Content`** — the sheet, and the gesture. - **`BottomSheet.Handle`** — the grab bar. - **`BottomSheet.Title`** / **`BottomSheet.Description`** — its two texts. - **`BottomSheet.Close`** — anything that sends it back down. ## Usage ### Basic ```tsx Partager ce document ``` ### One that cannot be escaped ```tsx … ``` **Two separate refusals**, because a sheet that can be tapped away but not dragged is a real design, and so is the reverse. ### Style as props ```tsx ``` Full RN names, full RN values (R14). Every node takes them. ## Props ### `BottomSheet` | prop | type | default | description | | ------------------ | --------------------------- | ------- | --------------------------------------------- | | `radius` | `RadiusKey` | — | Overrides the **top** corners | | `isOpen` | `boolean` | — | Controlled | | `defaultOpen` | `boolean` | `false` | Uncontrolled | | `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and on close | | `isDisabled` | `boolean` | `false` | Stops the trigger | | `dismissThreshold` | `number` | `0.35` | How far down, as a fraction of its own height | `radius` moves the **top** corners only. A sheet sits on the bottom edge of the screen, so its lower corners are off it — rounding them would put two arcs against a straight edge nobody can see. No `variant`: a sheet is the theme's floating surface, with no emphasis to report. ### `BottomSheet.Content` `isSwipeable` (default `true`), plus `ViewProps` and `ViewStyle` as props. ### `BottomSheet.Handle` Written by you, not drawn by the content — a sheet with `isSwipeable={false}` should not advertise a gesture it refuses. It is the **only** thing telling a reader the sheet can be dragged, so leave it out only when the drag is off. ## How the gesture works **It measures its own height, then slides that far.** A sheet is as tall as what is in it, and nothing else on the screen knows that number — the first layout is what tells the animation how far "down" is. Until it has one the sheet waits off-screen at a pessimistic distance rather than flashing at its resting place for a frame. **Downward only.** A sheet dragged upward has nowhere to go — it is already against the top of its own content — and letting it stretch there is a rubber-band nobody asked for. **Far enough or fast enough.** Past `dismissThreshold` of its height it closes; so does a flick over 900 points a second, whatever the distance. Without the second, a quick flick from the top of a tall sheet is refused however clearly it meant to throw the thing away. Anything short of either springs back. ## A reduced state ```tsx … ``` `collapsedHeight` gives the sheet a **second disclosure inside the first**: it is either up or gone, and while it is up it is either full or reduced. `isExpanded`, `defaultExpanded` and `onExpandedChange` control it the way `isOpen` controls the other one. **These are not snap points.** There are two heights, not an array of them, and the reduced one is a number you give rather than a fraction of the screen the sheet works out. **The sheet is not re-laid out.** It is the same box at its full height, moved further down, so the tail below the reduced height slides off the bottom of the screen and comes back untouched. Nothing re-measures — which is why a bare `collapsedHeight` cuts wherever the number happens to land, and why `BottomSheet.Summary` below exists. ### `BottomSheet.Summary` — cut at a seam you chose ```tsx Café des Arts ★★★★☆ · Ouvert jusqu'à 22 h ``` It is `` to the sheet's `
`, and the same thing an `Accordion.Trigger` is: **the part that survives**, not a second view for the reduced state. It renders in both — what changes is whether everything under it does — so it costs no extra layout. **It reports where its bottom edge falls**, not how tall it is, so whatever sits above it is counted too: a handle above a summary stays visible when the sheet reduces. That is why it must be a **direct child of `Content`** — `y` is relative to the immediate parent, and a summary wrapped in a `View` would report the wrapper's coordinates. **The sheet adds its own bottom padding back onto that edge.** Cutting on the summary's last pixel would leave the reduced sheet with air above the handle and none at all under the last line — the text against the screen edge, and under the gesture bar on a phone that has one. The reduced sheet ends with the padding its expanded self ends with, and overriding the sheet's `padding` moves the seam with it. `collapsedHeight` is not extended that way. It is a number you wrote against a sheet you were looking at, and two hundred points showing has to mean two hundred. Give both and the summary wins, with a warning in development: a measurement of the content is truer than a number that has to be kept in step with it. ### Where a drag goes A drag that was not decisive puts the sheet back, whatever distance it covered. Decisive is past `dismissThreshold` of the sheet's height **or** faster than 900 points a second, either alone being enough. | from | decisive down | decisive up | | --------- | ---------------------------------------------------------------- | ----------- | | expanded | collapsed — or **closed**, if the throw was aimed past the notch | — | | collapsed | closed | expanded | The exception in the first row is the one thing a strict one-state-per-drag rule gets wrong: dragging a sheet the whole way to the bottom and having it stop half open reads as a refusal. Where the throw was aimed is the release point plus 0.15 s of its velocity. Without a `collapsedHeight` there is no middle row and no exception — the sheet behaves exactly as it always has. ### The handle becomes a control On a collapsible sheet `BottomSheet.Handle` is pressable, the way an `Accordion.Trigger` is, and a press toggles the two heights. That is not decoration acquiring a behaviour by accident: a drag would otherwise be the only way in and out of the reduced state, and a drag is a gesture some people cannot perform. It carries `accessibilityRole="button"` and `accessibilityState={{ expanded }}`, and warns in development without an `accessibilityLabel` — a pill says nothing to someone who cannot see it. Without a `collapsedHeight` the handle stays what it was: a pill, hidden from screen readers, taking no touches. **The backdrop does not know about any of this.** A reduced sheet is often a persistent panel rather than a modal, and a dimmed page behind one reads oddly — leave `BottomSheet.Overlay` out, or drive its own props, if that is the sheet you are building. ## Not `@gorhom/bottom-sheet` The reference implementation wraps it. A sheet that slides, springs and dismisses is a pan gesture and a shared value; taking a dependency for that would put a second animation library in every app that installs one component. What we lose is their scroll integration, which is worth having and worth its own change rather than a dependency. Their snap points we do not have and are not planning: `collapsedHeight` covers the case they are almost always used for, with two named states instead of an array of positions. ## Accessibility The sheet is `accessibilityViewIsModal`, so a screen reader stops at it rather than reading the page behind — the spoken half of what the backdrop says visually. The title is a `header`. The handle and the overlay announce nothing: a pill carries no information, and the drag it stands for is not a gesture a screen reader performs. # Button A control that runs an action when it is pressed. The reference component: every other one in the library is this shape. ## Import ```tsx import { Button } from '@xaui/native/button' ``` ## Anatomy ```tsx ``` - **`Button`** — the root. A `PressableFeedback`, which owns the press state, resolves the recipe once and publishes the resolved styles to its slots. String children are wrapped in a `Button.Label` automatically. - **`Button.Label`** — the text. Single-line by default: a control has a fixed height, so a label longer than the button truncates rather than deforming it. - **`Button.Icon`** — an icon that takes the variant's colour and the size's scale with no prop. Accepts `Icon`'s three forms: `as`, a raw SVG child, or `source`. - **`Button.Spinner`** — the busy indicator. `isLoading` inserts one when none is composed. The view depth is one — `PressableFeedback > (Text | Icon)`. There is no wrapper view. ## Usage ### Basic Text children are wrapped for you. ```tsx ``` This works through a recursive stringify, not an inspection of the first child, so `` is a label too — its children are the array `[3, ' items']`. ### Composed Use the slots when you need more than a label. **JSX order is screen order**: there is no `startContent` or `endContent`, and no slot carries a margin of its own — the gap belongs to the root. ```tsx ``` ### Icon only ```tsx ``` `isIconOnly` drops the horizontal padding and squares the button on its fixed height. There is no width to compute. **`accessibilityLabel` is required in practice** — there is no text to announce, and the component warns in development when it is missing. ### Loading ```tsx ``` The spinner is inserted at the head when none is composed. Compose one to place it yourself: ```tsx ``` A loading button is not pressable, and announces as busy rather than as disabled. ### As another element `asChild` merges the button's props — the ref, the styles, the handlers — into its single child instead of rendering a pressable of its own. The child _is_ the button. ```tsx ``` Neither the auto-wrap nor the auto-spinner applies there: there is one child, and it is yours. ### Sizes ```tsx ``` `size` drives **height, padding, gap, radius and type — never width**. A button with no width fills its parent in a column and hugs its content in a row, which is React Native's own behaviour. There is no `fullWidth` prop — the deliberate case is `width="100%"`, said explicitly. To tighten one, compose: ```tsx ``` ### Variants Seven values, emphasis and intention in one flat union. Each names tokens and computes nothing. ```tsx ``` | `variant` | background | border | label | | ------------- | ------------ | -------- | ---------------------- | | `primary` | `accent` | — | `accentForeground` | | `secondary` | `accentSoft` | — | `accentSoftForeground` | | `default` | `default` | — | `defaultForeground` | | `tertiary` | transparent | `border` | `foreground` | | `ghost` | transparent | — | `foreground` | | `danger` | `danger` | — | `dangerForeground` | | `danger-soft` | `dangerSoft` | — | `dangerSoftForeground` | The ladder descends by how much accent is left: `primary` is the full accent, `secondary` its soft slice, `default` gives up the accent for the neutral fill, `tertiary` gives up the fill for a border, `ghost` gives up that too. **`secondary` is to `primary` what `danger-soft` is to `danger`** — the same soft slice of the same family, which is also why a raw `color` lands on it with no rule of its own. **No `success` and no `warning`.** `danger` earns its place because destruction is an action a button performs; a success is an outcome and a warning is a state, and neither is something you press — they belong to whatever reports status (`Alert`, `Chip`, `Badge`). The theme keeps the tokens, so a button that genuinely needs that fill passes the tint: ```tsx ``` ### Colour One raw tint, and where it lands follows the variant rather than the prop: ```tsx ``` **Full React Native names, and therefore full React Native values.** `padding` rather than `p`; `padding={16}` is 16 points, exactly as `style` would be. A prop carrying the RN key's name while silently multiplying its value by a scale would be the most expensive trap in the API. The scale stays one word away: ```tsx const t = useXAUITheme() ``` The set is not a list but a type — the node's style keys (`ViewStyle` on the root, `TextStyle` on `Button.Label`) **minus the directional forms R13 bans**, which are not exposed at all: `paddingStart`, never `paddingLeft`. `Button.Icon` has them too, in `ImageStyle`, and they reach the **`source` form only** — exactly as far as its `style` already does. The other two forms render a third-party component or clone the caller's element, so there is no node of ours to style, and `size` and `color` stay the escape hatch there. Each one styles **the node it is written on**, never a descendant — which is what separates this from the legacy `customAppearance`. `width="100%"` is what replaces `fullWidth`, said explicitly, and `height` beats the height `size` chose, because style props resolve after the recipe. That is an escape hatch, not the normal path: a control whose height you straighten by hand is usually one whose size is missing from the scale. `color` keeps its meaning here — the tint the variant places (above) on the root, and `TextStyle`'s `color` on `Button.Label`. Those coincide rather than conflict: in a text component there is only one thing to tint. A raw fill on the root is `backgroundColor`, which says what it does. They resolve outside the style cache, and the slot's own `style` still wins — it stays the last word for `transform`, a per-platform shadow, or a computed object. ### Everything else goes through `style` There are two appearance props, `variant` and `color`, and no third. A shadow, a border in a different colour than the background, a gradient — those are a slot's own `style`: ```tsx ``` `style` also accepts `Pressable`'s function form, `(state) => style`. ## Props ### `Button` Everything `PressableFeedback` accepts — and therefore everything `Pressable` accepts — every `ViewStyle` key those two do not already claim (R14), plus: | Prop | Type | Default | Notes | | ------------------- | ------------------------------ | ----------- | ------------------------------------------------- | | `variant` | `ButtonVariant` | `'primary'` | The ten values above | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Height, padding, gap, radius, type. Never width | | `radius` | `RadiusKey` | — | Overrides the radius `size` implies | | `color` | `string` | — | A hex tint, placed by the variant | | `isDisabled` | `boolean` | `false` | | | `isLoading` | `boolean` | `false` | Not pressable; inserts a spinner if none composed | | `isIconOnly` | `boolean` | `false` | No horizontal padding, square on the fixed height | | `asChild` | `boolean` | `false` | Merge into the single child instead of rendering | | `animation` | `AnimationProp` | — | `false` mounts no worklet at all | | `accessibilityRole` | `AccessibilityRole` | `'button'` | Overridable | ### `Button.Label` Everything `Text` accepts, plus the `TextStyle` keys as props (R14) — `fontSize`, `color`, `letterSpacing`… `numberOfLines` defaults to `1`. ### `Button.Icon` `Icon`'s props, plus the `ImageStyle` keys as props (R14). `size` and `color` default to what the root resolved; passing either wins. Both `style` and the style props reach the `source` form only — the other two are not views this renders. ### `Button.Spinner` The `ViewStyle` keys as props (R14), plus: | Prop | Type | Default | Notes | | ----------- | ---------------------- | ------- | ------------------------------------------ | | `style` | `StyleProp` | — | | | `animation` | `boolean` | `true` | `false` stops the rotation; the ring stays | ## Touch feedback **A button scales, and mounts no overlay.** That is a decision rather than an omission: the recipe already paints the variant's own `…Pressed` token under the finger, so a wash or a wave on top would darken it twice. A component picks **one** pressed treatment, never both — and there is no prop here to pick the other, because there is nothing to pick between. `animation={false}` renders a different component rather than the same one with a branch inside — no Reanimated hook is reached at all. `'disable-all'` does the same for every descendant, which is how a long list switches its rows off with one prop. A button that genuinely needs a wave is a different component, not a prop away: compose it from `PressableFeedback` directly, with a recipe whose `pressed` state paints nothing. See the `PressableFeedback` page. ## Extending it The context hook is exported, so a third party can write their own slot against the same resolved values the built-in ones read: ```tsx import { useButton } from '@xaui/native/button' function ButtonBadge({ children }) { const { labelStyle, isDisabled } = useButton() return {children} } ``` Used outside a ` ``` Nothing is spaced by hand: the root's `gap` separates the three sections and the body's own gap separates the title from the description. That is what R4 buys — the layout is the component's, not a set of margin classes at the call site. The header is a column and the footer a row, which are the two common cases. Either flips with one style prop (R14): ```tsx Activité ``` ### Pressable ```tsx Facture #1024 ``` `isPressable` is a prop and not an inference from `onPress` being present, because the two answers are different **elements** — a `View` and a `Pressable`. Inferring it would remount the card, and change what a screen reader announces, on the render where a handler happens to become `undefined`. A press handler written without it warns in development. ### As another element ```tsx Facture #1024 ``` Under `asChild` the caller's element **is** the card: it receives the ref, the styles and the handlers. There is no auto-wrap and, on a pressable card, no press wash — the element is the pressable, and there is no sibling to paint under it. ### Sizes ```tsx ``` `size` drives padding, both gaps, the radius and the type of `Title` and `Description` — **never a height**. A card is a surface: it is as tall as what it holds, and as wide as its parent lets it be. There is no `fullWidth`; `width="100%"` and `alignSelf` are the answer, as everywhere else. Two gaps move with it, not one. `gap` separates the sections; the gap inside a section separates a title from its description. One value for both would read as a list of five things rather than as three blocks. ### Variants Four levels, and they are the `Button`'s own names — that is what makes this a subtype of one vocabulary rather than a second one. A card **reports nothing**, so `success`, `warning` and `danger` are absent: a card coloured by an outcome is a card holding a `Chip` or an `Alert` that carries it. | `variant` | Surface | Edge | Elevation | | ----------- | ------------------ | -------- | ----------------- | | `default` | `surface` | — | `shadows.surface` | | `secondary` | `surfaceSecondary` | — | — | | `tertiary` | — | `border` | — | | `ghost` | — | — | — | A filled card has **no border**: the fill and the elevation already separate it from the background, and an edge on top of them is a third signal saying the same thing — which is what makes a list of cards read as a table. `tertiary` names one because there it is the only signal. The elevation belongs to the one variant that is a surface standing on the background. `secondary` is the level for a card _inside_ a card, and `tertiary` and `ghost` have no fill to lift, so a shadow under any of them would read as dirt rather than as height. In dark mode the theme's `surface` shadow is already nothing, which is why the recipe names the role instead of a set of numbers. ### Colour ```tsx … … ``` One raw tint, placed by the variant: the fill of a `default`, the border of a `tertiary`, the text of a `ghost`. Its contrasted slice is derived in OKLab, so the title stays readable on the fill without a second colour being named — and the description follows, because it is a fraction of the title's colour rather than a fixed grey. ### Style as props ```tsx … … ``` Full React Native names, so full React Native values: `padding={32}` is 32 points, never a step on a scale. They resolve after the recipe and before the node's own `style`, and they are scoped to the node they are written on — a `padding` on `Card.Body` pads the body, not the card. ### Everything else goes through `style` A tinted shadow, a gradient, a `transform`, a per-platform value: `style`, on the root or on the slot that needs it. Two cases worth naming: - **A full-bleed image** needs `overflow="hidden"` on the root, which the card does not set itself: on iOS it clips the node's own shadow, and a `default` card would lose the elevation its variant just gave it. - **A border in a different colour than the fill** is `borderColor` and `borderWidth` as style props — the variant names one token for the edge, and a second one is not a variant. ### `Card.Background` — ce que la carte pose derrière elle ```tsx Marrakech ``` **The root paints it first, wherever it is written.** JSX order decides stacking for absolutely positioned siblings, so a background written after the header would cover it. The root hoists the slot instead — the mechanism `PressableFeedback` already uses for its overlays — which is why it is a marked component rather than a `View` the caller positions. `markBackground` is exported, so a third party's gradient or video layer gets the same treatment. **The clip lives on the layer, not on the root.** `overflow: 'hidden'` cuts the node's own shadow on iOS, so putting it on the card would cost a `default` card the elevation its variant just gave it. This slot carries its own `overflow` and the card's radius — which is also why `radius` moves both slots together. The reference implementation clips on both and loses the shadow. Two forms, like `Icon`: `source` renders the image, and anything else is the caller's own layer, already positioned and clipped. ## Props ### `Card` Everything `PressableFeedback` accepts — and therefore everything `Pressable` accepts — every `ViewStyle` key those two do not already claim (R14), plus: | Prop | Type | Default | Notes | | ------------------- | ------------------------------ | ------------------------- | ------------------------------------------------------ | | `variant` | `CardVariant` | `'default'` | The four levels above | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Padding, gaps, radius, type. Never a height | | `radius` | `RadiusKey` | — | Overrides the radius `size` implies | | `color` | `string` | — | A hex tint, placed by the variant | | `isDisabled` | `boolean` | `false` | Dims; on a pressable card, also stops the touch | | `isPressable` | `boolean` | `false` | Renders a `PressableFeedback` instead of a `View` | | `asChild` | `boolean` | `false` | Merge into the single child instead of rendering | | `animation` | `AnimationProp` | — | Pressable cards only; `false` mounts no worklet at all | | `accessibilityRole` | `AccessibilityRole` | `'button'` when pressable | Overridable | The press props are on the type unconditionally, because `isPressable` is read at runtime. A static card is a `View` and ignores them — the component warns in development rather than letting a card look broken. ### `Card.Header`, `Card.Body`, `Card.Footer` Everything `View` accepts, plus the `ViewStyle` keys as props (R14). The header is a column aligned to the leading edge, the body grows, the footer is a row. ### `Card.Title`, `Card.Description` Everything `Text` accepts, plus the `TextStyle` keys as props (R14) — `fontSize`, `color`, `numberOfLines`… Both wrap by default. ## Touch feedback **A pressable card scales and takes a wash**, where the `Button` scales and uses its variant's own `…Pressed` token. A component picks **one** pressed treatment, never both, and the card picks the other one for two reasons: it has no pressed token per surface level to swap to, and on an area this large a flat overlay says "under the finger" better than a fill a shade darker does. The wash contrasts with whatever the card is: the root reads the ink off its own resolved background, so a tinted card, an outlined one and a ghost one each get an overlay that is visible on them. It rounds itself to the card's corners, which is why the root needs no `overflow: 'hidden'` — and must not have one, or the iOS shadow goes with it. `animation={false}` renders a different component rather than the same one with a branch inside — no Reanimated hook is reached at all. `'disable-all'` does the same for every descendant, which is how a list of cards switches its rows off with one prop. ## Extending it The context hook is exported, so a third party can write their own slot against the same resolved values the built-in ones read: ```tsx import { useCard } from '@xaui/native/card' function CardMedia({ source }) { const { bodyStyle } = useCard() return } ``` Used outside a `` it throws by name, pointing at the misplaced component rather than failing three frames later on an undefined style. ## Accessibility - A **static** card has no role of its own and announces nothing: it is a box, and the text inside it is what a screen reader reads. `accessibilityRole` is yours to set when the card is a landmark. - A **pressable** card is `accessibilityRole="button"` by default and stays overridable, and its `accessibilityState` carries `disabled` **merged** with any state you pass — adding `expanded` or `selected` does not erase it. - A pressable card is one target. Nesting a `Button` inside one gives a screen reader two overlapping controls and a sighted user an ambiguous tap — put the actions in the footer of a _static_ card instead. - `Title` and `Description` are real `Text`, so they are read and they reflow at large font sizes rather than truncating. ## Migration from `@xaui/native-legacy` | Legacy | v1 | | --------------------------------------------- | --------------------------------------------------------- | | `themeColor="default"` | `variant="default"` | | `themeColor="primary"` | `color={theme.colors.accent}` | | `padding={16}` | `size`, or `padding={16}` as a style prop | | `elevation={2}` | `variant="default"` — the surface shadow is the variant's | | `fullWidth` | removed — that is already the default in a column | | `isPressable` | `isPressable` — now a `PressableFeedback` | | `isHoverable` | removed — there is no hover on a touch surface | | `isBlurred` / `isFooterBlurred` | removed — a glass theme, not a card prop. Out of 1.0 | | `disableAnimation` | `animation={false}` | | `disableRipple` | removed — a card washes, it does not ripple | | `customAppearance={{ container }}` | `style` on the root | | `customAppearance={{ header, body, footer }}` | `style` on `Card.Header` / `.Body` / `.Footer` | | `customAppearance={{ title, description }}` | `style` on `Card.Title` / `.Description` | # Carousel A series of slides, one or a few at a time, with the controls to move between them. ## Import ```tsx import { Carousel } from '@xaui/native/carousel' ``` ## Usage ```tsx {photos.map(photo => ( ))} ``` ## The slides are yours and the controls are the library's `variant`, `color` and the palette reach the arrows, the dots and the counter. **Nothing here paints what is inside a `Carousel.Item`** — a carousel of photographs and a carousel of cards want opposite things there, and the caller is the one who knows which this is. That is also why there is no `renderItem` and no `data`: the slides are children, in JSX order, and what is in one is whatever you put there. ## A slide's width comes from the track's, never from a prop `itemsPerView` says how many whole slides are in view; `peek` says how much of the next one shows at each edge. `carouselMetrics` divides the measured track between them: ``` ├ peek ┼ gap ┼──── item ────┼ gap ┼──── item ────┼ gap ┼ peek ┤ ``` A carousel whose slide width is given in points is a carousel that is wrong on the next screen size — so it is not a prop, and the arithmetic lives in `utils/carousel.ts` where it is read and tested rather than in the component where it is neither. **The peek brings a gap of its own.** The run it costs at each edge is `peek + gap`, not `peek`; without that second term the peeking neighbour touches the slide in view, and a carousel whose slides touch reads as one wide image that has been cut. Everything is clamped at zero. A track too narrow for the gaps and the peeks it was asked for gives slides of no width rather than negative ones. ## Every control is a slot, not a prop | slot | what it is | | --------------------- | -------------------------------------------------- | | `Carousel.Content` | the track | | `Carousel.Item` | one slide | | `Carousel.Previous` | back one, over the slides | | `Carousel.Next` | on one | | `Carousel.Indicator` | the row of dots | | `Carousel.Dot` | one of them, for an indicator you compose yourself | | `Carousel.Counter` | `3 / 12` | | `Carousel.Thumbnails` | a strip of small versions of the slides | | `Carousel.Thumbnail` | one of them | A carousel that wants dots and no arrows leaves the arrows out. There is no `showArrows={false}`, and there is no order to respect — the indicator above the track is a `` written first. ### The arrows are over the slides An arrow in the flow would move the track it belongs to, and then the two would disagree about where the middle is. They are centred on the **track's** own measured height, so a carousel with dots under it does not have its arrows sitting low. At the end of a series that does not loop an arrow **stays in place and goes quiet** — disabled, not removed. A control that disappears at the last slide takes its width with it and shifts everything beside it. They need an `accessibilityLabel`, and there is a development warning when one is missing. A chevron is not text, and unlike a labelled button there is nothing beside it to fall back on — and no default, because which language it would be in is not this library's to decide. ### The dot is the pill The chosen dot **grows in the row** rather than being covered by a wider node over it. An overlay keeps the row's width fixed, so the pill spills over the dots either side and the reader sees a lozenge with a grey dot half under it. It follows the **drag**, not the settle: the width and the colour are interpolated from the live scroll offset on the UI thread, so the pill grows out of one dot and into the next while the finger is still down. That is what makes a reader believe the indicator is attached to the thing they are moving. A screen reader hears "3 of 5" through `accessibilityValue` rather than a label, for the same reason the arrows have no default one. ### The thumbnails show what they point at The one thing dots cannot do: on a series of photographs a reader picks the one they want rather than counting across to it. The ring around the chosen one is always drawn and only its colour moves — a border that appears on selection nudges every thumbnail after it along the strip. ## The index `index` / `defaultIndex` / `onIndexChange`, controlled or not, as everywhere in the library. It changes as the track **crosses** the halfway point, not when it stops. `onMomentumScrollEnd` would be the obvious place and it is the wrong one twice: it never fires for a wheel or a trackpad, so under the web renderer the index would never move at all; and it lands after the fact, so the arrows and the thumbnails would sit on the previous slide for the length of the deceleration. A fast flick across three slides therefore reports three changes. That is what happened. ## How a move is animated A **drag** is the platform's: `snapToInterval` plus `decelerationRate="fast"`, so a flick lands on a slide rather than between two. An **arrow, a dot or an autoplay tick** is a hand-run tween — the offset is carried from where it is to the next step over ~420ms against an ease-**out** curve, a frame at a time, so the track leaves fast under the press and brakes onto the slide. `scrollTo({ animated: true })` was the obvious call and it is close to linear on both renderers: it leaves and arrives at the same speed and the move reads as a jump cut. A press mid-travel eases on from the live position rather than racing the tween before it. ## `hasLoop` is about the arrows Unset, they stop at the ends. Set, they wrap. **Autoplay always wraps**, whatever `hasLoop` says. That prop answers "what does the arrow do at the last slide"; an autoplay that stopped there would be one that quietly dies. ## `autoPlayInterval` stops at the first interaction And does not come back. A carousel that resumes moving under a reader who has taken hold of it is a carousel fighting them. A drag, an arrow, a dot or a thumbnail all count. It is a number of milliseconds rather than a `hasAutoPlay` beside an interval, because the two would only ever be set together. ## Variants The four emphases, narrowed as `Card` and `Surface` narrow them: a carousel reports nothing — it is a way of arranging what does — so `success` or `danger` would be an intent nothing here has. | variant | the arrows | | ----------- | --------------------------- | | `primary` | on `surface` | | `secondary` | on `default` | | `tertiary` | on `surface`, with a border | | `ghost` | no ground at all | The arrows sit **on** a slide, so their ground is the raised one rather than the page's: a control the same colour as the page vanishes over a pale photograph. `ghost` is what a carousel over a dark photograph wants and what one over a white photograph must not use. `color` lands where the variant put its roles — the active pill and the chevron. The dots at rest keep their neutral fill: they are the ground the pill travels on, and a pale tint of the accent there reads as a control that has half failed to load. ## Size `size` moves the arrows, the dots, the gap between slides, the thumbnails and the counter's type — **never a slide**. A slide's width is the track's; its height is whatever is in it. ## Accessibility The arrows are buttons and carry `accessibilityState.disabled` at the ends. The dots and the thumbnails carry `accessibilityValue` — position and total — and `accessibilityState.selected`. What is _in_ a slide or a thumbnail is yours, so an image with its own `accessibilityLabel` is how a slide says what it shows. ## Props Every node takes R14's style props for its own style type and forwards `ref`, `testID` and the a11y props. `useCarousel()` is exported: it returns the resolved styles, the metrics, the index, the count and the live scroll offset — so an indicator of your own is written the same way the shipped one is. # Charts Five figures — `LineChart`, `AreaChart`, `BarChart`, `PieChart`, `RadarChart` — and the plot the first three share. ## Import ```tsx import { Chart } from '@xaui/native/chart' import { LineChart } from '@xaui/native/line-chart' import { AreaChart } from '@xaui/native/area-chart' import { BarChart } from '@xaui/native/bar-chart' import { PieChart } from '@xaui/native/pie-chart' import { RadarChart } from '@xaui/native/radar-chart' ``` ## The frame A figure on a screen is a card with words around it, and those words are a title, a subtitle, a number and a legend. `Chart` is that card: ```tsx Source du trafic Sessions Douze derniers mois ``` | slot | what it is | | ------------------- | ------------------------------------------------------- | | `Chart` | The card, and the palette everything in it shares | | `Chart.Header` | The row above the figure, pushed apart | | `Chart.Heading` | The title, its description and a value, as one block | | `Chart.Title` | What the figure is. A `header` to a screen reader | | `Chart.Description` | What it is measuring — the unit, the period, the caveat | | `Chart.Value` | The one number it is about, when it is about one | | `Chart.Legend` | Which colour is which series | | `Chart.LegendItem` | One entry of it | | `Chart.Footer` | The row under the figure. It wraps | **It is optional in both directions.** A figure on its own is the shape all five shipped as, and it draws no ground — so `` in a caller's own `Card` is a complete use. And a frame with **no figure in it** is a complete use too: a card with a title, a value and a footer is what a chart looks like while its data is loading, or when there is none. ```tsx Sessions Aucune donnée sur la période ``` Neither half asks for the other, which is what makes the pair worth having. **The frame owns the appearance and the figure takes it.** `variant`, `size` and `color` are handed down, so the legend's dots and the figure's series are the same colours in the same order without either being told twice — which is the whole reason `Chart.Legend` can exist rather than being a prop on a figure. A figure that names its own still wins. `seriesCount` is the one number the frame asks for: it cannot count the figure's series, because it has not rendered the figure and the keys are that figure's props, and a legend needs the palette walked to the right length or its third dot is the wrong colour. **The labels stay the caller's.** What a series is called is a sentence in their language; which colour it got is arithmetic the palette already did. `labels` is the short form and children are the long one — a legend carrying a value and a share beside each name, which is what a donut usually wants under it. `Chart.Heading` exists for `ProgressBar.Header`'s reason: the gap between a title and its subtitle is a different gap from the one between that block and the legend beside it, and two gaps belong to two roots. ## They are drawn here, not wrapped **Nothing is imported to draw them.** `react-native-svg` is already an optional peer — the `Select`'s check and the `Icon`'s chevron use it — and everything above it is this library's: the scales, the paths, the palette. That is not an aesthetic preference. A chart library is a second design system: it has its own idea of a colour, its own appearance blob, its own units, and its API is the one place `variant` and `color` cannot reach. Wrapping one means either exposing that blob — which is `customAppearance`, the thing R2 removed — or fighting it. The alternative was a native graphics engine as a peer dependency, which is a lot of install for four figures. **And the maths becomes tests.** `chart-scale`, `chart-path` and `chart-palette` are 106 cases. A curve that must never dip below its data, an axis that must never label a value above the tallest bar, a bar whose corner must not exceed its own height — all of that is a test rather than a screenshot someone has to remember to look at. ## The API is chartkit's shape ```tsx ``` Rows of objects, a key for the axis, a key per series. The one difference is **`yKeys` plural**, because a chart with two series is the common case and not an escape hatch. **The series are props, not children**, and that is the single place this family parts company with the rest of the library. A line is not a component a caller composes — it is a column of their data. What composition there is lives on `ChartPlot`, which takes a render function for the case a caller needs a mark the five do not draw. ## A shade per series, not a colour per series The palette is walked out of **one** colour — the variant's ink, or `color` when a caller gives one — in OKLab lightness. A chart's series are usually one quantity split: organic and paid traffic, mobile and desktop. Shades of one colour say "parts of a whole" where a rainbow says "unrelated things", and it is the only scheme that survives a caller changing the accent, because there is nothing to change but the seed. The walk **reduces chroma to stay in gamut** rather than clamping channels. Clamping gives away the hue — the channel that overflowed stops moving while the others keep going, and a blue drifts several degrees towards cyan across a ramp — where pulling chroma in keeps the angle exactly and gives away only saturation, which is what a lighter shade of one colour is anyway. ## The plot `LineChart`, `AreaChart` and `BarChart` are three files over one `ChartPlot`, the way the `Autocomplete` is a few files over `selectRecipe`. Three charts with three axis tables would be three to keep in step, and the drift would show as a line chart and a bar chart on one dashboard with labels at two sizes. It owns the frame, the grid, the axes, both scales and the palette. `PieChart` and `RadarChart` are square rather than framed, so they take the palette and the ink and draw their own geometry. ### The axis is honest `niceScale` picks a step from 1, 2, 2.5, 5 or 10 times a power of ten, then **widens the domain** to a multiple of it. The other way round — squeezing the ticks into the data's own range — is what produces an axis labelled 3.33 and 6.67, and an axis whose top tick sits below the tallest bar is an axis that lies about the data. `tickCount` is a target, not a count. ### Point spacing and band spacing A **bar occupies a slot**, so it is centred in one and the ends of the plot are empty. A **line connects readings**, so the first and the last sit on the plot's edges. A line inset by half a slot at either end reads as a chart that has been cut off; a bar on the edge is half outside the plot. Two scales rather than one with a flag, because they are two shapes. Each chart asks for the one it is. ### The labels are React Native `` beside the canvas rather than inside it, so they take the theme's font, its scaling and its colour. `` inside an `` needs a font file loaded and ignores the platform's text size. The plot reserves 40 points for the y labels and 20 for the x. A label of "1 200 000" is the case that loses to, and `formatY` is the answer — which is why it exists. ## The curve never leaves its data `monotone` is Fritsch–Carlson: the tangent at each point starts as the average of the slopes either side of it, is **flattened to zero wherever the data turns**, and is capped at three times the neighbouring slope elsewhere. That cap is the condition under which a cubic Hermite segment cannot leave the interval its endpoints set. The alternative — a control point at the x-midpoint — is four lines and overshoots: two high readings either side of a low one bow the curve below the low one, and on an area chart that is ink under the axis. `curve="linear"` for straight edges. ## Variants and colour | `variant` | The series' ink | | ----------- | --------------- | | `primary` | `accent` | | `secondary` | `foreground` | | `success` | `success` | | `warning` | `warning` | | `danger` | `danger` | The `ProgressBar`'s five, for the `ProgressBar`'s reasons: a chart reports a quantity, so `primary` and `secondary` are its two emphases and the three intents are for when the number itself is the news. `tertiary` and `ghost` are gone — a series with no ink is not a series — and the `*-soft` pairs with them, since a chart's fills are already soft. The ink is `bgSelected`, a **role** rather than a token named in an axis: the tint pass re-runs `paint` and never the axes, so a series colour written as an axis would snap back to the accent the moment a caller set `color`, and the palette walked out of it would go too. **No figure paints its own ground.** One that did would be a card inside the `Chart` frame, or inside the caller's own `Card` where there is no frame. ## Sizes | `size` | Plot height | Ring / web | | ------ | ----------- | ---------- | | `sm` | 140 | 140 / 180 | | `md` | 200 | 200 / 240 | | `lg` | 280 | 260 / 300 | **`size` is the plot's height, never its width.** A chart spans its parent — RN's own behaviour, and the reason there is no `fullWidth` — and how tall it is is the only thing a caller cannot infer from the column it sits in. A pie and a radar are square, so for those it is a diameter. ## Shared props Every cartesian chart takes these, plus everything `View` accepts and the `ViewStyle` keys as props (R14): | Prop | Type | Default | Notes | | ----------------- | --------------------------- | ----------- | ---------------------------------------------------- | | `data` | `Data[]` | — | An empty list draws the axes | | `xKey` | `keyof Data` | — | What runs along the bottom | | `yKeys` | `(keyof Data)[]` | — | The series, in palette order | | `variant` | `ChartVariant` | `'primary'` | | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The plot's height | | `color` | `string` | — | The palette's seed | | `hasGrid` | `boolean` | `true` | | | `hasXAxis` | `boolean` | `true` | | | `hasYAxis` | `boolean` | `true` | | | `tickCount` | `number` | `4` | A target, not a count | | `xLabelCount` | `number` | what fits | Every nth row is labelled | | `formatX` | `(value, index) => string` | — | | | `formatY` | `(value: number) => string` | — | | | `hasZeroBaseline` | `boolean` | `true` | An axis from the data's floor lies about proportions | | `isDisabled` | `boolean` | `false` | | ## Extending them `ChartPlot` is exported. It takes the same props and a **render function** that receives the scaled series, the frame, both scales and the ticks — enough to draw a mark none of the five does, on the same axes as the ones that do. ```tsx {({ series, frame }) => ( )} ``` `chart-scale` and `chart-path` are `utils/` — private, and deliberately: they are the family's own maths, not an API. A chart of your own reaches for `ChartPlot`. # Checkbox A box that is either ticked or not, with the label that says what it means. ## Import ```tsx import { Checkbox } from '@xaui/native/checkbox' ``` ## Anatomy ```tsx ``` - **`Checkbox`** — the root, and it is the **row, not the box**. It is the pressable: it owns the selected state, resolves the recipe once (R5) and publishes it to its slots. - **`Checkbox.Indicator`** — the box, and the mark inside it. With no children it draws its own check. - **`Checkbox.Label`** — what ticking the box means. **The root is the row** so that tapping the label toggles the checkbox. That is the whole reason the label is a slot here rather than a `Text` you put beside the component and wire up yourself — and it is why `PressableProps` are on the root. **R3 — and the box comes with the auto-wrap.** A stringifiable tree becomes the label _and_ the root supplies the indicator, because a checkbox without one is not a checkbox: ```tsx J'accepte // is exactly J'accepte ``` Written with no children at all, it is the box alone — the form you want inside a table row, where the label is a cell of its own. Anything else and the arrangement is yours. **No slot carries a margin** (R4). What separates the box from its label is the root's `gap`, so JSX order is screen order — a label before the box is a matter of where you wrote it. ## Usage ### Controlled, or not ```tsx J'accepte les conditions Recevoir la lettre d'information ``` `isSelected` present means the caller owns the value; `defaultSelected` means the checkbox keeps it. `onSelectedChange` fires either way, and so does `onPress` — the tick and your handler both happen, in that order. ### Invalid ```tsx Il faut accepter pour continuer ``` The border, the mark's fill and the label turn `danger`, and the **resting fill is dropped**: a box that is wrong reads as an outline rather than as a filled control that happens to be red at the edge. The reference implementation reaches the same shape with a compound. It does not mount a message. Put one under the row yourself — a slot that silently renders nothing is a slot you cannot debug, which is the `TextField.Error` bargain again. ### Indeterminate ```tsx Tout sélectionner ``` Neither ticked nor empty — the state a "select all" sits in while some of its rows are. The box fills as if selected and the mark is a dash, a screen reader hears `mixed`, and a press resolves it to **selected** rather than toggling into it: "some of these" is a state a control reports, never one a person picks. It is a display state and does not touch `isSelected` — the caller decides when the tri-state collapses, because only the caller knows what the rows say. ### Disabled ```tsx Verrouillé ``` On the row, not the box: what is disabled is the control, and the label is part of it. R8 keeps `disabled` off the public surface. ### A label that wraps ```tsx J'accepte que ces informations soient conservées… ``` The box centres on the row, which is right for one line and wrong for a paragraph. A style prop moves it (R14) — no second API for it. ### A mark of your own ```tsx ``` Children replace the built-in check and ride the same fade. The built-in one exists so a checkbox works in a project that has installed no icon set — `@xaui/icons` was deleted in P0 — not to stop you having your own. ### As another element ```tsx … ``` R12 — the caller's element **is** the row, so it takes the children it was written with and the auto-wrap does not apply. ## Sizes | `size` | Box | Corner | Check | Gap | Label | | ------ | --- | -------- | ------------ | --- | ----- | | `sm` | 20 | `sm` (6) | 10 × 5 · 2 | 8 | 14/20 | | `md` | 24 | `md` (9) | 12 × 6 · 2 | 8 | 16/24 | | `lg` | 28 | `md` (9) | 14 × 7 · 2.5 | 10 | 18/28 | `size` drives the box, the glyph, the gap and the label's type — **never width**. A checkbox hugs its label; a row that has to fill its parent is a style prop away. The check is **derived from the box** — half its width, a quarter its height — rather than tabulated, so it is one glyph at four sizes instead of four drawings of one. `md` is the reference implementation's checkbox measured: a 24pt box with the field's 1pt border. The corner is `md` (9) where theirs is `lg` (8) — the same key is 12 on our radius base, and 12 on a 24pt box is a circle, which is the `Radio`. ## Variants Three of the `TextField`'s four levels, meaning here what they mean there — this is the `field*` family again, on a box 24pt wide instead of a field 48pt tall. | `variant` | Box at rest | Border | Shadow | | ----------- | ----------------- | ------------- | ------- | | `primary` | `fieldBackground` | `fieldBorder` | `field` | | `secondary` | `default` | `fieldBorder` | — | | `tertiary` | transparent | `fieldBorder` | — | **`ghost` is absent**, where the `TextField` has it: a field with no border is still a line of text you can see; a checkbox with no border and no fill is nothing at all. **A variant describes the box at rest.** Ticked, all three are the accent — or `color`. ## Colour ```tsx Teinté ``` A raw tint, never a token (R7), and on this component it is **the colour the box checks in**, with a mark derived to read against it. That works because the selected fill is a **role** — `bgSelected`, with `fgSelected` for the mark — rather than a token named in an `isSelected` axis. The tint pass re-runs `paint` and the states, never the axes, so a fill written as an axis would have been the accent again the moment the box was ticked. `Radio` and `Switch` need the same pair, which is why it is in the engine rather than here. **The tint is ignored while `isInvalid`.** An error outranks a brand colour, the same way it outranks focus on the `TextField` — and unlike the `TextField`, where a tint and an error currently fight over the border, this one is decided in the root and not left to resolution order. ## Selection is not a style axis `isSelected` never reaches the recipe. The fill and the mark are two slots the indicator **mounts only while it is ticked**, painted unconditionally from the roles above. That buys two things at once: the style cache keeps one entry per token combination instead of two, and `color` reaches the selected fill — which an axis would have skipped. ## Animation The fill fades and grows in over 120ms, and the mark rides with it: a check arriving before its background reads as a glitch rather than as an animation. `` turns it off, and then the Reanimated hooks are never reached at all — two components rather than a branch inside one. The press treatment is `PressableFeedback`'s, so `animation` on the root is the library's usual knob: `false`, `'disabled'`, `'disable-all'`, or the object. ## Alignment with the reference implementation Measured against their `checkbox.tsx` and `checkbox.css` rather than eyeballed. **Identical:** the 24pt box, the field border and its width, the `field` fill plus shadow on `primary` and the neutral `default` on `secondary`, the accent fill with an `accentForeground` mark, the danger treatment including the dropped resting fill, the fade and scale on the mark, and a built-in check so no icon set is required. **Four deltas:** | Theirs | Ours | Why | | ------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | The root is the box; a label needs `ControlField` | The root is the row, `Label` is a slot | The plan's slots for this component are Indicator · Label, and a label that does not toggle the box is a bug waiting to be filed. | | `variant` auto-switches on a surface | `secondary` by default | We have no surface detection, and the `TextField` already answers this the same way. | | One size | Four | Every control in this library has the same ladder. | | `Checkbox.Background` (a glass layer) | — | A theme-registered blur layer belongs to their Uniwind theming, which is the premise XAUI does not share. | **We have `isIndeterminate` and they do not.** The legacy `@xaui/native-legacy` checkbox had it, a "select all" is the reason it exists, and `accessibilityState.checked: 'mixed'` is a thing only the component can say. ## Props ### `Checkbox` Everything `PressableFeedback` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | ------------------ | ------------------------------- | ------------- | --------------------------------------------- | | `variant` | `CheckboxVariant` | `'secondary'` | The box at rest | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Box, glyph, gap, label type | | `radius` | `RadiusKey` | — | Overrides the corner the size chose | | `color` | `string` | — | A hex tint — the colour it checks in | | `isSelected` | `boolean` | — | Controlled | | `defaultSelected` | `boolean` | `false` | Uncontrolled starting value | | `onSelectedChange` | `(isSelected: boolean) => void` | — | Fires on every tick, either mode | | `isIndeterminate` | `boolean` | `false` | The third state — a dash, and `mixed` in a11y | | `isInvalid` | `boolean` | `false` | Danger colours, and the tint is ignored | | `isDisabled` | `boolean` | `false` | Dims the row and stops the press | | `animation` | `AnimationProp` | — | The press treatment, as everywhere else | | `asChild` | `boolean` | `false` | Merge into the single child | ### `Checkbox.Indicator` Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus: | Prop | Type | Default | Notes | | ----------- | ----------- | ------- | ------------------------------------------------ | | `children` | `ReactNode` | — | Replaces the built-in check, inside the fill | | `animation` | `boolean` | `true` | `false` shows the mark with no fade and no scale | ### `Checkbox.Label` Everything `Text` accepts, plus the `TextStyle` keys as props (R14). ## Extending it The context hook is exported, so a third party can write its own slot against the same resolved values the built-in ones read: ```tsx import { useCheckbox } from '@xaui/native/checkbox' function CheckboxDescription({ children }) { const { isInvalid } = useCheckbox() return {children} } ``` Used outside a `` it throws by name, pointing at the misplaced component rather than failing three frames later on an undefined style. ## Accessibility - `accessibilityRole="checkbox"` on the root, overridable, with `accessibilityState.checked` following the value and `.disabled` following `isDisabled`. A caller's own `accessibilityState` is **merged**, not spread over: naming one key must not silently drop the two a screen reader reads this control by. - `aria-invalid` follows `isInvalid`. - **The label is inside the control**, so the checkbox announces itself with its text instead of as an unnamed box. A checkbox with no label needs an `accessibilityLabel`. - The whole row is the touch target, which is what makes a 24pt box reachable; `hitSlop` is `Pressable`'s and still available for the case where it is not. - **The built-in check mirrors under RTL**, because `borderStartWidth` is a logical edge and R13 leaves no other kind. A check reads as a check either way. ## Migration from `@xaui/native-legacy` | Legacy | v1 | | ----------------------------- | -------------------------------------------------------------------- | | `label="…"` | `…` — the text child _is_ the label | | `labelAlignment="left"` | write `` before `` | | `labelAlignment="justify-*"` | `justifyContent="space-between"` plus a width, in style props | | `isChecked` / `onValueChange` | `isSelected` / `onSelectedChange`, plus `defaultSelected` | | `isIndeterminate` | `isIndeterminate` — unchanged, and now `mixed` in a11y too | | `isDisabled` | unchanged, on the root | | `themeColor="primary"` | `color={theme.colors.accent}` — a raw value (R7) | | `variant="filled"` | `variant="tertiary"` — a border at rest, the accent once ticked | | `variant="light"` | gone: a mark with no box. `` with your own glyph | | `size="sm" \| "md" \| "lg"` | `size` — the same three | | `radius` | `radius` — a `RadiusKey` now, not the legacy `Radius` | | `fullWidth` | `width="100%"` in style props (R14) | | `labelStyle` | `style` on `` | | `style` | `style` on the root | # Chip A compact token — a status, a tag, a filter, a person. ## Import ```tsx import { Chip } from '@xaui/native/chip' ``` ## Anatomy ```tsx ``` - **`Chip`** — the root. A `View`, or a `PressableFeedback` when `isPressable` is set. It resolves the recipe once and publishes the resolved styles to its slots. A string child is wrapped in a `Chip.Label` automatically, which is the majority case: `Payée` is the whole component most of the time. - **`Chip.Label`** — the text. Single-line by default, like `Button.Label` and for the same reason: a chip has a fixed height, so a label too long for it truncates rather than deforming the capsule. - **`Chip.Dot`** — the status mark. A filled circle in the variant's foreground, sized by the chip. - **`Chip.Icon`** — a glyph that takes the chip's size and colour without being told either. The three forms of `Icon`: `as`, a raw SVG child, or `source`. - **`Chip.Avatar`** — a round, chip-sized frame: `source` renders an image, anything else is centred and clipped the same way. Its diameter always fits inside the chip's height. - **`Chip.Close`** — the dismiss affordance, and **a control in its own right**. It owns its press state rather than reading the chip's, and with no children it draws its own cross — so a dismissible chip works in a project that has installed no icon set. **JSX order is screen order** (R4). No slot carries a margin: what separates a dot from its label, or a label from its cross, is the root's `gap`. That is why there is no `startContent` / `endContent` — a leading cross and a trailing one differ by where you wrote the slot, and nothing has to be undone to swap them. ## Usage ### Basic ```tsx Nouveau Payée ``` ### Composed ```tsx En cours Amina Expire demain ``` ### Pressable ```tsx Design ``` `isPressable` is a prop rather than an inference from `onPress` being present, because the two answers are different **elements** — a `View` and a `Pressable`. Inferring it would remount the chip, and change what a screen reader announces, on the render where a handler happens to become `undefined`. A chip carrying only a `Chip.Close` stays static: the control is the cross, not the chip around it. ### Dismissible ```tsx { tags.map(tag => ( {tag} remove(tag)} /> )) } ``` Pass a child to replace the built-in cross: ```tsx ``` ### As another element ```tsx Design ``` The caller's element **is** the chip, so the auto-wrap does not apply — write the label yourself, or a slot, inside it. ### Sizes `size` drives the height, the horizontal padding, the gap and the type — **never the width**. A chip hugs its label: `alignSelf` is `flex-start`, so a chip in a column stays the width of its text instead of stretching, which is the one place its layout deliberately differs from a `Button`'s. | `size` | Height | Padding | Label | Dot | Avatar | | ------ | ------ | ------- | ----- | --- | ------ | | `xs` | 20 | 8 | 12/16 | 4 | 14 | | `sm` | 24 | 10 | 12/16 | 5 | 18 | | `md` | 28 | 12 | 14/20 | 6 | 22 | | `lg` | 36 | 16 | 16/24 | 8 | 28 | The height is fixed where the reference implementation uses vertical padding. Same numbers — theirs resolve to 20, 28 and 36 — and a different reason to arrive at them: with padding, a chip carrying an avatar is taller than the chip beside it carrying only text, and a row of filters stops lining up. ### Variants Eleven flat names, replacing the reference implementation's `variant × color` matrix — four emphases times five intents, of which nine combinations paint the same thing. The first five are the `Button`'s ladder, descending by how much accent is left. The six that follow are the three status families the `Button` deliberately refused: a button is something you press and neither a success nor a warning is an action, while a chip **reports** — the outcome _is_ what it says. | `variant` | Background | Border | Text | | -------------- | ------------- | -------- | ----------------------- | | `primary` | `accent` | — | `accentForeground` | | `secondary` | `accentSoft` | — | `accentSoftForeground` | | `default` | `default` | — | `defaultForeground` | | `tertiary` | transparent | `border` | `foreground` | | `ghost` | transparent | — | `foreground` | | `success` | `success` | — | `successForeground` | | `success-soft` | `successSoft` | — | `successSoftForeground` | | `warning` | `warning` | — | `warningForeground` | | `warning-soft` | `warningSoft` | — | `warningSoftForeground` | | `danger` | `danger` | — | `dangerForeground` | | `danger-soft` | `dangerSoft` | — | `dangerSoftForeground` | The dot and the cross take the **text** colour, not a status token of their own: on a filled `success` chip the only readable colour is the one the label already uses, and on a `success-soft` one that token _is_ the green. One rule, eleven variants, and a tinted chip gets it for free. ### Colour ```tsx Sprint 12 Sprint 12 ``` A raw tint, never a token (R7). Where it lands follows the variant, exactly as on a `Button`: the fill of a `primary`, the border and text of a `tertiary`, the text of a `ghost`. Its soft, contrasted and pressed slices are derived in OKLab, so the dot and the cross follow without being told a second colour — and so a tinted chip presses the same way a token one does. It resolves outside the style cache, which is why it must be a hex value and why it is the one prop that allocates per render. ### Style as props Every node takes its own style keys as props (R14) — full React Native names, full React Native values, no hidden scale: ```tsx Un libellé très long qui sera tronqué En ligne ``` A dot that reports something other than what its chip does is the case `backgroundColor` exists for. `padding={16}` is 16 points; reach for the scale explicitly with `padding={theme.spacing(4)}`. ### Everything else goes through `style` - **A gradient fill, a tinted shadow, a border in a colour the fill does not imply** — the variant names one token for the edge, and a second one is not a variant. - **`radius`** overrides the capsule. A chip is a pill at every size, which is what the name means, so this is the escape hatch for the tag that wants corners. ## Props ### `Chip` Everything `PressableFeedback` accepts — and therefore everything `Pressable` accepts — every `ViewStyle` key those two do not already claim (R14), plus: | Prop | Type | Default | Notes | | ------------------- | ------------------------------ | ------------------------- | ------------------------------------------------- | | `variant` | `ChipVariant` | `'primary'` | The eleven names above | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Height, padding, gap, type. Never width | | `radius` | `RadiusKey` | — | Overrides the capsule | | `color` | `string` | — | A hex tint, placed by the variant | | `isDisabled` | `boolean` | `false` | Dims, stops a pressable chip, reaches its close | | `isPressable` | `boolean` | `false` | Renders a `PressableFeedback` instead of a `View` | | `asChild` | `boolean` | `false` | Merge into the single child instead of rendering | | `animation` | `AnimationProp` | — | Pressable chips only; `false` mounts no worklet | | `accessibilityRole` | `AccessibilityRole` | `'button'` when pressable | Overridable | The press props are on the type unconditionally, because `isPressable` is read at runtime. A static chip is a `View` and ignores them — the component warns in development rather than letting a chip look broken. ### `Chip.Label` Everything `Text` accepts, plus the `TextStyle` keys as props (R14). `numberOfLines` is `1` by default. ### `Chip.Dot` Everything `View` accepts, plus the `ViewStyle` keys as props (R14). A circle sized by the chip and filled with its foreground. ### `Chip.Icon` The three forms of `Icon` — `as`, a raw SVG child, `source`. `size` and `color` default to what the root resolved and an explicit value wins. ### `Chip.Avatar` Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus `source` for the image form. It is a frame, not the `Avatar` component of P3.11 — when that lands, this slot becomes its call site rather than a second implementation. ### `Chip.Close` Everything `PressableFeedback` accepts, plus the `ViewStyle` keys as props (R14). `accessibilityRole` is `'button'` and `hitSlop` is `8` by default; `isDisabled` falls back to the chip's. ## Touch feedback **A pressed chip scales and its fill goes one step down**, using the variant's own `…Pressed` token — the `Button`'s treatment, not the `Card`'s. A component picks **one** pressed treatment, never both, and a chip picks this one because a wash over something this small reads as a smudge, while the fill moving reads as the thing itself being pushed. `Chip.Close` owns a press state of its own. The chip may be a `View`, and even when it is pressable the two are different targets — pressing the cross must not read as pressing the chip around it. `hitSlop` grows the cross's target outwards, because a cross big enough to hit is a cross too big to look right. `animation={false}` renders a different component rather than the same one with a branch inside — no Reanimated hook is reached at all. `'disable-all'` does the same for every descendant, which is how a long list of chips switches its rows off with one prop. ## Extending it The context hook is exported, so a third party can write their own slot against the same resolved values the built-in ones read: ```tsx import { useChip } from '@xaui/native/chip' function ChipCount({ children }) { const { labelStyle } = useChip() return {children} } ``` Used outside a `` it throws by name, pointing at the misplaced component rather than failing three frames later on an undefined style. ## Accessibility - A **static** chip has no role of its own and announces nothing: it is a label, and the text inside it is what a screen reader reads. - A **pressable** chip is `accessibilityRole="button"` by default and stays overridable, and its `accessibilityState` carries `disabled` **merged** with any state you pass — adding `selected`, which is what a filter chip announces, does not erase it. - **`Chip.Close` needs an `accessibilityLabel`**, and warns in development without one. A cross is not text, and unlike an icon-only button there is nothing to fall back on: the label beside it names what is being removed, not the action. - **A dot is decoration.** A chip whose colour or dot is its only meaning is unreadable to a screen reader and to anyone who does not distinguish the hue — put the status in the label. - A pressable chip is one target. Putting a `Chip.Close` inside one gives a screen reader two overlapping controls; it works, and it is worth asking whether the chip needs to be pressable at all. ## Migration from `@xaui/native-legacy` | Legacy | v1 | | ------------------------------------ | --------------------------------------------------------- | | `variant="solid"` + `themeColor` | `variant="primary"` / `"success"` / `"danger"`… | | `variant="flat"` / `"faded"` | the `-soft` slice: `variant="success-soft"` | | `variant="bordered"` | `variant="tertiary"` | | `variant="light"` | `variant="ghost"` | | `variant="dot"` | `variant="tertiary"` + `` | | `variant="shadow"` | removed — a chip is not a surface. `style` if you need it | | `themeColor="primary"` | `color={theme.colors.accent}` | | `size="sm" \| "md" \| "lg"` | `size` — now `xs` … `lg`, and `xs` is the legacy `sm` | | `radius="none"` | `radius="xs"` | | `avatar={}` | `` | | `startContent` / `endContent` | a slot, written before or after `Chip.Label` | | `onClose` | `` — composed, so you place it | | `onPress` | `isPressable` + `onPress` | | `customAppearance={{ container }}` | `style` on the root | | `customAppearance={{ text }}` | `style` on `Chip.Label` | | `customAppearance={{ dot }}` | `style` on `Chip.Dot` | | `customAppearance={{ closeButton }}` | `style` on `Chip.Close` | | `ChipGroup` / `ChipItem` | not in the 1.0 core — the group lands with P5 | # CloseButton The way out, on its own. ## Import ```tsx import { CloseButton } from '@xaui/native/close-button' ``` ## Anatomy ```tsx ``` No slots. There is one node and one mark in it, and a component with a single child has nothing to publish a context for. **It is the affordance `Chip`, `Alert`, `Dialog`, `Popover` and `BottomSheet` already have, given a recipe of its own.** Those five keep theirs: a close _inside_ a component takes that component's colours and that component's scale, resolved once at its root (R5), and `Chip.Close` is five lines over the shared base for exactly that reason. What was missing is this one — a dismiss on something the library does not own: a card header, a banner, a sheet of your own. ## The base, and the component Two things share the name, and the split is the point: | | Where | What it is | | ----------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `CloseButtonBase` | `@xaui/native/system` | The behaviour: its own press state, the grown target, the missing-label warning, the built-in cross. Takes the styles a host recipe resolved. | | `CloseButton` | `@xaui/native/close-button` | The component: the base plus a recipe — a box that follows `size`, a disc that follows `variant`, and a `color` that tints it. | Writing a dismissible component of your own means reaching for the base and publishing `close` / `closeGlyph` from your recipe, exactly as `Chip` does. Dropping a dismiss into a layout means reaching for the component. ## Usage ### The disc, and the bare cross ```tsx ``` `secondary` — the neutral disc — is the default, for the reason the `Dialog` gives at its own close: a cross floating on a panel with nothing under it reads as decoration, and the disc is what makes it a target. `ghost` is the bare cross, for a component that is already providing the target around it. ### A mark of your own ```tsx ``` Children replace the built-in cross. Unset, the button draws its own from two bars a quarter turn apart, so this works in a project that has installed no icon set — the same bargain the `Checkbox`'s tick and the `Radio`'s dot take. ### As another element ```tsx … ``` R12, through the base. The missing-label warning does not fire under `asChild`: the caller's element carries its own label or its own text. ## Sizes | `size` | Box | Bar | Cross | | ------ | --- | --- | ----- | | `xs` | 24 | 12 | ~8 | | `sm` | 28 | 14 | ~10 | | `md` | 32 | 16 | ~11 | | `lg` | 40 | 20 | ~14 | `md` is the reference implementation's close button measured — a 32-point square — and it is the `Dialog`'s. The **bar** is twice as long as the cross looks: a bar rotated a quarter turn spans `length / √2` on each axis. It is kept as a ratio of the box rather than tabulated, so it is one cross at four sizes instead of four drawings of one — the `Radio`'s dot, again. The **stroke does not scale**. It is the shared thickness the `Chip`, the `Alert` and the `Dialog` all draw their cross at; a family of crosses that thicken with their container reads as four different marks rather than one at four sizes. The box is small at `xs`, and it is still pressable: the target grows outwards through `hitSlop`, which costs nothing in layout, rather than the glyph growing. `radius` is the circle `size` sets — half the box — and overridable, like everywhere else. ## Variants and colour | `variant` | Disc | Border | Cross | | ----------- | --------- | -------- | ------------------- | | `primary` | `accent` | — | `accentForeground` | | `secondary` | `default` | — | `defaultForeground` | | `tertiary` | — | `border` | `foreground` | | `ghost` | — | — | `foreground` | Four emphasis levels and **no intent**: dismissing is neither a success nor a danger. The close that carries an intent is the one inside a component that has one — `Alert.Close` in a danger alert — and it is the alert's recipe that says so, not a variant here. `secondary` is the neutral grey, the `Checkbox`'s and the `Radio`'s reading of the word rather than the `Button`'s accent-soft: a close button is grey before it is anything else, and it sits beside those two more often than beside a `Button`. `color` is a raw value (R7), and it lands where the variant puts it: the disc on `primary` and `secondary`, the cross on `tertiary` and `ghost`. **There is no pressed colour.** The press is the shared `PressableFeedback` treatment, because the base owns the press state — a cross has to be a different target from the panel around it — and a root that does not know it is pressed cannot resolve a colour for it. Every other close button in the library reads the same way. ## Alignment with the reference implementation **Identical:** the 32-point box at the default size, the circle, the filled default rather than a bare glyph, the grown touch target, and children replacing the mark. **Three deltas:** | Theirs | Ours | Why | | ------------------------------------------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | A `Button` preset — `tertiary`, `sm`, `isIconOnly`, `hitSlop={12}` | Its own recipe over the shared base | A preset inherits a label slot, a spinner and a `fullWidth`-shaped API for a control that has one mark and no text. | | `iconProps={{ size, color }}` | `children`, or `color` and `size` | R1 — a prop that styles the inside of another component is the thing the v1 API exists to remove. The glyph is a child. | | The cross is an imported icon | Two bars, a quarter turn apart | A dismissible screen has to work in a project that has installed no icon set. Children still replace it. | ## Props Everything `PressableFeedback` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | ------------ | ------------------------------ | ------------- | ----------------------------------------- | | `variant` | `CloseButtonVariant` | `'secondary'` | Four emphasis levels, no intent | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | The box, and the cross in it | | `radius` | `RadiusKey` | half the box | A circle, unless you say otherwise | | `color` | `string` | — | A hex tint — the disc, or the cross | | `isDisabled` | `boolean` | `false` | Dims it and stops the press | | `children` | `ReactNode` | — | Replaces the built-in cross | | `hitSlop` | `Insets \| number` | `8` | From the base — the target, not the glyph | | `asChild` | `boolean` | `false` | Merge into the single child | ## Accessibility - `accessibilityRole="button"`, overridable. - **An `accessibilityLabel` is required**, and its absence warns in development: a cross says "close" to someone who can see it and nothing at all to someone who cannot. Unlike an icon-only `Button`, the text beside it names the thing being dismissed rather than the action, so there is nothing to fall back on. - The warning does not fire under `asChild`, where the caller's element carries its own label. - The touch target is 8 points larger than the box on every side. # ColorPicker ## Overview A colour, chosen off a palette. Imports from `@xaui/native/color-picker`. Run the `color-picker` demo screen to try the real component in light and dark mode. **Two arrangements, one component.** The **dialog** is a field that opens the palette — the legacy picker's shape — and the **grid** is that palette on its own, for a settings row or a theme editor where there is nothing to open. Neither is a mode the root is told about: the caller writes the slots they want, and a picker with no `Content` in it never mounts a dialog. It owns almost nothing. The field is a `DummyField` — the v1 name of the `InputTrigger` the legacy picker opened its sheet from — and the panel is a `Dialog`, both by construction rather than by resemblance, so a colour field and a select in one form cannot drift apart. What this component adds is the chip, the grid and the wiring between them. ## Anatomy ```text ColorPicker Label FieldGroup FieldGroup.Prefix → Preview Field Description / Error Overlay Content Title / Close Grid Group Swatch ``` ## Usage ```tsx import { ColorPicker } from '@xaui/native/color-picker' import { FieldGroup } from '@xaui/native/field-group' ; Brand colour Used across the whole app. Pick a colour ``` Two layouts. `ramps` is the default — one row per hue, its name in a fixed column beside it. `mosaic` withholds the names and closes everything up into one continuous block that fills the width, which is also what makes its cells half again the size: ```tsx … ``` The grid on its own — no field, no dialog, nothing to open: ```tsx ``` A palette of your own, composed rather than configured: ```tsx ``` Use `value` with `onValueChange` for a controlled colour, or `defaultValue` and let the picker hold its own. The value is a hex string, and `ColorPicker.Field` writes it in upper case because a hex is a code rather than a word. Choosing a swatch closes the dialog; `closeOnSelect={false}` keeps it open, which is also what a grid on a page wants. `colors` replaces the palette. It defaults to `TAILWIND_PALETTE` — the seventeen Tailwind hues plus Zinc, at eight steps each — which is exported from the same subpath, so a picker that wants a subset filters it rather than restating it. ## Props Generated from TypeScript by `node tooling/component-docs/generate.mjs color-picker`. The root inherits DummyField's column, styles, states, ref and `asChild` behavior. ### ColorPickerProps Inherited React Native and composed component props remain available. | Prop | Type | Description | | -------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | variant | `ColorPickerVariant \| undefined` | The **field**'s emphasis. It never reaches the grid, which is not a field. | | size | `Size \| undefined` | The field's height, its padding and the grid's cell. Never width. | | radius | `RadiusKey \| undefined` | The field's corner, the chip's and the swatch's. The dialog keeps the `Dialog`'s. | | color | `string \| undefined` | A raw tint (`'#7c3aed'`), never a token (R7). It dresses the field, and it is the ring around the chosen swatch — the legacy picker's `themeColor`, which drove the same two. | | labelPlacement | `DummyFieldLabelPlacement \| undefined` | Above the box, or lifted into it. | | layout | `ColorPickerLayout \| undefined` | Named ramps, or one continuous block of colour with no labels at all. | | value | `string \| undefined` | The chosen colour, as a hex string. Controlled — leave it out and the picker holds its own. | | defaultValue | `string \| undefined` | | | onValueChange | `((color: string) => void) \| undefined` | Every choice, including one made in a grid that stays open. | | isOpen | `boolean \| undefined` | Controlled open state of the dialog. Ignored by a grid with no dialog in it. | | defaultOpen | `boolean \| undefined` | | | onOpenChange | `((isOpen: boolean) => void) \| undefined` | | | colors | `readonly ColorGroup[] \| undefined` | What the grid draws. Defaults to `TAILWIND_PALETTE` — the seventeen hues plus Zinc, at eight steps each. | | closeOnSelect | `boolean \| undefined` | Whether choosing a colour closes the dialog. On by default: a picker whose only job is one colour has been answered the moment a swatch is pressed. Off for a grid on a page, where there is nothing to close. | | isInvalid | `boolean \| undefined` | Paints the field's border, label and description in `danger`. | | isDisabled | `boolean \| undefined` | Dims the field and stops the dialog, the field and every swatch together. | | children | `ReactNode` | | | asChild | `boolean \| undefined` | | ### ColorPickerFieldProps Inherited React Native and composed component props remain available. ### ColorPickerPreviewProps Inherited React Native and composed component props remain available. | Prop | Type | Description | | ----- | --------------------- | ----------------------------------------------------------------------------------- | | color | `string \| undefined` | A colour of your own. Unset, it is the chosen one — which is the point of the chip. | ### ColorPickerContentProps Inherited React Native and composed component props remain available. | Prop | Type | Description | | -------- | ----------- | ----------- | | children | `ReactNode` | | ### ColorPickerGridProps Inherited React Native and composed component props remain available. ### ColorPickerGroupProps Inherited React Native and composed component props remain available. | Prop | Type | Description | | -------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | name | `ReactNode` | The hue's name, drawn above the row. A string is wrapped in the label's own `Text` (R3); an element is rendered as it is. Unset, the row carries no label. | | children | `ReactNode` | The swatches. | ### ColorPickerSwatchProps Inherited React Native and composed component props remain available. | Prop | Type | Description | | ------------------ | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | color | `string` | The colour this cell offers. | | accessibilityLabel | `string \| undefined` | What a screen reader reads. Unset, it is the colour's own hex — pass the name a designer would use ("Violet 600"), which is what `ColorPicker.Grid` does. | | style | `StyleProp \| ((state: PressableStateCallbackType) => StyleProp)` | R9 — `Pressable`'s function form as much as an object or an array. | ## Slots `Label`, `Description` and `Error` **are** the `DummyField`'s slots, and `Overlay`, `Title` and `Close` **are** the `Dialog`'s — re-exported rather than wrapped. `Field` is `DummyField.Field` with the chosen colour filled in as its value and the root's press attached. `Preview` is the chip, a `View` rather than a control: the field around it is what opens the dialog, and it wears a dashed edge while nothing is chosen so the field's text does not shift the first time it is used. `Content` is `Dialog.Content` with the picker's context put back on the far side of the portal; with no children it is the grid alone. `Grid` is a `ScrollView` of `Group`s, and `Group` is a wrapping row of `Swatch`es. Each node accepts its own `style` and style props. ## Variants The `DummyField`'s four — `primary`, `secondary`, `tertiary`, `ghost` — dress the **field**, and the four sizes (`xs`, `sm`, `md`, `lg`) drive the field's height, the dialog's inset and the grid's cell: 24, 28, 32 and 36 points. **The cell is a basis, not a width.** A row holds the label column, a gap and eight cells inside whatever the dialog leaves — 326 points on one phone, 311 on the next — and a fixed cell would have to be sized for the narrowest of them. So the cell is what it takes when there is room and the ramp **shrinks** rather than wrapping when there is not: 32 points at `md` on a 390-point screen, 31 on a 375. A ramp that wrapped halfway through itself would read as two bars rather than one colour getting darker. The dialog itself is capped at three quarters of the screen and pays less inset than a `Dialog` of prose, so the page stays visible at both ends and the grid scrolls inside the panel rather than the panel growing past it. `color` is a raw tint (R7). It dresses the field and it is the ring around the chosen swatch — the legacy picker's `themeColor`, which drove the same two. `radius` moves the field's corner, the chip's and the swatch's together; the dialog keeps the `Dialog`'s own, because a picker asked for a pill of a trigger did not ask for a pill of a panel. **`layout` is the grid's own shape, and it is one prop rather than two because the two halves are the same decision.** Under `ramps` a hue's name sits in a fixed column beside its row; under `mosaic` there are no names, no gaps anywhere, and the block fills the width. The names were the only thing holding the rows apart, so dropping them is what lets the block close up — and the width they were taking goes back to the colour, so a mosaic cell comes out about half again the size of a ramp's. The hairline moves with the shape it describes: around each ramp it would double into a two-point line between every pair of rows, so in a mosaic it is the block's own edge. **A hue's name sits to the left of its ramp, in a column of fixed width.** Above it, eighteen captions are eighteen lines of type in a dialog that could have been colour; in the flow beside it, each ramp would start where its own name ended and the bars would step in and out of the column by the length of the word. Fixed, they line up, and a name too long for the column truncates rather than pushing its bar out of line. **A ramp is one square bar.** Its cells touch, with no gutter and no corner on either end: a rounded end would put a curve on two of the eight colours and leave the other six square, and the corner is the one shape a swatch cannot afford, because it is the shape of the colour itself. The only edge in the grid is a hairline around the bar — one per ramp rather than one per cell, which would double into a two-point line between every pair of colours — and it is what keeps a pale ramp visible on a white sheet. `radius` still rounds the bar for a caller who asks. The chosen cell draws its ring **over** its colour rather than around it. As a border the ring would reserve its width at every cell, answer or not, and six points of ground between two colours is not a ramp; a border that appeared only on the chosen cell, which is what the legacy picker did, shrank the swatch under the finger at the moment it was pressed. ## Accessibility The grid is the radio group and each swatch is a radio: a reader chooses one colour out of the palette, and the rows are how it is arranged rather than what is being asked. Every swatch carries a label — `ColorPicker.Grid` passes the palette's own name, "Violet 600", and a hand-written `Swatch` should be given one, because a colour is not text and the hex is what it falls back to. Mark the prefix `isDecorative` so the chip hands its touches to the field. `isDisabled` stops the field, the dialog and every swatch together. ## Migration from legacy | Legacy | v1 | | ----------------------- | ------------------------------------------------------------------- | | `onColorChange` | `onValueChange` — the vocabulary every other picker already used | | `label` / `description` | `ColorPicker.Label` / `ColorPicker.Description`, siblings not props | | `errorMessage` | `ColorPicker.Error`, mounted by the caller | | `themeColor` | `color`, a raw value | | `variant="colored"` | `variant`, the field's four levels | | `colorGroups` | `colors`, and `TAILWIND_PALETTE` is the default | | `defaultColorGroups` | `TAILWIND_PALETTE` | | `sheetTitle` | `ColorPicker.Title`, a slot | | `sheetStyle` | `style` on `ColorPicker.Content` | | `swatchSize` | `size`, which moves the field and the cell together | | `isOpened` | `isOpen` / `defaultOpen` | | `fullWidth` | gone — a column fills its parent, which is RN's own behaviour | | a bottom sheet | a `Dialog`, or no overlay at all with `ColorPicker.Grid` | The legacy palette's `Lime` group held green values and its `Neutral` group held eleven entries where every other held eight. `TAILWIND_PALETTE` is eighteen ramps of the same eight steps, so the grid is rectangular and Lime is lime. # Combobox A field you type in, over a list you must choose from. ## Import ```tsx import { Combobox } from '@xaui/native/combobox' ``` ## Usage ```tsx Animal Chat Chien Aucun animal ne correspond Un seul, et il doit être dans la liste. ``` ## Anatomy | slot | what it is | | ---------------------- | ---------------------------------------------------- | | `Combobox` | The column, and the resolved style | | `Combobox.Label` | What the field is for | | `Combobox.Trigger` | The field box — a `View`, not a pressable | | `Combobox.Input` | The field. The query while open, the label while not | | `Combobox.Indicator` | The chevron, and the way in without typing | | `Combobox.Overlay` | The backdrop. Optional | | `Combobox.Content` | The panel | | `Combobox.Item` | One row | | `Combobox.Empty` | What the panel says when nothing matches | | `Combobox.Description` | The hint under the field | | `Combobox.Error` | What is wrong with the choice | ## It is the `Autocomplete` with the search moved into the trigger In an [`Autocomplete`](../autocomplete/autocomplete.md) the control shows the **chosen row** and you type in a box inside the panel. Here the field **is** the trigger: what you have typed is what you see, on the line where a `TextField` would be, rather than a button that opens a search. That is the ARIA combobox, and it is the shape a form wants. The autocomplete's shape is what a filter bar or a "jump to" wants — the control keeps saying what is currently chosen while you look for something else. ## The list is closed What is typed **narrows the rows and never becomes the value**. The query goes with the panel, so closing without choosing puts the chosen row's label back in the field. A combobox that kept the half-typed word would be a text field with a dropdown attached — which is a real control, and a different one. Here the field can only ever end up holding something that is in the list. ```tsx value={isOpen ? query : labelFor(value)} ``` is the whole of it, and it is why there is no `onInputChange` separate from `onQueryChange`: there is one string being typed, and it is the query. ## What it shares, and why **The root is the `Autocomplete`'s**, wrapped rather than aliased. A value, an open panel and a query narrowing the list is all either component holds, and a second copy of that would be a second thing to keep in step. **The panel, the rows and the empty line are the `Autocomplete`'s own objects** — `Combobox.Content` **is** `Autocomplete.Content`. Two reasons, and the second is the load-bearing one: - A row is a row whichever field opened it. A second set would drift, and it would show as a combobox and an autocomplete in one form with panels half a shade apart. - `Autocomplete.Content` tells its children apart **by identity** (`type === Item`), not by name. A `Combobox.Item` that were a different component would be sorted into "not a row", would never be filtered and would never register its label. **The label and the help lines come from the same place**, and for the same reason: the root _is_ the `Autocomplete`'s, so the column it renders is already the `TextField`'s, and a combobox that labelled its field differently from the text field beside it would be exactly the drift this sharing exists to prevent. `Combobox.Label`, `Combobox.Description` and `Combobox.Error` are the autocomplete's slots under this component's name. What this component owns is the three slots that make the field the control: the trigger, the input and the chevron. `useCombobox()` is `useAutocomplete()`, for the same reason (R10). ## Three slots, and why each is what it is **`Combobox.Trigger` is a `View`, not a `Pressable`.** The thing you press is the input inside it, and a pressable wrapper around a text field is a second target laid over the one that already takes the tap — same pixels, different effect. The autocomplete's trigger is a pressable because it holds no field; this one holds nothing else. It carries `accessibilityRole="combobox"` and measures itself in window coordinates on every open, because that rectangle is what the panel positions against and `onLayout` fires once. **`Combobox.Input` fills the box**, so the box is one target and it is the one that raises the keyboard. It takes the trigger's own text style: it stands where the chosen value stands in a `Select`, and a query that read smaller than the answer it replaces would make the control change size as you typed. Typing opens the panel and focusing opens it — `opensOnChange={false}` and `opensOnFocus={false}` turn either off, for a control that should only open from the chevron. **`Combobox.Indicator` is a control**, where the autocomplete's is a decoration. That component's trigger is itself pressable, so its chevron only has to point; this one sits inside a field that takes the tap and raises a keyboard, so the way into the list _without_ typing has to be the chevron itself. It presses with a `hitSlop`, and it takes an `accessibilityLabel` because a chevron is not text. ## Variants, sizes and colour All of them are the `Select`'s, resolved through `selectRecipe` exactly as the `Autocomplete`'s are: four field levels, three sizes, `radius`, `color`, `isInvalid`, `isDisabled`. A second table would be a third one to keep in step. ## Alignment with the reference implementation Theirs is `ComboBox` with `InputGroup` · `Value` · `Trigger` · `Popover`. | Theirs | Ours | Why | | -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | `ComboBox.InputGroup` | `Combobox.Trigger` | The box that holds the field is the trigger — it is what the panel anchors to, and naming it twice would hide that. | | `ComboBox.Value` | `Combobox.Input` | One node. The value and the query are the same string in the same box; two slots for it is two things that can disagree. | | `selectionMode="multiple"` | — | One value, as everywhere else here. Several is a `TagGroup` beside a field, which says what it holds without a popover open. | ## Props The root's props are the `Autocomplete`'s, to the prop: `variant`, `size`, `radius`, `color`, `value` / `defaultValue` / `onValueChange`, `isOpen` / `defaultOpen` / `onOpenChange`, `query` / `defaultQuery` / `onQueryChange`, `isDisabled`, `isInvalid`, `asChild`. The root is the **column** — `ref`, `style` and R14's style props on it dress that column, not the field box. ### `Combobox.Input` Everything `TextInput` accepts, plus the `TextStyle` keys as props (R14), plus: | Prop | Type | Default | Notes | | --------------- | --------- | ------- | ---------------------- | | `opensOnChange` | `boolean` | `true` | Typing opens the panel | | `opensOnFocus` | `boolean` | `true` | Focusing opens it | ### `Combobox.Trigger` Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus `asChild`. ### `Combobox.Indicator` `as` for the glyph, and `accessibilityLabel`. ## Accessibility - `accessibilityRole="combobox"` on the trigger, with `accessibilityState.expanded` following the panel — on the box rather than on the input, because it is the box that expands and a screen reader reads the field inside it as the field it is. - The chevron is a `button` and needs an `accessibilityLabel`: it is not text. - `aria-invalid` follows `isInvalid`, on the input as well as on the box. - The input points at `Combobox.Label` and `Combobox.Description` through `aria-labelledby` and `aria-describedby`, so what is announced is what the field was asking for rather than only what has been typed into it. Mounting the two slots is all it takes; your own `aria-labelledby` still wins. - The panel keeps taps through the keyboard (`keyboardShouldPersistTaps`), so choosing a row while the keyboard is up takes one tap rather than two. # DatePicker A field that opens a month. ## Import ```tsx import { DatePicker } from '@xaui/native/date-picker' ``` ## Usage ```tsx Date de naissance {error ? ( {error} ) : ( jj/mm/aaaa )} ``` `DatePicker.Field` is the trigger in one tag — `DatePicker.Trigger` around `DatePicker.Value` and `DatePicker.Indicator`. Open it up for finer control: ```tsx ``` ## Anatomy | slot | what it is | | ------------------------ | -------------------------------------------------- | | `DatePicker` | The **column** — state, resolved style, one `gap` | | `DatePicker.Label` | What the field is for. `danger` on `isInvalid` | | `DatePicker.Field` | The trigger in one tag — value and glyph inside it | | `DatePicker.Trigger` | The control — the field the user sees | | `DatePicker.Value` | The chosen day, or the placeholder | | `DatePicker.Indicator` | The calendar glyph on the field | | `DatePicker.Description` | The hint under the field | | `DatePicker.Error` | What is wrong, in `danger` | | `DatePicker.Overlay` | The backdrop. Optional | | `DatePicker.Content` | The panel | | `DatePicker.Calendar` | The month, bound to the picker | **The root is the `TextField`'s column**, token for token: the same `root` gap, the same label and help styles, resolved through `textFieldRecipe`. A date field and a text field stacked in one form line up and read as one control — the same reason the trigger is a `Select`'s and the grid is a `Calendar`. **`isInvalid` paints the colours; the caller mounts the message.** It turns the border, the label and the description `danger` — it does not add or remove `DatePicker.Error`, because a slot that silently renders nothing is one you cannot debug. You write the condition and see it, exactly as on a `TextField`. No auto-wrap either (R3): a string child of the root is not a label or a hint in any way the component could guess. ## It owns almost nothing The trigger **is** a `Select`'s trigger, the panel **is** a `Select`'s panel, and the grid **is** a [`Calendar`](../calendar/calendar.md) — all three by construction rather than by resemblance. A select and a date field in one form cannot drift apart, and a calendar in a picker cannot differ from one on a page. What this component adds is the wiring, and it is worth naming because each piece is a place two things could otherwise disagree: - the chosen day read into the field through `Intl`, - a panel that closes when a day is pressed, - **one** set of bounds, which the field, the grid and the chevrons all read. `DatePicker.Calendar` takes the `Calendar`'s props _minus_ the ones the picker already owns — value, bounds, locale, first day, variant, disabled — because two sources for one of them would be two answers to one question. ## The panel matches the field, with the grid as its floor `width` defaults to **`trigger`** with a `minWidth` of **seven cells** — the `Calendar`'s own `7 × cell` at the picker's `size`. So a narrow field opens a panel exactly seven cells wide, and a wide field opens one as wide as itself: the panel belongs to the field, and a month grid squeezed under a narrow one would crush its columns or clip a week. `DatePicker.Calendar` fills the panel (`width: 100%`) so a wide one spreads its cells rather than leaving a gap beside them. Override it like any anchored surface: `width="content-fit"` for the compact seven-cell panel whatever the field, `width={320}` for a fixed one, or your own `minWidth`. `minWidth` is `maxWidth`'s mirror — a floor under the resolved width, on `DatePicker.Content` and `Select.Content`, and the screen edge still overrides it. ## The field's level is not the calendar's ```tsx ``` A quiet trigger over an emphatic chosen day is the ordinary case, so `variant` dresses the field and `calendarVariant` dresses the grid. `color` reaches both — the field's fill and the chosen day's disc are the same brand. ## The month on screen is not bound The picker owns the **value**; the calendar owns the **month it is showing**. Opening the panel a second time after paging leaves you where you were, and choosing a day in another month still works — paging is not choosing, which `calendar.md` argues at length. ## A year and a month, from the panel `DatePicker.Calendar` is a `Calendar`, so its year → month → day walk composes here exactly as it does on a page: pass the children, hold `view`, and mount `Calendar.YearPicker` / `Calendar.MonthPicker` in the grid's place. ```tsx const [view, setView] = useState('grid') { if (!open) setView('grid') }}> {/* trigger, overlay … */} setView(v => (v === 'grid' ? 'year' : 'grid'))}> {view === 'year' ? ( ) : view === 'month' ? ( ) : ( <> )} ``` `view` is the `Calendar`'s, not the picker's — resetting it to `'grid'` on close is what makes the panel open on the month every time. ## `closeOnSelect` On by default: a picker whose only job is one date has been answered the moment a day is pressed, and a panel that stayed open would need a second control to say so. `closeOnSelect={false}` for a picker inside a form that confirms — where the caller writes their own footer under the grid through `DatePicker.Calendar`'s children. ## `formatOptions` ```tsx ``` Anything `Intl.DateTimeFormat` takes. `dateStyle: 'medium'` by default — "6 sept. 2026" — because a field is a line on a form and the long form is a sentence. It changes **how the field reads the day and nothing else**: the value is a `Date` at midnight local time either way. A Hermes build with no ICU falls back to the ISO day, which is unambiguous everywhere even where it is nobody's habit. ## Props ### `DatePicker` | Prop | Type | Default | Notes | | ----------------- | ---------------------------- | ------------ | ---------------------------------- | | `variant` | `DatePickerVariant` | `'primary'` | The field's level | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The field, and the calendar's cell | | `radius` | `RadiusKey` | — | The field's corner | | `color` | `string` | — | The field and the chosen day | | `calendarVariant` | `CalendarVariant` | — | The grid's level | | `value` | `Date` | — | Controlled | | `defaultValue` | `Date` | — | | | `onValueChange` | `(value: Date) => void` | — | Midnight, local time | | `isOpen` | `boolean` | — | Controlled open state | | `defaultOpen` | `boolean` | `false` | | | `onOpenChange` | `(isOpen: boolean) => void` | — | | | `minValue` | `Date` | — | Compared by day | | `maxValue` | `Date` | — | | | `firstDayOfWeek` | `0`–`6` | the locale's | | | `locale` | `string` | the device's | | | `formatOptions` | `Intl.DateTimeFormatOptions` | `medium` | How the field reads it | | `closeOnSelect` | `boolean` | `true` | | | `isDisabled` | `boolean` | `false` | | | `isInvalid` | `boolean` | `false` | Paints the colours, mounts nothing | | `asChild` | `boolean` | `false` | The caller's element is the column | The root **is the column** (a `View`, or the caller's element with `asChild`), so `ref`, `style` and R14's style props are the column's. `DatePicker.Trigger` keeps its own — it is the node the panel measures, and everything `Pressable` takes is written there. ### `DatePicker.Content` The `Select`'s placement props — `placement`, `align`, `width`, `minWidth`, `offset`, `alignOffset`, `avoidCollisions`, `insets` — with `width` defaulting to `trigger` and `minWidth` to the `Calendar`'s seven-cell width. ## Accessibility - The trigger is a `button`, not a `combobox`: there is nothing to type into, and a month grid is not a list of options a screen reader can walk from the field. `accessibilityState.expanded` follows the panel; `aria-invalid` follows `isInvalid`. - The backdrop announces nothing — it is the absence of the panel, and "button" over the whole screen is worse than silence. - Every day inside is labelled with its full date by the `Calendar`. # DateRangePicker A field that opens a month, and takes two days from it. ## Import ```tsx import { DateRangePicker } from '@xaui/native/date-range-picker' ``` ## Usage ```tsx ``` ## It owns almost nothing The trigger **is** a `Select`'s trigger and the month **is** a `RangeCalendar`, which is itself a `Calendar`. What this adds is the wiring: two ends read into the field through `Intl`, and a sheet that closes on the second one. Every rule the `RangeCalendar` has — the third press, the backwards range, the one-day range — is this component's too, because it is that component. **A sheet rather than an anchored panel**, for the `TimePicker`'s reason: a month is three hundred points wide, which beside a field on a phone is the screen. ## The first choice never closes the sheet A period is **two decisions**, and a sheet that shut after the first would make the second one a second opening. `closeOnSelect` governs the second only. ## A start with no end reads as itself Not as `"start – "`. A dash with nothing after it says the field is broken, where a lone date says it is half answered — which is exactly what it is between the two presses. The separator is an **en dash** with a space either side, not a hyphen: a hyphen is already the date separator in half the locales this field serves. ## `variant` dresses the field, `calendarVariant` dresses the month A `ghost` field over a `primary` month is the ordinary case — the trigger is quiet on the form and the chosen period is not. `minValue`, `maxValue`, `firstDayOfWeek` and `locale` pass straight through. `formatOptions` is `Intl.DateTimeFormatOptions` and reads **each end** with it. The root **renders no node**: `ref`, `style` and the a11y props live on `DateRangePicker.Trigger`. ## See also - **`DateRangeField`** — a period _typed_ rather than chosen. - **`RangeCalendar`** — the month on its own. - **`DatePicker`** — one day rather than two. # DateTimePicker A field that opens a month, and then a clock. ## Import ```tsx import { DateTimePicker } from '@xaui/native/date-time-picker' ``` ## Usage ```tsx ``` ## It owns nothing at all | the part | what it actually is | | ------------- | ---------------------- | | the field | a `Select`'s trigger | | the two steps | a `Tabs` | | the month | a `Calendar` | | the dial | a `TimePicker` | | the mark | `TimePicker.Indicator` | Four components rendered **as themselves** rather than four tables restated. It has no recipe of its own — the only style it touches is the field's, and that one belongs to the `Select`. That is also why `TimePicker.Indicator` reads `IconContext` rather than its own picker's context: a glyph another field can render is one glyph to keep right, not two. ## Two steps rather than two fields A moment is one value, so it is one control. A calendar and a clock will not fit on a phone at the same time, which is why the two take turns. **Choosing a day moves to the clock** — the `TimePicker`'s hours handing over to its minutes, one level up — and choosing the minutes closes the sheet. The common path is one gesture. **The steps are a tab bar and not a stepper**, because either half can be changed at any time: a reader who set the day, then the time, then wants the day again presses "date" rather than starting over. Each tab **reads the half it stands for** once there is one — the date under "date", the time under "time" — so the sheet says what has been decided without a header of its own. The sheet reopens on the date, always: one that reopened on the clock would hide the month from a reader who came to change the day. ## Each half keeps the other A day chosen after a time keeps the time. A time chosen after a day keeps the day. The value is **one moment being narrowed**, not two values being collected. With nothing chosen yet, the first day lands at midnight — which is what the dial shows when it opens. ## The pieces are composable `DateTimePicker.Sheet` with no children assembles `Steps`, `Calendar` and `Clock`. Written out, the whole thing goes on a page with no sheet at all, which is also how the demo screen makes both steps visible without opening anything: ```tsx {step === 'date' ? : } ``` `DateTimePicker.Clock` renders a real `TimePicker` with `closeOnSelect={false}`: the minutes close the sheet the _trigger_ opened, and that inner picker has no sheet of its own. ## Props `hourCycle`, `minuteStep`, `minValue`, `maxValue` and `locale` pass straight through to the two components. `formatOptions` is `Intl.DateTimeFormatOptions` and defaults to `{ dateStyle: 'medium', timeStyle: 'short' }`. `step` / `defaultStep` / `onStepChange` drive which half is on screen, controlled or not. The root **renders no node**: `ref`, `style` and the a11y props live on `DateTimePicker.Trigger`. ## See also - **`DateTimeField`** — a moment _typed_ rather than chosen. - **`DatePicker`** and **`TimePicker`** — either half on its own. # Dialog A question the page has to be answered before it goes on. ## Import ```tsx import { Dialog } from '@xaui/native/dialog' ``` ## Anatomy ```tsx … … … … ``` - **`Dialog`** — state and resolved style. **It renders no node.** - **`Dialog.Trigger`** — what asks the question. - **`Dialog.Overlay`** — the backdrop. It dims, and closes on a press. - **`Dialog.Content`** — the panel, centred and inset from the screen's edges. - **`Dialog.Title`** / **`Dialog.Description`** — the question and what it costs. - **`Dialog.Close`** — the way out. A cross when it is empty, whatever you put in it otherwise. ## Usage ### Basic ```tsx Supprimer ce document ? Définitif, sans corbeille. ``` ### One that must be answered ```tsx ``` A press outside does nothing, so the only way out is through a `Dialog.Close`. ### Style as props ```tsx ``` Full RN names, full RN values (R14). Every node takes them. ## Props ### `Dialog` | prop | type | default | description | | -------------- | --------------------------- | ------- | ---------------------------- | | `radius` | `RadiusKey` | — | Overrides the panel's corner | | `isOpen` | `boolean` | — | Controlled | | `defaultOpen` | `boolean` | `false` | Uncontrolled | | `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and on close | | `isDisabled` | `boolean` | `false` | Stops the trigger | No `variant`. A dialog is the theme's floating surface: the question it asks is in its words, not in its fill. ### `Dialog.Overlay` `isDismissable` (default `true`), plus `ViewProps` and `ViewStyle` as props. ### `Dialog.Trigger` Everything `Pressable` takes, plus `ViewStyle` as props and `asChild`. It paints nothing of its own — a dialog is asked by a button, and giving the trigger a surface would put a second box around one. ### `Dialog.Close` Everything the shared `CloseButtonBase` takes, minus `name`, `baseStyle` and `glyphStyle`, which the dialog supplies. Two shapes, and the empty one is the default: ```tsx {/* a 32pt disc, muted cross */} {/* the answer, in words */} ``` **Empty, it draws a cross** — the shared button's, from two rotated bars rather than an icon, so the corner affordance works in a project that has installed no icon set. Every measurement is the reference implementation's, read off their CSS. `close-button.css` sets `height: calc(var(--spacing) * 8)` on a spacing base of 4, so the box is **32 points**, and `isIconOnly` gives it `aspect-ratio: 1` and a radius past half its height — a circle. It is **filled**, not a bare glyph: their `CloseButton` is a `tertiary` button, and their `tertiary` is `background-color: var(--color-default)`, the same token our `secondary` names. The cross itself is `muted`. A cross floating on the panel with nothing under it reads as decoration; the disc is what makes it a target. **It places itself nowhere.** `alignSelf="flex-end"` above the title, or `position`, `top` and `end` over content that has room for it — a title with space beside it and one without want different answers, and that is layout, which is the caller's (R4). This is what the reference implementation does too: their own examples use `absolute top-3 inset-e-2.5`, `items-end` and `self-end me-4` in three different dialogs. The placement is in the example, not in the component. Under `asChild` the box is not applied, and the missing-label warning does not fire: the caller's element has a height and a shape of its own, and its text is the label. ## How it differs from the `Popover` Same portal, same context re-provision, same overlay keyframes. What it drops is everything that needs an anchor: the measuring pass, the host origin, the collision flip. A centred box has nothing to be measured against. What it adds is two things: **The backdrop dims.** A popover is an aside you read the page around; a dialog is a question, and the page behind it is not available until it is answered. **It grows from its own centre**, 200 ms from `scale: 0.94`, rather than out of a trigger. A popover's entrance is offset towards the thing that opened it so the motion points back at it; a dialog belongs to the screen rather than to a control, so the absence of a direction is the message. ## Two layers on the content A centred box cannot also be the thing that centres it. An outer layer fills the portal and does the centring; the panel is the box. The outer one takes **no touches**, so a press that misses the panel reaches the overlay under it and closes the dialog. ## Accessibility The panel is `accessibilityViewIsModal`, so a screen reader stops at the dialog rather than reading the page behind it — the spoken half of what the backdrop says visually. The title is a `header`. The overlay announces nothing. # Divider The line between two things. ## Import ```tsx import { Divider } from '@xaui/native/divider' ``` ## Anatomy ```tsx ``` **One node and no slots.** A rule is a filled box one point thick; there is nothing inside it. A divider with a word in the middle of it is a `Row` holding two of these and a `Typography` — the composition the library already has. A `Divider.Label` would put a layout inside a line. ## Usage Between rows, in a column: ```tsx … … ``` Between two words, in a row: ```tsx Brouillon Il y a deux minutes ``` A section break, rather than a row break: ```tsx ``` With a word across it — two dividers and a label, not a prop: ```tsx ou ``` ## Props | Prop | Type | Default | | ------------- | ------------------------------ | -------------- | | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'xs'` | | `color` | `string` — a raw hex tint (R7) | — | | `style` | `StyleProp` | — | Plus every `ViewStyle` key as a prop (R14) and every `View` prop. ### No `variant`, and it is the only component in the core without one A variant is the design system's **vocabulary** (§1 bis) — a name that means the same thing everywhere it appears. On a rule there is nothing for such a name to describe: no fill against a foreground, no border against a surface, no intent to report. It used to have three, naming the three separator tokens. That is a shade of grey wearing a word. `size` says how heavy the rule is, `color` says what colour it is in React Native's own values, and between them there is nothing a third name would add. The theme still decides the default: the rule paints `separator`, and `color` is the way past it. ### `size` is the thickness `xs` is the reference implementation's `thin` — one device pixel, `StyleSheet.hairlineWidth` — and `lg` is their `thick`, six points, with the two steps our ladder puts between them. It is `size` and not `thickness` because that is the vocabulary word (§1 bis), and it is consistent with the rest of the library rather than in spite of it: "`size` drives height, never width" is exactly what this does, on the axis `orientation` names. **`xs` is the default, where every other component defaults to `md`.** It is the one place in the library that departs from that, because a rule you notice is a rule that is too thick. ### The other axis comes from the parent `alignSelf: 'stretch'` is the whole mechanism. In a `Column` the cross axis is horizontal, so a stretched child is full width; in a `Row` it is vertical, so the same word makes a vertical rule full height. On the axis the thickness fixes, `stretch` is ignored. So there is no `width` prop and no `height` prop to remember, and a **horizontal divider written inside a `Row` collapses on purpose** rather than guessing what you meant. Give it a width if that is really what you want. ### `asChild` R12. What it is for here is the animated rule: an `Animated.View` that collapses a section takes the thickness and the ink from the recipe and the height from a shared value. ```tsx ``` ## Accessibility `accessibilityElementsHidden` and `importantForAccessibility="no"` are set: a rule is furniture. A screen reader reads the two groups it sits between, not the line — and React Native has no `separator` role to announce, so leaving it visible would add a stop to every list. Both stay overridable (R9). ## Migration | Legacy | v1 | | ----------------------------- | ------------------------------------ | | `` | `` | | `` | `` | | `` | `` | | `` | `` | # DummyField A field container styled identically to a text input (`TextField`), but non-editable and pressable. Designed as a trigger for pickers, modals, bottom sheets, menus, or file selection — or for presenting a read-only field value that can be tapped. Renamed from legacy `InputTrigger` to match the `*Field` vocabulary (`TextField`, `MaskField`, `NumberField`, `TimeField`) and avoid clashing with the `*.Trigger` slot vocabulary of overlay compounds (`Select.Trigger`, `Popover.Trigger`, `Menu.Trigger`). ## Import ```tsx import { DummyField } from '@xaui/native/dummy-field' ``` ## Usage ### Basic trigger ```tsx Country {selectedCountry} Where you reside. ``` ### Composed with Indicator ```tsx Appointment date {appointmentDate} ``` ### Inside label ```tsx Category {category} ``` ### Inside a `FieldGroup` ```tsx Attachment ``` ## Anatomy - **The root is the column (`DummyFieldRoot`), not the field.** It renders the outer `View` that stacks `DummyField.Label`, `DummyField.Field`, `DummyField.Description`, and `DummyField.Error`. - **`DummyField.Field` is the pressable control.** It renders a `PressableFeedback`, forwards refs, applies pressed styling, and receives touch interactions. - **`DummyField.Value` is the content text.** It renders the selected text or the placeholder in `fieldPlaceholder` colour when empty. - **`DummyField.Indicator` is the affordance glyph.** Defaults to `ChevronDownIcon` and inherits glyph size and color from the resolved recipe context. ## Props ### `DummyField` (Root) | Prop | Type | Default | Description | | ---------------- | --------------------------------------------------- | ------------- | ------------------------------------------------ | | `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'ghost'` | `'secondary'` | Visual emphasis of the field box | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Height, horizontal padding, type size, and gaps | | `radius` | `RadiusKey` | `'field'` | Corner radius override | | `labelPlacement` | `'outside' \| 'inside'` | `'outside'` | Outside in column flow or lifted inside the box | | `color` | `string` | — | Raw tint override | | `isInvalid` | `boolean` | `false` | Turns border, label, and description to `danger` | | `isDisabled` | `boolean` | `false` | Reduces opacity and disables press interactions | | `onPress` | `(event: GestureResponderEvent) => void` | — | Field press handler passed through context | | `asChild` | `boolean` | `false` | Merge props and refs into single child element | ### `DummyField.Field` | Prop | Type | Default | Description | | ------------- | ------------ | ------- | ---------------------------------------------- | | `placeholder` | `string` | — | Text shown when no value or child is provided | | `value` | `ReactNode` | — | Value to display when child is not passed | | `onPress` | `() => void` | — | Called on press; composed with root `onPress` | | `asChild` | `boolean` | `false` | Merge props and refs into single child element | ### `DummyField.Value` | Prop | Type | Default | Description | | --------------- | -------- | ------- | ---------------------------------------------- | | `placeholder` | `string` | — | Text shown when empty | | `numberOfLines` | `number` | `1` | Maximum number of text lines before truncating | ### `DummyField.Indicator` | Prop | Type | Default | Description | | ---- | ----------------------------------- | ----------------- | -------------------------- | | `as` | `ComponentType` | `ChevronDownIcon` | Custom indicator component | # EmptyState What is on the screen when there is nothing on the screen. ## Import ```tsx import { EmptyState } from '@xaui/native/empty-state' ``` ## Usage ```tsx Aucun message Ce qu’on vous envoie arrivera ici. ``` ## The header and the content are two roots The gap between a mark, a title and a sentence is a **different gap** from the one between that block and the buttons under it, and R4 puts layout on a root — so two gaps need two roots. It is also what lets an empty state with nothing to do about it leave `EmptyState.Content` out entirely, rather than render an empty row with a gap above it. ## Variants | variant | what it draws | | ---------- | -------------------------- | | `plain` | nothing | | `surface` | the `surface` fill | | `outlined` | a **dashed** edge, no fill | **`plain` is the default**, and it is the right one more often than not: most empty states fill a screen, and a screen already has a ground. **`outlined` is the one that is not a fill.** A dashed edge round the space the content would occupy says "this is a container, and it is empty"; a solid one would say "this is a panel". It is what a drop target and an empty column want. There is no `success`, no `warning` and no `danger`: an empty state reports nothing — it is what is left when there is nothing to report. ## The media `EmptyState.Media` takes `variant="icon"` to put its child in a circle of muted surface. **That is not decoration.** A 24-point mark alone in the middle of a screen reads as an image that failed to load; the circle gives it a size, and the fill says it is a mark rather than a photograph. `plain` is the default, because an avatar or an illustration brings its own shape and a circle behind it would be a second one. Either way it publishes the glyph's size and colour through `IconContext`, so an `Icon` inside takes both without being told — and `useEmptyState().icon` is the same two values for a glyph you draw yourself. ## The tint `color` is a raw value (R7) and lands on the icon's circle, the mark on it, and the outline. **Never on the words.** An empty state's text is the page's own ink, and tinting the sentence would make the quietest thing on the screen the loudest. The mark takes the tint's **contrast colour**, not the tint — `fgSelected` names a `*Foreground` token, which is what tells `resolveTint` to answer with the readable colour on the tint rather than with the tint itself. Without it a grey mark sits on a saturated circle. ## Size `size` moves the media, the gaps and the type — **never a height**. An empty state is as tall as what is in it; how tall the space it fills is, is the layout's business. ## Accessibility `EmptyState.Title` is an `accessibilityRole="header"`, which is what a screen reader jumps between — so a screen whose only content is an empty state is still navigable. ## See also - **`Surface`** — the ground, when all you need is a fill. - **`Skeleton`** — for content that is _coming_, rather than absent. # Fab The one thing to do on a screen, floating over the thing it does it to. ## Import ```tsx import { Fab, FabDiscovery, FabMenu } from '@xaui/native/fab' ``` ## Usage ```tsx Nouveau ``` A bare string is a label (R3), so `Composer` works. ## It is not a `Button`, and it shares the `Button`'s table The seven intents are the same seven — the thing a FAB floats over is as likely to be a delete as a compose — so the variant table is copied token for token and a compose button and a compose FAB are the same green. The **recipe** is not shared, and that is deliberate. A button is a row of text with padding; this is a fixed square that carries a shadow **at rest**, because floating over content is what it does. One recipe would have meant a `size` axis meaning a height on one and a side on the other, and a shadow in `base` that a button never wants. ## `isExtended` is a prop Not "there is a `Fab.Label` in here". The root's recipe resolves **before** its children do: the shape has to be known when the box is measured, and the box is measured before the label inside it exists. An extended FAB keeps the size's height and gives up its width, so a round one and an extended one sit on the same line at the same height. It **hugs its content** rather than filling its parent — that is where it parts company with a `Button`, whose stretching in a column is RN's own behaviour and the right one. A full-width FAB is a button. ## Size Three steps on the square's side: **40, 56 and 72** points. `size` is the side of the square, or an extended one's height — never its width. | size | side | glyph | | ---- | ---- | ----- | | `sm` | 40 | 18 | | `md` | 56 | 24 | | `lg` | 72 | 28 | ## `placement` Unset it sits in the flow, which is what a FAB inside a card or a toolbar wants. The three others pin it to the bottom of its nearest positioned ancestor — `bottom-start`, `bottom-center`, `bottom-end`. **`start` and `end`, never left and right** (R13), so a right-to-left layout moves it with no second branch. `offset` is how far in, in points, and defaults to 16. `bottom-center` is `alignSelf`, not `start: '50%'`: the latter would centre the box's _edge_ rather than the box. ## `isLoading` Swaps the mark for a ring and stops the press. The label stays, so `Envoi…` reads as one control changing state rather than as a control being replaced. The ring is the recipe's own — the `Button.Spinner`'s argument — so it follows the FAB's size and its variant with nothing to pass. Composing `` yourself is how you put it after the label instead of before it. ## `FabMenu` A FAB that opens the two or three things it could have been. ```tsx Nouveau message Nouveau libellé Nouveau dossier ``` It is a compound of its own, exported from the FAB entry point. Its trigger renders a `Fab`, so the two primitives stay available from one import. **The root renders no node.** It holds the disclosure, the anchor and the styles the slots read. `FabMenu.Trigger` is the FAB and keeps its own `ref`; `FabMenu.Overlay` and `FabMenu.Content` portal out, so they add nothing where they are written. `isOpen`, `defaultOpen` and `onOpenChange` are the root's, so a menu is controlled or not exactly as a `Select` is. `FabMenu.Trigger` takes everything a `Fab` takes **except `size`**, which is the root's because the pills read it too — a menu whose actions were sized apart from the FAB they come out of would read as two controls. `color` on the root paints the pills; `color` on the trigger paints the FAB, and they are deliberately two props: a menu whose actions were the same colour as the button they came out of would read as one shape that had grown. A bare string child of `FabMenu.Item` is wrapped in a `FabMenu.Label` (R3). Write the label by hand when there is a `FabMenu.Icon` beside it. ### The FAB does not move when the menu opens That is the whole reason this exists rather than the legacy `FabMenu`. That component re-rendered its FAB **inside the portal**, at the portal's own bottom-end inset, so a FAB sitting anywhere else jumped across the screen at the moment it was pressed. Here the trigger is never re-parented. It measures itself with `useAnchorRef` — on layout and again on every open, so a FAB inside a `ScrollView` reports where it actually is — and the actions are positioned against that rectangle by `useAnchoredPosition`. That is the `Select`'s and the `Menu`'s machinery, unchanged. ### It is not a `Menu` A menu is one surface with rows inside it. These are separate pills with air between them, and the difference is not decoration: a panel dropping out of a field is that field's list of answers and belongs to its edge, while a FAB floats over the page — there is no edge for its actions to belong to, so each one carries its own. Three consequences: - **The column has no surface.** No background, no border, no shadow; the pills are the surface — `theme.colors.overlay`, which is white in light mode and the theme's raised surface in dark. `content-fit` is the only width that makes sense on it, because a `trigger` width would crush three words into the FAB's diameter. - **The pill wears the `surface` shadow, not the `overlay` one** a `Menu` panel wears. Three pills twelve points apart, each carrying a sixteen-point blur, pool into one grey smudge behind the lot of them. - **The press feedback is the shared one.** A `Menu` row darkens because the scale treatment on a full-width strip reads as the panel twitching; a pill is a small floating button — the same object the FAB above it is — and `PressableFeedback`'s treatment is right on it. **`FabMenu.Overlay` dims**, where a `Menu`'s and a `Select`'s do not. Those drop out of a field and leave the page alone, because the page is still the context for the answer they are asking for; a FAB floats over everything and its actions replace the screen's one thing to do with three. That is a `Dialog`'s situation, and it takes the `Dialog`'s `backdrop` token — which is also what puts the pills on a dimmed ground, where a white pill reads as white. `backgroundColor="transparent"` on the slot takes the dimming back off, and omitting the slot leaves no backdrop and nothing to press outside. `top` and `end` are the defaults, because that is where a FAB is. `avoidCollisions` still flips the column below the trigger for a FAB at the top of a screen. ### Accessibility The trigger carries `accessibilityState.expanded`. The column carries `accessibilityRole="menu"` — a `Menu`'s panel needs no role because its surface is the container a screen reader stops at, and these pills have no surface between them, so without it nothing says a set of actions has appeared. Each action is a `menuitem`. `isDisabled` on the root stops the trigger and every action with it; on one action it stops that one. Either way the pill dims once, never twice. ## `FabDiscovery` The coach mark that says what a FAB is for. ```tsx Composez d’où vous voulez Ce bouton suit chaque écran de la boîte de réception. Compris ``` **It is opened by the app, not by the FAB.** A discovery is shown because this reader has not seen the feature — a question only the app can answer — so `isOpen` is controlled far more often than not, and pressing the target does what it always did. `FabDiscovery.Action` takes the mark down after the caller's `onPress` has run; so does a press on the overlay. `color` is a raw tint for the disc and everything on it. Unset it is the theme's `accent`, which is what a coach mark means: the one thing on this screen worth pointing at. `scale` sets the disc's diameter as a multiple of the window's width and `padding` how far the ring stands off the target. ### The FAB does not move, and it stays the FAB The legacy `FeatureDiscovery` took a `targetRef`, measured it, and drew a **copy** of whatever the caller passed as `highlightContent` over the disc. A copy is a picture: it does not press, and it is only correct for as long as somebody keeps it in step with the original. `FabDiscovery.Target` **is** the `Fab`. While the mark is up it is lifted into the portal at its own measured rectangle — the coordinates it already occupied — so it draws above the disc, still presses, and never appears to move. The node left in the flow stays mounted and invisible, because it is what holds the space the layout gave the FAB and what `onLayout` measures; it is taken out of the accessibility tree while it is a placeholder, or a screen reader would find the same button twice. The layers are **numbered** rather than left to mount order. The target and the disc each open a portal, and which one landed on top would otherwise depend on the order two slots were written in — a FAB under its own disc, from moving one line. `zIndex` alone is not enough on Android, which draws a native Z from `elevation` that a React `zIndex` does not outrank, so the layer holding the FAB carries more of both. ### The text is laid out to the chord, not to the diameter `discoveryGeometry` is a pure function — eleven tests — and this is the part worth reading. The block of text is placed at a `y`, and how wide the disc is at that `y` is the chord of a circle, `√(r² − dy²)`: a block near the disc's centre is nearly its full width, and one near the top or bottom is a sliver. Laying the text out at the disc's width instead runs it past the curve at both ends, which is the shape every first coach mark has. Where that chord is too narrow to read a paragraph in, the block gives up on the disc and sets from the screen's own edge — on the side the target is **not** on, so the words run away from the thing they describe rather than under it. **The block is pinned by the edge nearest the target**: by its top when it sits below one, by its **bottom** when it sits above. That is what keeps the gap to the FAB fixed however long the description runs — the legacy set a `top` at `targetY − 150`, so a coach mark that gained a line grew down into the button it was pointing at. Only the block's _width_ still needs a guess at its height, because the chord that bounds it is narrowest at the far end and the far end is not known until the text has been laid out; that guess is one named constant, and it errs wide. ## Accessibility **A round FAB needs an `accessibilityLabel`.** A mark is not text and there is nothing beside it to fall back on. An extended one has a label and does not. `accessibilityState` carries `disabled` and `busy`, and `isLoading` sets the second. ## See also - **`Button`** — the same seven intents, in a row of text. - **`Menu`** — a list of actions dropping out of a control that is not a FAB. - **`Dialog`** — for a question that has to be answered, rather than a feature explained. - **`Portal`** — for a FAB that must escape a scroll container's clipping. # FieldGroup A field with something beside it — a glyph, a unit, a reveal toggle. ## Import ```tsx import { TextField } from '@xaui/native/text-field' import { FieldGroup } from '@xaui/native/field-group' ``` Two imports, because they are two components and the second one lives **inside** the first. ## Anatomy ```tsx ``` - **`FieldGroup`** — one row of the `TextField`'s column, and the positioning context the two decorators are laid over. It owns exactly one thing: how wide they turned out to be. - **`FieldGroup.Prefix`** — pinned to the leading edge, inset by the field's own padding. Its measured width becomes the field's `paddingStart`. - **`FieldGroup.Suffix`** — the same, on the trailing edge, as `paddingEnd`. - **`FieldGroup.Field`** — `TextField.Field` with that padding. It **is** that field: the same `TextInput`, the same focus plumbing, the same styles, every `TextInput` prop. - **`FieldGroup.Icon`** — a glyph at the field's scale in the placeholder's colour. **The label, the hint and the error are not here.** They are the `TextField`'s, and this replaces nothing but the field — which is why there is no `variant`, no `size`, no `color` and no `isDisabled` on this root: a second `size` here would be a second answer to a question the field has already answered. **The box is still the `TextInput`.** The decorators are taken out of flow and laid over it, so no wrapper borrows the border, the fill, the radius and the shadow — a `FieldGroup` and a bare `TextField` cannot drift apart, because there is only one box in the library and this is not a second one. **No slot carries a margin** (R4), and no decorator pushes the text: the field clears them by padding. ## Usage ### With a prefix ```tsx Recherche ``` ### With a suffix that does something ```tsx Mot de passe setIsVisible(!isVisible)} hitSlop={20} accessibilityRole="button" accessibilityLabel={isVisible ? 'Masquer' : 'Afficher'} > Douze caractères au moins. ``` The library ships no button for a suffix. A `Pressable` with a `hitSlop` is the thing to put there — a 16pt glyph is not a touch target on its own. ### Decorative, or a control ```tsx … … ``` `isDecorative` does two things and they belong together: touches pass through to the field underneath, so tapping the glyph focuses the field, and the content leaves the accessibility tree, so a screen reader never stops on a mark it cannot act on. It is **off by default**, because the trailing edge is where a decorator is most often a control and a suffix that swallowed its own taps would be a reveal toggle you cannot press. ### Anything, not just a glyph ```tsx +33 ``` Nobody is told a number. The decorator measures itself and the field clears it by that width, so a country code, two glyphs and a 16pt mark all land right. ### Multiline `FieldGroup.Field` is `TextField.Field`, so `multiline` works on it — and `rows` and `maxRows` do not, because those are the [`TextArea`](../text-area/text-area.md)'s and reach `TextArea.Field` alone. A decorator spans the whole box, so over several lines it lands in the middle of it; style props are what pin it where it belongs: ```tsx ``` ### Disabled ```tsx … … ``` `isDisabled` is on the **`TextField`**, and it reaches everything: the column dims, the field stops, and both decorators stop taking touches — including a suffix that is not decorative, because a toggle you can still press on a disabled field is a bug rather than a feature. ### As another element ```tsx … ``` R12 — the caller's element **is** the row. The decorators still measure into it. ## How the padding gets there A decorator is out of flow, so it cannot push the text aside the way a sibling in a row would. It measures itself with `onLayout`, reports the width to the root, and `FieldGroup.Field` turns the two numbers into `paddingStart` and `paddingEnd`. That is the same shape `TextArea` uses for `rows`: **a raw value the slot turns into a style, outside the style cache** (R6). A width is not knowable before layout and takes as many values as there are decorators, so it could never be a cache key. They land after the recipe's own `paddingHorizontal`, which is what makes them win, and before the caller's `style`, which still has the last word. `start` and `end`, never `left` and `right` (R13): the prefix is the leading edge in both directions of writing. The cost is one extra layout pass on mount, and the first frame draws before the width is known. That is the trade the alternative avoids — a wrapper `View` carrying the box, with the field flattened inside it — and the reason we did not take it is above: a second box is a second thing to keep in step with `TextField`. ## Sizes There is no `size` here. The `TextField`'s decides everything: | `size` | Decorator inset | Gap | Icon | | ------ | --------------- | --- | ---- | | `xs` | 10 | 10 | 16 | | `sm` | 12 | 12 | 18 | | `md` | 12 | 12 | 18 | | `lg` | 16 | 16 | 20 | The inset is the field's own horizontal padding, so the glyph starts where the text would have. The gap is that same step — the reference implementation's, and it is what separates two glyphs in one decorator. The icon sits **one step above the field's type**, exactly as on the `Button` and the `Chip`: a 16pt glyph beside 16pt of text reads as an icon smaller than the text it sits with. The reference implementation's component sizes no icon at all — theirs is a number at the call site — so this is a slot they do not have rather than a value we disagree on. ## Colour `FieldGroup.Icon` takes the theme's `fieldPlaceholder`: a mark in a field is decoration for the text, not text. An explicit `color` wins, which is what `Icon` promises everywhere else in the library, and it is the answer for a tinted `primary` or `secondary` field — there the fill is the caller's colour and the placeholder grey is no longer readable over it: ```tsx ``` Everything else a tint touches — the border, the fill, the focus — is the `TextField`'s and reaches the group untouched. ## Alignment with the reference implementation Measured against their `input-group.tsx` and `input-group.css` rather than eyeballed. **Identical:** the anatomy (a root, two decorators, a field), the decorators pinned out of flow with a `zIndex` over the field, the 12pt inset and 12pt gap at `md`, the field cleared by the decorator's _measured_ width on the logical edges, `isDecorative` doing exactly the two things it does there — `pointerEvents="none"`, `accessibilityElementsHidden` and `importantForAccessibility="no-hide-descendants"` — and the disabled cascade reaching both decorators. **Three deltas, all of them the shape of our `TextField` rather than a disagreement:** | Theirs | Ours | Why | | ----------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `isDisabled` on the group | on the `TextField` | Their `TextField` is the box and their `TextField` is the column; ours is the column already, and it holds the state the recipe resolves on. | | `FieldGroup.TextField`, a pass-through to `TextField` | `FieldGroup.Field` | Our field is `TextField.Field`, so the slot keeps the field's name. | | No icon slot — a size and a colour at each call | `FieldGroup.Icon` | The slot `Button`, `Chip` and `Alert` all have, so a form does not carry a hard-coded `#888` on every field. | Their `animation="disable-all"` has no counterpart because nothing here animates: a decorator that is laid over the field does not move when the field is focused. ## Props ### `FieldGroup` Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | --------- | --------- | ------- | ------------------------------------------------ | | `asChild` | `boolean` | `false` | Merge into the single child instead of rendering | **No `variant`, `size`, `radius`, `color`, `isInvalid` or `isDisabled`.** They are the `TextField`'s. ### `FieldGroup.Prefix`, `FieldGroup.Suffix` Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus: | Prop | Type | Default | Notes | | -------------- | --------- | ------- | ------------------------------------------------- | | `isDecorative` | `boolean` | `false` | Touches pass through, and the content leaves a11y | `onLayout` is composed, never replaced — the measurement happens and your handler runs. ### `FieldGroup.Field` Everything `TextField.Field` accepts, which is everything `TextInput` accepts except `editable`, plus the `TextStyle` keys as props (R14). ### `FieldGroup.Icon` Everything `Icon` accepts. `size` and `color` default to the field's own; passing either wins. ## Extending it Both hooks are exported, so a third party can write its own decorator against the same values the built-in two read: ```tsx import { useTextField } from '@xaui/native/text-field' import { useFieldGroup } from '@xaui/native/field-group' function FieldGroupCounter({ length, max }) { const { suffixStyle, icon } = useTextField() const { setSuffixWidth } = useFieldGroup() return ( setSuffixWidth(event.nativeEvent.layout.width)} > {length} / {max} ) } ``` Reporting the width is what makes the field clear it. Skip that and the decorator still draws — over the text. Used outside a `` the hook throws by name, pointing at the misplaced component rather than failing three frames later on an undefined width. ## Accessibility - **The row has no role.** The control is the field inside it, and a role here would give a screen reader a second element to stop on before reaching it. - **A decorative decorator is hidden on both platforms** — `accessibilityElementsHidden` on iOS, `importantForAccessibility="no-hide-descendants"` on Android — and only when decorative: a reveal toggle is a control and has to stay reachable. - **A control in a suffix names itself.** Give it an `accessibilityRole` and an `accessibilityLabel`; a glyph has no text for a screen reader to read. - **A prefix is not a label.** `+33` in front of a field says nothing to a screen reader about what the field holds — write a `TextField.Label` as well. ## Migration from `@xaui/native-legacy` The legacy component is `TextInput`, and these are the two props this replaces. | Legacy | v1 | | ------------------------------ | ------------------------------------------------------- | | `startContent={}` | `…` | | `endContent={}` | `…` | | `isClearable` | a `Pressable` in a suffix that sets the value to `''` | | `customAppearance={{ input }}` | `style` on `` | # FlipCard A card with two faces, and a turn between them. ## Import ```tsx import { FlipCard } from '@xaui/native/flip-card' ``` ## Usage ```tsx Recto Verso ``` ## It paints nothing, and it has no recipe What turns is two faces the caller supplied, and each of those is usually a `Card` with its own variant, its own radius and its own shadow. A recipe here would be a second table saying the same things, and the day one of them gained a border the other would not have it. There is no `variant`, no `color` and no `radius` on a `FlipCard`. There is a `Card` inside it, and those are its. ## The front decides how big the card is The back is **out of flow** and fills it. A back in the flow would stack under the front and double the height. Two faces of different heights therefore take the front's — which is the only answer that does not make the card resize halfway through its own turn. ## The two faces are a half turn apart at every moment That is the whole mechanism. The away-facing side is not drawn (`backfaceVisibility: 'hidden'`), so exactly one face is on screen at any angle: | progress | front | back | | -------- | ----- | ----- | | 0 | 0° | −180° | | 0.5 | 90° | −90° | | 1 | 180° | 0° | `faceAngle` is that relationship and it is tested, including the case it exists to prevent: a back on a spring of its own, or a back at `progress × 180` rather than `(progress − 1) × 180`, shows **both faces at once** through the middle of the turn. It also follows past its own ends rather than clamping, which is what makes the spring's small overshoot visible — a card that stops dead at a hundred and eighty degrees reads as a texture swap rather than as an object with a weight. `rotation="reverse"` negates **both** faces, so they still follow each other. Negating one would make them meet. ## Perspective `{ perspective: 1000 }` comes first in the transform list, and it has to: a rotation applied before it is an affine squash, and the card reads as a blind closing rather than as a face turning away. It is the card's rather than a prop, because two cards on one screen at two depths look like a mistake. ## Direction `horizontal` (the default) spins about the vertical axis — the two faces swap left for right, which is what a playing card does. `vertical` turns it about the horizontal one, which reads as the card tipping towards you. ## Driving it `isFlipped` / `defaultFlipped` / `onFlipChange`, controlled or not, as everywhere in the library. `isPressable={false}` leaves it a display and the flip to a control of yours — a button on one face, a gesture, a timer: ```tsx function TurnBack() { const { flip } = useFlipCard() return } ``` `useFlipCard()` also carries `progress`, which is a **shared value**: an indicator that follows the turn frame by frame is `useAnimatedStyle` over it, and it costs no re-render. ## Accessibility Pressable, it is a `button` whose `accessibilityState.expanded` says which face is up. With `isPressable={false}` it is an `image` — an element a screen reader announces as pressable and which does nothing when pressed is worse than one it announces as a picture. It carries **no press feedback of its own**: the turn is the feedback, and a card that also dimmed under the finger would read as two things happening to it at once. ## See also - **`Card`** — what usually goes on each face. # InputOTP A one-time code, one character to a box. > **Not part of the 1.0 core.** The fifteen are listed in the plan and this is not one of > them; it ships under `1.x` as a P5 component. Nothing about its API differs — it follows > the same fourteen rules as the `Input` it sits beside. ## Import ```tsx import { InputOTP } from '@xaui/native/input-otp' ``` ## Anatomy ```tsx {({ slots }) => slots.map(s => )} ``` - **`InputOTP`** — the root. It owns the code as **one string**, renders a hidden `TextInput` over the row, and publishes the resolved styles and the per-box state. - **`InputOTP.Group`** — a row of boxes. It takes a **render function** as well as elements. - **`InputOTP.Box`** — one character's box. With no children it draws the value, the placeholder and the caret. - **`InputOTP.Value`** — the character, or nothing while the box is empty. - **`InputOTP.Placeholder`** — what stands in for it before anything is typed. - **`InputOTP.Caret`** — the blinking bar, in the box the next character lands in. - **`InputOTP.Separator`** — the dash between two groups. ### There is one input, and it is hidden Six focusable boxes is the design every OTP component starts with and every one of them abandons: the caret has to be moved by hand, a backspace at the start of a box has to jump backwards, and a paste arrives in one box out of six. Here a single `TextInput` covers the row and holds the whole code. The boxes are a _rendering_ of that string, so a keystroke, a backspace, a paste and an autofilled `one-time-code` all take the same path — and the state that can disagree with itself does not exist. Two consequences worth knowing: - **The hidden input takes every touch on the row.** That is what opens the keyboard from a tap anywhere. A control that has to stay pressable goes _beside_ the `InputOTP`, not inside it. - **The input cannot be hidden by size or `display`.** An input the platform thinks is invisible is one iOS refuses to focus and one the autofill heuristics skip — which would cost the `one-time-code` suggestion that is the point of the component. It is transparent instead, at a hair of opacity on iOS and zero on Android. ## Usage ### Basic ```tsx const [code, setCode] = useState('') {({ slots }) => slots.map(s => )} ``` Leave `value` out and the component keeps the code itself; `defaultValue` seeds it. `onComplete` fires once the last box is filled, which is the only event most callers need. ### Groups and a separator ```tsx {({ slots }) => slots.slice(0, 3).map(box)} {({ slots }) => slots.slice(3).map(box)} ``` Splitting is a matter of slicing what you are handed, which is why there is no `groupSize` prop. ### What `InputOTP.Group` is, and is not It is **the unit a `Separator` sits between**, and the node the render function belongs to. It is not an input, and there is never more than one of those however many groups you write — the hidden `TextInput` is the root's, it covers the whole row, and every box in every group reads the same string. **It buys no spacing today.** Its resolved style is the root's, line for line: a row, centred, with the same gap. The break you see between `482` and `913` comes from the separator's own width plus the gap on either side of it — `8 + 8 + 8` — which a flat row of boxes and a separator would produce identically: ``` □ □ □ [8] ▬ [8] □ □ □ └8┘ └──── 24 ────┘ ``` What it keeps open is the ability to make those two gaps differ — a tight one inside a group, a loose one around the separator — which is a change to one number in the recipe rather than a change of shape. That is the reason it stays: a single-group code pays one nested view for it, and that is the whole cost. ### The render function `InputOTP.Group` is the one slot in the library that takes a function, because the number of children here is **data** — `maxLength` — rather than markup. Writing six boxes by hand is a list that silently disagrees with the prop the moment either changes. It is handed `{ slots, value, maxLength, isFocused, isDisabled, isInvalid }`. Elements still work if you want to write them out. ### Composing a box ```tsx ``` The three built-in children each return `null` when they do not apply, so all three stay mounted and the box never changes its tree as the code is typed. ### Pattern ```tsx import { OTP_DIGITS, OTP_LETTERS, OTP_ALPHANUMERIC } from '@xaui/native/input-otp' ; … ``` Tested against the **whole value**, not each character, so a rule about the shape of a code works as well as one about its alphabet. A string is compiled once; a `RegExp` you built is used as it is, so its flags stay yours. ### Paste Nothing to configure. A one-time code arrives surrounded by prose far more often than alone — _"Your code is 482913, it expires in 10 minutes"_ — and a naïve `slice(0, 6)` takes `"Your c"`. The component looks for a run of exactly `maxLength` digits with **no digit on either side**, which is what rules out the `10` in that sentence and the year in a date. `maxLength` is deliberately **not** set on the hidden input: the platform would truncate the message before there was anything to look inside. ### Placeholder ```tsx {/* every box */} {/* one per box */} ``` Shown only where there is neither a typed character nor the caret. That three-way choice is made in one place, so two of them never appear at once. ### Imperative ```tsx const otp = useRef(null) … otp.current?.focus() otp.current?.clear() ``` **`ref` is not the view.** It is `focus`, `blur` and `clear` — the three things only the hidden input can do, and the reason the usual "the root forwards its node" shape is inverted here. `clear` empties both halves, the native buffer and the value; emptying one and not the other is how a cleared field comes back on the next keystroke. ### Sizes | `size` | Box | Character | Gap | | ------ | ------- | --------- | --- | | `sm` | 40 × 36 | 16/24 | 8 | | `md` | 48 × 44 | 18/28 | 8 | | `lg` | 56 × 52 | 20/28 | 10 | `md` is the reference implementation's OTP measured. The width is the control height less one spacing step, which reproduces their 48 × 44 and holds the proportion at the other two. ### The corner The box takes `lg`, 12 points — **not `field`**, which is what the rest of this family uses. A field is wide, so 21 on a 48-tall one reads as a rounded rectangle. A code box is very nearly square — 44 by 48 at `md`, 36 by 40 at `sm` — where the geometric maximum is 22, so the same 21 is a pill in all but name and is clamped to one outright at the small end. Twelve is where the reference implementation lands for the same box from the other direction: their `field` radius is their `xl`, and their scale's base is 8 where ours is 12. **There is no `xs`**, where the rest of the library has four sizes. That box would be 28 by 32, and it still has to carry an 18pt character to stay legible — 18 in 28 leaves no room for the two-point active ring without the digit touching it. A code is also the one field a user reads back to themselves character by character, which is the worst place to save eight points. Below `sm`, use fewer boxes rather than smaller ones. ### Variants Three of the `Input`'s four, token for token — a box of a code is a field one character wide. | `variant` | Background | Border | Active ring | Shadow | | ----------- | ----------------- | ------------- | ----------- | ------- | | `primary` | `fieldBackground` | `fieldBorder` | `accent` | `field` | | `secondary` | `default` | `fieldBorder` | `accent` | — | | `tertiary` | transparent | `fieldBorder` | `accent` | — | **The active box takes a two-point ring** in the accent. The reference implementation uses `outline-width: 2px`; React Native has no `outline`, so it is a border — and two points rather than one, because a box that gains a colour without gaining weight reads as a rendering artefact next to five that did not. The box has a fixed width and height and centres what it holds, so the extra point eats into the padding rather than moving anything. **Three levels, not the `Input`'s four.** There is no `ghost`, and the shape of the component is what removes it: an input is one wide field whose position the caret and the label already give away, so it survives having neither fill nor edge. A code is six boxes, and their only job before anything is typed is to say **how many characters are expected and where they go** — with no fill and no border there is nothing to count. It is the reason the `Checkbox` has no `ghost` either. ### `isInvalid` Every box goes `danger`, and the active ring is **taken off**: the red is already on all of them, and a seventh colour on one says nothing it did not. ### Colour ```tsx … ``` A raw tint, never a token (R7). It lands where the variant put its tokens **and** becomes the active ring — the active colour is a role like any other, so nothing extra is passed. ## Props ### `InputOTP` Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | ---------------- | ----------------------------- | ------------- | ---------------------------------------- | | `maxLength` | `number` | — (required) | How many boxes, and how long the code is | | `variant` | `InputOTPVariant` | `'secondary'` | The three levels above | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The box, the character, the gaps | | `radius` | `RadiusKey` | — | Overrides the theme's `lg` radius | | `color` | `string` | — | A hex tint, placed by the variant | | `value` | `string` | — | Controlled | | `defaultValue` | `string` | `''` | Uncontrolled seed | | `onChangeText` | `(value: string) => void` | — | Every change, controlled or not | | `onComplete` | `(value: string) => void` | — | Fires when the last box fills | | `pattern` | `string \| RegExp` | — | Tested against the whole value | | `placeholder` | `string` | — | One per box, or one for all | | `isInvalid` | `boolean` | `false` | Danger boxes, no active ring | | `isDisabled` | `boolean` | `false` | Dims the row, stops the input | | `inputMode` | `TextInputProps['inputMode']` | `'numeric'` | The keyboard the hidden input asks for | | `textInputProps` | `TextInputProps` | — | Anything else for the hidden input | **No `asChild`** — the one root in the library without it. This root is not a pass-through container: it owns a hidden `TextInput` that has to be its own child, and `Slot` merges into a _single_ element. Wrap the `InputOTP` instead of replacing it. ### `InputOTP.Group` Everything `View` accepts plus the `ViewStyle` keys (R14). `children` is a node **or** a function of `{ slots, value, maxLength, isFocused, isDisabled, isInvalid }`. ### `InputOTP.Box` Everything `View` accepts plus the `ViewStyle` keys (R14), and `index` — which box this is. With no children it renders the placeholder, the value and the caret. ### `InputOTP.Value`, `InputOTP.Placeholder` Everything `Text` accepts plus the `TextStyle` keys (R14). `maxFontSizeMultiplier` is capped at `1.6`: a box has a fixed width, so a character scaled to 200% would be cut in half rather than made larger. ### `InputOTP.Caret` The `ViewStyle` keys as props (R14), plus `animation` — `false` stops the blink and leaves the bar, so nothing moves. ### `InputOTP.Separator` Everything `View` accepts plus the `ViewStyle` keys (R14). ## Extending it Two hooks are exported: `useInputOTP()` for the whole component's resolved styles and state, and `useInputOTPBox()` for one box's own slot. `buildSlots` and `extractPastedCode` are exported too, because a caller reimplementing the paste rule is a caller who will get it subtly different. ```tsx import { useInputOTP } from '@xaui/native/input-otp' function OTPProgress() { const { value, maxLength } = useInputOTP() return ( {value.length} / {maxLength} ) } ``` ## Accessibility - **The hidden input is the control**, and it carries the label, the disabled state and an `accessibilityValue` reading "n of m entered" — so the boxes are decoration and a screen reader never walks six separate elements. - `autoComplete="one-time-code"` and `textContentType="oneTimeCode"` are set, which is what puts the code in the keyboard's suggestion strip. Both depend on the input being _transparent_ rather than hidden. - **`InputOTP.Separator` is hidden from the accessibility tree.** The value is read off the hidden input, which has no gap in it — announcing a dash in the middle of a code would be reading punctuation that is not in the value. - The caret is ours rather than the platform's, because the real one belongs to an input stretched across the whole row and would sit wherever the invisible text ends. ## Migration from `@xaui/native-legacy` There is none: the legacy package has no OTP component. This is a new one. # LineChart A line per series, over a shared scale. ```tsx import { LineChart } from '@xaui/native/line-chart' ; `${value / 1000}k`} /> ``` Everything about the plot, the palette, the axes and the API's shape is in [`chart.md`](../chart/chart.md) — this page is only what is this chart's own. ## Its own props | Prop | Type | Default | Notes | | ------------- | ------------------------ | ------------ | ------------------------------- | | `curve` | `'monotone' \| 'linear'` | `'monotone'` | The curve never leaves its data | | `strokeWidth` | `number` | `2` | | | `hasPoints` | `boolean` | `false` | A dot on every reading | `hasPoints` is off by default because twelve months of dots is a dotted line. It earns itself on a short series, where each reading is a thing you point at rather than a trend. The cap and the join are round: a line ending in a square cap reads as clipped at the axis, and every trend line ends at the edge of its plot. # List ## Overview `List` virtualise une collection avec le `FlatList` de React Native. Il fournit la structure visuelle d'une ligne sans lui attribuer de comportement de sélection ou de pression. ## Anatomy ```tsx ``` ## Usage ```tsx person.id} renderItem={({ item }) => ( {item.avatar} {item.name} {item.role} {item.action} )} /> ``` ## Props Le root accepte les props verticales de `FlatList`, ainsi que les style props de vue. Les props qui changeraient son orientation ou son séparateur sont volontairement réservées afin de garantir le contrat visuel du composant. `asChild` permet de les fusionner dans un root personnalisé compatible avec `FlatList`. ## Slots `Item` compose une ligne. `Leading` et `Action` centrent leurs contenus. `Content` prend l'espace restant et empile `Title` et `Description`. ## Variants `List` n'a pas de variant d'apparence : son fond reste celui de son parent et ses espacements proviennent du thème. ## Accessibility Le root est annoncé comme une liste. Chaque ligne conserve les propriétés d'accessibilité fournies à `List.Item`, et le contrôle placé dans `Action` conserve sa propre sémantique. ## Migration from legacy Contrairement à `ListBuilder`, la nouvelle API reçoit le contrat standard de `FlatList` et compose la ligne avec des slots. La sélection et la pression appartiennent au contrôle rendu par l'application, pas à la liste. # ListBox Rows on a ground. ## Import ```tsx import { ListBox, ListBoxGroup } from '@xaui/native/list-box' ``` One import, because they are one component and a group with no lists in it is nothing. ## Usage ```tsx Wi-Fi Maison ``` ## Anatomy | slot | what it is | | ------------------------- | ------------------------------------------------------ | | `ListBox` | The ground, and the thing that draws the separators | | `ListBox.Item` | One row, inert | | `ListBox.ItemButton` | One row you can press, in place of `ListBox.Item` | | `ListBox.ItemPrefix` | What leads it — an icon, an avatar, a checkbox | | `ListBox.ItemContent` | The text column, and what pushes the suffix to the end | | `ListBox.ItemTitle` | What the row is | | `ListBox.ItemDescription` | What it is currently set to, usually | | `ListBox.ItemSuffix` | What trails it — a value, a switch, a chevron | ## It is the `Accordion` with rows that do not open Same ladder, same separator inset, same one variant lifted. That is deliberate: a list in `primary` **is** a card with rows in it, and two containers that look alike but are declared apart drift until a list on a card sits one shade off it. They will eventually share a container — the `Surface` refactor that `Card`, `Popover`, `Accordion` and `Dialog` are all waiting on. Until then they at least name the same tokens. ## The separators are the root's Drawn **between** the children rather than by them. A row that drew its own would draw one under the last row too, and every list would start by hiding it. The fill is the root's for the same reason: a row that painted its own would stack two fills where the hairline sits, and the hairline would disappear into the seam. The hairline stops where the text starts rather than running the full width — a separator under a row's own inset reads as a rule the rows hang off, where one that starts with them reads as the gap between two rows, which is what it is. `ghost` has no edge for its separators to be inset from, so its rows run the full width and the hairline runs with them. That is the difference between a list in a box and a list on a page. `hasSeparator={false}` leaves the rows to run together. ## It does not select There is **no `selectionMode` and no `selectedKeys`**. Picking one of several things is what `Select` and `Menu` are; a list that owned a selection would be a second, quieter menu with none of the affordances. A row that toggles carries the control that toggles it: ```tsx Wi-Fi ``` which says out loud what it does, and is reachable by a screen reader as the control it actually is. ## The suffix draws nothing of its own The reference implementation's puts a chevron there by default. The trailing end of a settings row is a `Switch` at least as often, and a slot that guesses makes you pass a child in order to render nothing. The library ships `ChevronDownIcon`; a row that wants one says so. ## A plain row does nothing, and shows nothing **A list is not necessarily a list of buttons.** Most of them are a table of facts: a value beside a label, a switch that is its own control. `ListBox.Item` is therefore a `View` — no press state, no wash, no role — because a row that lights up under a finger it never responds to is a promise the component does not keep. A row you can press is `ListBox.ItemButton`, used **in place of** `ListBox.Item`: ```tsx Wi-Fi Sécurité ``` It is the same row — same inset, same slots, same separators around it — plus the two things a plain row must not have: the wash under the finger and the `button` to announce. **Structural rather than inferred.** A single `Item` that turned pressable when it was given an `onPress` would still be guessing, and the guess would be invisible in the JSX: two rows that read identically would behave differently. Which of the two a row is, the JSX says. `isDisabled` on the root stops every `ItemButton`; on one of them it stops that one. `accessibilityRole` stays overridable (R9). The root announces itself as a `list`. That is overridable too — a list of one row is a row, and a list used as a plain container is neither. ## `ListBoxGroup` — the sectioned list ```tsx Réseau Wi-Fi Le Wi-Fi se coupe en veille. Confidentialité … ``` **It is a group of lists, not a list with headings in it.** A `ListBox` draws its container and its separators **between its own children**, so a heading placed among the rows would get a hairline above and below it and would sit inside the card it names. Sections are containers side by side; a heading belongs outside them. **`ListBoxGroup.Section` exists because proximity is the only thing grouping a header with its list** — nothing draws a box around a section. One gap on the group would put a heading exactly as far from its own rows as from the section above it, so there are two gaps, on the two roots that own them (R4). The ratio is the whole design. **The header is inset by the row's own padding**, read off the `ListBox`'s size table rather than guessed, so the heading and the text it heads share a left edge. `ListBoxGroup.Footer` is inset with it — the sentence under a settings block that says what the switch actually does. **Nothing is walked and nothing is counted.** The group publishes two gaps and a type scale; the sections are ordinary children, and a section can be built out of something that is not a `ListBox` at all. ### What the group hands down `variant`, `size`, `radius`, `color` and `hasSeparator` are **defaults**, and a list that names its own wins: a settings screen is uniform, and setting `variant` on five lists is five chances to set it differently. `isDisabled` is the one that is not a default — a disabled group has no live list in it. ```tsx Du groupe … À elle … ``` A `ListBox` outside any group is unchanged. ### Alignment with the reference implementation Their `ListBoxGroup` **is our `ListBox`** — a Surface container with Item · ItemPrefix · ItemContent · ItemTitle · ItemDescription · ItemSuffix, slot for slot. What is here under that name is the thing neither of us had: the sections, their headings and the spacing between them. Two components rather than one renamed, because a settings screen is a column of lists and a list is a column of rows, and those are two different columns. ### `ListBoxGroup` props Everything `View` accepts, every `ViewStyle` key it does not claim (R14), plus: | prop | type | default | description | | -------------- | --------------------------------------------------- | ------- | ----------------------------- | | `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'ghost'` | — | Default for every list | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `md` | Gaps, heading type, the inset | | `radius` | `RadiusKey` | — | Default for every list | | `color` | `string` | — | Default for every list | | `hasSeparator` | `boolean` | — | Default for every list | | `isDisabled` | `boolean` | `false` | Every list, and none opts out | | `asChild` | `boolean` | `false` | Renders the caller's element | `ListBoxGroup.Section` takes `View`'s props, `ListBoxGroup.Header` and `ListBoxGroup.Footer` take `Text`'s, all three plus their style props (R14). `useListBoxGroup()` is exported (R10). `ListBoxGroup.Header` carries `accessibilityRole="header"`, overridable — that is what lets a screen reader jump between sections. The footer carries none: a footnote is prose, and announcing it as a heading would put it in the list a reader jumps between. ## Props | prop | type | default | description | | -------------- | --------------------------------------------------- | --------- | ------------------------------ | | `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'ghost'` | `primary` | Which ground | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `md` | Inset, type and corner | | `radius` | `RadiusKey` | — | Overrides the corner | | `color` | `string` | — | The tint (R7) — a raw value | | `hasSeparator` | `boolean` | `true` | Hairlines. The group's, in one | | `isDisabled` | `boolean` | `false` | Stops every row | | `asChild` | `boolean` | `false` | Renders the caller's element | ## Two layers, and one of them is a platform constraint The root carries the subtle `field` shadow and the border and must **not** clip: on iOS `overflow: 'hidden'` sets `masksToBounds`, which cuts the layer's own shadow off with everything else, and a raised `primary` would have none. It deliberately sits closer to the page than a card, whose stronger elevation uses the `surface` shadow. So the clipping is one layer in. It is the only extra node in this component, and without it the press wash on the first and last rows paints square over the container's rounded corner. The `Accordion` carries the same pair, for the same reason. ## Everything else is a style prop `padding`, `gap`, `backgroundColor` — full RN names, full RN values (R14), on the root and on every slot. There is nothing here a prop had to be invented for. # MaskField A value typed into a shape — a date, a time, a card number, or a pattern of your own. ## Import ```tsx import { MaskField, parseMaskedDate } from '@xaui/native/mask-field' ``` ## Usage ```tsx Card number parseMaskedDate(text, 'fr-FR')} onValueChange={(text, value) => setBirthday(value as Date | null)} > Date de naissance Jour, mois, année. ``` ## It is a `TextField` The root below is the `TextField`'s root, unchanged: the same recipe, the same four variants, the same `size`, `radius`, `color`, `labelPlacement`, `isInvalid` and `isDisabled`. `MaskField.Label`, `.Description` and `.Error` **are** the `TextField`'s slots — the same components, re-exported rather than wrapped — and only `MaskField.Field` differs, by masking what is typed into it. The `TextArea`'s arrangement exactly, and `TextField` is not touched. ## It is a mask, not a set of segments There is one representation — the accepted characters, in order — and `maskInput` is the only thing that turns them into text. Everything else is dropped, including the literals, which are **put back** rather than kept. That is what makes the field survive a paste, a keyboard that offers its own punctuation, and a backspace over a separator, none of which a segmented field survives without a rule each. ## `mask` is a preset or a pattern | preset | shape | rules | | --------------- | --------------------- | ------------------------------------------------------------------ | | `'date'` | `DD/MM/YYYY` | order and separator from `locale`; each part clamped, never raised | | `'time'` | `HH:MM` | 24-hour; hour ≤ 23, minute ≤ 59 | | `'datetime'` | `DD/MM/YYYY HH:MM` | the two above, joined by a space | | `'credit-card'` | `#### #### #### ####` | shape only | `MASK_FIELD_MASKS` is the list of them. Anything else is a **pattern string**: | token | accepts | | ------- | ---------------------------------------- | | `#` | a digit | | `A` | a letter | | `*` | either | | _other_ | a literal, put back in as the parts fill | ```tsx ``` A pattern is a shape, not a range — it does not clamp. If a custom mask needs clamping, that is a follow-up, an object form `{ pattern, clamp }`. ### The date parts come from the locale Read out of `Intl` rather than off a table of countries. `order` overrides it when the order is a decision rather than a locale — an ISO field is `YMD` wherever it is read. `separator` and `segmentLabels` (`{ day, month, year }`) override the rest. A date that cannot exist — the 31st of February — is capped by the mask the moment the month is known, and `parseMaskedDate` returns `null` for one typed before it, rather than rolling forward into March the way `new Date` would. ## The value is the masked string `value` / `defaultValue` / `onValueChange` operate on the masked text, controlled or not, as everywhere in the library. A raw string handed to `value` is masked on the way in. `convert` is the one plug that turns the text into a value of your own: ```tsx …} /> ``` `parseMaskedDate(text, locale?, order?)` → `Date | null` and `parseMaskedTime(text)` → `{ hours, minutes } | null` are exported for the `date` and `time` shapes, with `formatMaskedDate` / `formatMaskedTime` as their inverses. Without `convert`, `onValueChange`'s second argument is the masked text itself. ## Slots | slot | node | what it is | | ----------------------- | ----------- | ----------------------------- | | `MaskField` | `TextField` | the field | | `MaskField.Label` | `Text` | the `TextField`'s label | | `MaskField.Field` | `TextInput` | the box, masked | | `MaskField.Description` | `Text` | the `TextField`'s description | | `MaskField.Error` | `Text` | the `TextField`'s error | `MaskField.Field` takes the number pad for a digit shape and the default keyboard for one with letters, and caps the caret at the shape's own rendered length. Inside a `FieldGroup` it leaves the decorators their room, exactly as `FieldGroup.Field` does — so a `$` prefix or a clear button sits beside it without the text running under. ## For a date chosen rather than typed That is `DatePicker` — a trigger, an overlay and a `Calendar` in it. `MaskField` is the box alone; it has no picker. ## Props Every node takes R14's style props for its own style type and forwards `ref`, `testID` and the a11y props. `useMaskField()` is exported — it returns the masked text, the keystroke handler and the shape, so a field of your own is written the same way the shipped one is. # Menu A list of actions, anchored to whatever opened it. The `Popover`'s positioning with rows in it. ## Import ```tsx import { Menu } from '@xaui/native/menu' ``` ## Anatomy ```tsx … … … … ``` - **`Menu`** — state and resolved style. **It renders no node.** - **`Menu.Trigger`** — what opens it, and the rectangle it anchors to. - **`Menu.Overlay`** — the backdrop, and what closes it on a press outside. - **`Menu.Content`** — the panel, positioned against the trigger, in a portal. - **`Menu.Label`** — a heading over a run of rows. - **`Menu.Separator`** — a rule between two runs of them. - **`Menu.Group`** — that run, announced as a group. - **`Menu.Item`** — one action. - **`Menu.ItemTitle`** / **`Menu.ItemDescription`** — its two lines. - **`Menu.ItemIndicator`** — a fixed box at either end of a row. ## Usage ### Basic ```tsx Renommer Supprimer ``` Choosing a row closes the menu, **after** the caller's `onPress` has run: a handler that reads the menu's state has to run while there is still a menu. ### A row that does not close ```tsx setCount(n => n + 1)}> Encore une ``` For a row that toggles something the reader will want to toggle again. ### Headings, groups and rules ```tsx Ce document … Supprimer ``` `Menu.Label` and `Menu.Group` draw nothing; `Menu.Separator` is the rule, and **you place it**. A menu of four related actions wants none; a menu whose last row is "Supprimer" wants exactly one, above it. Drawing them between every pair and asking for the exceptions is the wrong way round — a menu is short enough that the one place a break belongs is obvious to whoever wrote it, and invisible to the component. It runs the panel's full inner width rather than lining up with the rows' text: a rule inset to the titles reads as belonging to the row under it, and this one belongs to neither. It is hidden from screen readers, because announcing "separator" between every pair of actions is noise in the one place a menu has to be brisk. ### Style as props ```tsx ``` Full RN names, full RN values (R14). Every node takes them. ## Props ### `Menu` | prop | type | default | description | | -------------- | --------------------------- | ------- | ---------------------------- | | `radius` | `RadiusKey` | — | Overrides the panel's corner | | `isOpen` | `boolean` | — | Controlled | | `defaultOpen` | `boolean` | `false` | Uncontrolled | | `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and on close | | `isDisabled` | `boolean` | `false` | Dims the trigger, stops it | There is no `variant` on the root. A menu is the theme's floating surface, like the `Popover`. The intent belongs to the **row**. ### `Menu.Content` The `Popover.Content` props exactly — `placement` (four sides), `align`, `width`, `offset`, `alignOffset`, `avoidCollisions`, `insets` — with one difference: `offset` defaults to **6** rather than 9, because a menu belongs to the control it drops out of and a popover belongs to nothing. ### `Menu.Item` | prop | type | default | description | | --------------- | ----------------------- | --------- | ----------------------------------- | | `variant` | `'default' \| 'danger'` | `default` | Paints the title and its icon | | `isDisabled` | `boolean` | `false` | | | `closesOnPress` | `boolean` | `true` | Whether choosing it closes the menu | | `asChild` | `boolean` | `false` | | `children` may be a function taking `{ isPressed, isDisabled }`. ### `Menu.ItemTitle` · `Menu.ItemDescription` · `Menu.Label` Everything `Text` takes, plus `TextStyle` as props. The title carries the row's intent and takes the row's width, so an indicator stays pinned to the end whatever the label's length. The description stays **muted whatever the intent**: a `danger` row says what it does in red once, and a red sentence under it says it twice. ### `Menu.ItemIndicator` A fixed 20-point box. Its size does not depend on what is in it, so two rows whose indicators differ still line their titles up. It renders nothing on its own, unlike the `Select`'s — a menu has no selected row to mark. ## What it shares The positioning is the `Popover`'s, hook for hook: `utils/placement.ts`, `hooks/use-anchor-ref.ts`, `hooks/use-anchored-position.ts`, `system/anchored/`. This is the third component to read them, after the `Select` and the `Popover`. ## Not here yet **`SubMenu`.** The reference implementation ships it as its own component and it needs a second anchored panel whose trigger is a row of the first — worth its own change rather than a corner of this one. ## Accessibility The trigger is a `button` carrying `expanded`. Rows are `menuitem`, groups are `menu`, and `Menu.Label` is a `header` so the group it opens is announced with it. The overlay announces nothing — it is the absence of the menu. ## Migration from `@xaui/native-legacy` The legacy component is `Menu`, with `MenuItem`. | Legacy | v1 | | -------------------------- | ----------------------------------------- | | `` | `…` | | `description="…"` | `` | | `isDestructive` | `variant="danger"` | | `icon={…}` | an `` inside `` | | `customAppearance={{ … }}` | `style` on the slot that key named | # MorphButton A button that changes shape: a pill at rest, a card once it is open. ## Import ```tsx import { MorphButton } from '@xaui/native/morph-button' ``` ## Usage ```tsx Primary Primary High-contrast inverted surface for floating buttons over app content. ``` ## One box, two contents The pressable **is** the shape that travels, and exactly one face is mounted at a time — so the box takes its size from whichever that is, and Reanimated's layout transition animates it between the two. **Nothing is measured.** No hidden pass, no `maxHeight`, no `onLayout`: a card whose sentence arrives from the network grows the box with it, instead of being stuck at the size it had when it was measured. It is the `Accordion`'s panel, applied to the control's own box rather than to a row under it. That is also why the transition sits on the pressable rather than on a wrapper around it. A transition on a wrapper animates the wrapper's frame while the pressable inside it is already at its final size, so the content is drawn outside the shape for the length of the spring. `PressableFeedback` carries a `layout` prop for exactly this. The box clips (`overflow: 'hidden'`), which turns those same frames into the shape revealing its content. ## One corner for both shapes Half the collapsed height. At that height it is exactly a pill; on the taller card it is a corner in proportion to the control's scale. Two radius keys would have needed a compound of `size` and `radius` — a pill at `sm` is 20 points and a card rounded that much is a gélule — so the pair would have said what one derived value says once. `radius` still overrides it. ## The measurements are on the faces, not on the box `size` writes the collapsed height and side inset on `Collapsed`, and the card's four-sided frame on `Expanded`. Since exactly one of them is mounted, the root takes its shape from whichever that is — which is what removes the `size` × `isExpanded` compound this component would otherwise need. | `size` | collapsed height | pill inset | card frame | title | description | | ------ | ---------------- | ---------- | ---------- | ----- | ----------- | | `sm` | 40 | 14 | 16 | 16 | 12 | | `md` | 48 | 16 | 20 | 18 | 14 | | `lg` | 56 | 20 | 24 | 20 | 16 | `xs` is absent rather than forgotten: a card that opens out of a 32-point control has less room than the padding it would need, and the shape it morphs into is a pill with two lines crammed into it. There is no `fullWidth` and no width anywhere in the recipe. Without an `alignSelf` the box fills its column in **both** shapes, which is RN's own behaviour and the `Button`'s. ## No `childrenToString` R3 auto-wraps a text child everywhere else in the library. This is the one component that legitimately skips it: a bare string could belong to either face, and wrapping it into the collapsed one would build a button that morphs into an empty card. The two faces are the API, and a missing one warns in development. ## Driving it `isExpanded` / `defaultExpanded` / `onExpandedChange`, controlled or not, as everywhere in the library. Pressing it toggles the shape, and `onPress` still runs. `useMorphButton().toggle` is what a control **inside** a face is written against, and it costs no state: ```tsx function Close() { const { toggle } = useMorphButton() return } ``` A caller who does not want the press to close the card holds the state itself and ignores the change when it is open — which is why there is no `isPressable` prop. ## The spring `animation={false}` puts it in the other shape with no travel at all: the box jumps and the faces swap without a fade. An object retunes it — `{ stiffness, damping, mass }`, merged over `MORPH_SPRING`. The default is slightly under-damped (ζ ≈ 0.85), so the shape settles with a hint of overshoot rather than stopping dead: a box that arrives exactly at its target reads as a layout swap, and the small overrun is what makes it read as one object changing shape. The faces fade over 140ms — shorter than the travel, because they are never both on screen and this is the arriving one catching up rather than a crossfade. They fade from the **second** shape onwards. Reanimated runs an `entering` animation on the first mount as well, so without that every button on a screen would fade its collapsed face in as the screen arrived — and on the web would render `visibility: hidden` until the worklet started. `defaultExpanded` therefore opens onto the card with no entrance of its own. ## Appearance The `Button`'s seven variants, unchanged, and the card's two text slots read the same foreground as the pill's label — the description turned down by opacity rather than recoloured, because the ground is a raw `color` as often as it is a token and there is no contrast colour in the theme for a colour the caller invented. The pressed colour is the variant's own `…Pressed` token rather than a `PressableFeedback.Highlight`, exactly as on the `Button`: both would darken the control twice. ## Accessibility A `button` whose `accessibilityState.expanded` says which shape it is in. A caller's own `accessibilityState` is merged over that rather than replacing it, so adding `selected` cannot silently drop the `expanded` a screen reader depends on. ## See also - **`Button`** — the same table, on a box that does not change shape. - **`Accordion`** — the same mounted-or-not height animation, on a panel rather than a control. - **`FlipCard`** — two faces and a turn between them, where this is two shapes and a travel. # NumberField A number, typed — with the two ends of its range and a pair of buttons that walk it. ## Import ```tsx import { NumberField } from '@xaui/native/number-field' import { FieldGroup } from '@xaui/native/field-group' ``` ## Usage ```tsx Quantité ``` ## It is a `TextField` The root is the `TextField`'s root, unchanged: the same recipe, the same four variants, the same `size`, `radius`, `color`, `labelPlacement`, `isInvalid` and `isDisabled`. `NumberField.Label`, `.Description` and `.Error` **are** the `TextField`'s slots — the same components, not wrappers. Only the field differs, by reading a number out of what is typed into it. The [`MaskField`](../mask-field/mask-field.md)'s arrangement exactly. ## The value is a number, and the box is a string Two representations, and the caret is what swaps them. | where the caret is | what the box holds | | ------------------ | -------------------------------------------------- | | outside | the value written by `Intl`, `formatOptions`'s way | | inside | the same number written plainly, no grouping | **Out of the field**, `formatOptions` is `Intl.NumberFormat`'s own options, unchanged — a currency, a unit, a fixed number of decimals cost nothing here. **Into it**, everything that is not a digit, a sign or the decimal mark is **dropped rather than refused**. The value the caret lands in is grouped, and rejecting its separators would make the first keystroke on an existing value clear the box. A full stop passes as the decimal mark wherever the locale is not using it to group, so `1.5` reads as one and a half in `fr-FR` and `1.234` still reads as a thousand-odd in `de-DE`. **On the way in the value is rewritten plainly**, so nobody has to type a euro sign back in. `null`, never `NaN`: "not a number yet" is the state a half-typed field is in for as long as it takes to type a minus sign, and `NaN` is a value that propagates instead of a state a caller can test. ## The bounds land when the reader leaves, not while they type `min={10}`, and a reader on their way to `15` types a `1` first. Clamping that would take the keyboard away from them. So until the field is left, `onValueChange` reports what is actually in the box. The clamp falls on blur, and on every press of a stepper. ## The steppers They are `FieldGroup` decorators, like `TimeField.Period` — that is the thing that lays a control over a field and measures it, so the box stays the `TextInput` itself and no wrapper borrows its border, its fill and its radius. **`Decrement` takes the leading edge and `Increment` the trailing one.** The value sits between them, which is the only arrangement in which the two read as one control rather than as two marks that happen to be nearby. `start` and `end`, never `left` and `right` (R13). **A button goes flat when the value has nowhere left to go**, and stops taking presses there. That is asked of the **result** rather than of the bound: a value half a step short of the ceiling can still reach it, and a button dead at that point strands the reader. With no children each draws its own mark — one bar, or two a quarter turn apart — so the field works in a project that has installed no icon set. Pass an `` to replace it. ## From an empty field, a step starts at zero Which is what puts the first press on `min` when the range starts above it: `min={5}` and a step of one gives 5, not 6. ## The keyboard is a guess, and a guess you can override A fractional `step`, or a `formatOptions` that asks for decimals, opens the decimal pad; anything else opens the number pad. Unlike the `MaskField`'s, `NumberField.Field` does **not** claim `keyboardType` — a field that has to take a minus sign needs a keyboard with one on it, and that is the caller's call. ## Related - [`NumberStepper`](../number-stepper/number-stepper.md) — the same pair without a field to type into. - [`Slider`](../slider/slider.md) — a quantity chosen on a track. - [`TextField`](../text-field/text-field.md) — the root, the label, the hint and the error. # NumberPad The keypad a PIN, a code and an amount are typed on. ## Import ```tsx import { NumberPad } from '@xaui/native/number-pad' ``` ## Usage ```tsx ``` ```tsx ``` ## The grid is data, not markup `1` through `9`, the `0` and the backspace are not a decision a caller makes, so the root renders them. Eleven hand-written cells is a layout that disagrees with itself the moment one of them is edited, and there is no `maxLength`-shaped prop driving the count the way there is in `InputOTP` — which is the one component here whose children genuinely are data and which therefore takes a render function. What **is** composed is the one free corner of the bottom row, opposite the backspace, and that is what `children` is. One cell. Left out, the corner is still a cell — without it the `0` slides to the start of its row and stops being the middle column. `NUMBER_PAD_ROWS` is exported, for a caller building a different pad out of the same cells. ## It draws no display What the value looks like is the screen's — an `InputOTP`'s boxes, a row of dots, an amount in a `Typography`. A pad that also rendered the value would be two components that have to agree on a string, and the interesting half of that pair is always the one the pad did not anticipate. ### A masked display is a composition, not a prop There is no `isSecure` here, and there does not need to be. `InputOTP.Value` takes children that win over the box's own character, so a PIN masks in one line: ```tsx {({ slots }) => slots.map(slot => ( {slot.char ? '•' : undefined} )) } ``` The lock screen's bare row of dots — no boxes at all — is a `View` per character filled up to `value.length`, which is six divs and stays the screen's. A component for it would have to pick a size, a gap, a colour and a shape on behalf of every lock screen that installs it. Both are on the demo screen. ## The value One string, controlled or not: `value` / `defaultValue` / `onChangeText`, as everywhere in the library. `onComplete` fires the moment it reaches `maxLength` — the PIN screen's only event. `maxLength` **clamps rather than truncating**. A press past the limit changes nothing at all, so `onChangeText` does not fire and a full PIN cannot be completed twice by leaning on a key. The insert is measured whole rather than as one character, because a key may carry more than one: a `00` key on a currency pad that landed halfway over the limit would produce a value one character longer than the pad promised. Unset, `maxLength` is unbounded — which is what a pad in front of an amount wants. ## The cells - **`NumberPad.Key`** — one filled key. `value` is what it inserts, and what it shows when nothing is composed inside it. Exported for the eleventh key: a decimal separator, a `00`. - **`NumberPad.Backspace`** — the delete key, bare. **A long press clears the whole value**, the gesture every platform's own keypad carries; without it a mistyped sixteen-digit card is sixteen presses to undo. - **`NumberPad.Action`** — the free corner. It does nothing on its own, because what belongs there is the caller's: a fingerprint that unlocks, a `Clear`, a decimal separator. - **`NumberPad.Label`** and **`NumberPad.Icon`** — the character or the glyph on a cell. ### A cell owns its press state The root cannot see which of eleven keys is down, so it resolves **both faces** of the cell style and each cell picks. That is the `Menu`'s arrangement, for the same reason: nothing re-resolves per key, so a pad of eleven costs what a pad of two would. R5 stays intact — no slot touches the recipe. ### Bare cells read the page's foreground `NumberPad.Label` and `NumberPad.Icon` need no prop to know which colour they take: the cell they are in publishes it. A filled key reads the variant's foreground; a bare corner reads the page's. That distinction is load-bearing rather than cosmetic. A `primary` pad puts `accentForeground` on its digits, and a backspace with no ground of its own would take white on white. It is also why a raw `color` does not reach the bare cells — a glyph with nothing tinted behind it has nothing tinted to read against. ## The backspace arrow is a character `←`, not an icon, so the pad needs no `react-native-svg` for the one glyph it draws itself. It flips to `→` under RTL, because a backspace points at what it removes and that is the other way round in a right-to-left layout. Compose a `NumberPad.Icon` to replace it. ## Appearance Five emphasis levels and no intent among them — there is no `danger` keypad, and `success` and `warning` are outcomes rather than something you press. `default` is what the pad ships as, where the `Button` ships as `primary`: eleven keys in the accent is a wall of colour, and the digit is what the eye is looking for. | `size` | key height | gap | corner | digit | | ------ | ---------- | --- | ------ | ----- | | `sm` | 60 | 8 | 18 | 24 | | `md` | 72 | 10 | 18 | 30 | | `lg` | 84 | 12 | 24 | 36 | The height is **a control and a half**, derived from `controlHeights` rather than written out, so a theme that raises its controls raises its keypad with them. A key is hit with a thumb rather than pointed at, and it is hit eleven times in a row — a control-height key turns a PIN into an exercise in precision. `xs` is absent for the same reason: 48 points holding a 24-point digit is a control pretending to be a key. The gap is one value for both axes. A grid whose rows sit closer than its columns reads as three separate rows rather than as one pad. **No width anywhere in the recipe.** Every cell is `flex: 1`, so the pad is as wide as it is given and the three columns divide that between them. ## Accessibility Each cell is a `button`, labelled with what it inserts. **Not `keyboardkey`**, which is the role that exists for exactly this and would have a screen reader announce "1, key". React Native maps it to a trait on **iOS only**: on Android it falls through to no role, and the web renderer emits a bare focusable `div` with no role at all. A key that announces as nothing on two platforms out of three is worse than one that announces as a button on all three. It is still only the default, so an iOS-only app passes `accessibilityRole="keyboardkey"` on the cell. The root is `none`. There is no keypad role in React Native, and reaching for `toolbar` or `list` would have a screen reader announce the box as something it is not. ## See also - **`InputOTP`** — the boxes this pad usually types into. - **`NumberField`** — the same value with a keyboard instead of a pad. # NumberStepper A quantity, and the two presses that move it. ## Import ```tsx import { NumberStepper } from '@xaui/native/number-stepper' ``` ## Usage ```tsx ``` ## It is not a `NumberField` without its box A field is typed into and this is not. It has no keyboard, no caret, no parse, no `isInvalid` and no bounds to apply late — a value that can only be pressed into existence is inside its range at every moment. What the two share is the arithmetic, and nothing else. `stepNumber`, `clampNumber` and their rounding live in `utils/number.ts`, so neither component owns them and both read the same answers. | you want | reach for | | ----------------------------------------------------- | ------------------------------------------------ | | a small number, and the presses are the point | `NumberStepper` | | a number long enough that pressing to it is absurd | [`NumberField`](../number-field/number-field.md) | | where it sits in its range, more than its exact value | [`Slider`](../slider/slider.md) | ## The track is a slot, and it is written first It is out of flow and painted behind everything after it, so its place in the JSX is what puts it under the rest rather than over it. `Slider.Track`'s arrangement, and its reason: a ground a caller cannot leave out is a ground a caller cannot replace either. **The inset is the shape.** The buttons are the control's full height and the pill is shorter, so the two circles stand proud of the ground between them. A pill as tall as its buttons is a segmented control, which says "pick one" rather than "more of it". It takes no touches and is hidden from the accessibility tree — a ground is not a control. ## The variant paints the buttons Because they are what a finger is aimed at. The pill under them is the ground they are raised off, and it is the same soft grey at every level that has a pill at all. | `variant` | the buttons | the pill | | ----------- | ------------------------------------- | -------- | | `primary` | the accent, filled | yes | | `secondary` | raised `surface`, hairline border | yes | | `tertiary` | the border alone, no fill and no lift | yes | | `ghost` | neither | no | `secondary` is the default, and it is the one thing here that departs from the vocabulary table: `default` is the token `secondary` usually names, and a `default` button on a `defaultSoft` pill is two greys a shade apart, in which the button stops reading as raised at all. There are no intents. A stepper **reports nothing** — it is a quantity being set, not a verdict — so `success`, `warning` and `danger` are absent, exactly as they are on the `Card` and the `TextField`. ## Each button owns its own press state Two buttons on one control are two targets: pressing the plus must not light the minus. That is why the recipe has no `bgPressed` and the press is the shared `PressableFeedback` treatment instead — the `CloseButton`'s arrangement, for the `CloseButton`'s reason. **A spent button fades its mark and keeps its fill**, and stops taking presses. The mark and not the box, because a button dimmed whole goes translucent — and a translucent button stops hiding the pill it is raised off, so the ground reads straight through the circle. It is also the wrong thing to say: the affordance is gone, the button is still there. Which end is spent is asked of the **result** rather than of the bound: a value half a step short of the ceiling can still reach it, and a button dead at that point strands the reader. With no children each draws its own mark — one bar, or two a quarter turn apart — so a stepper works in a project that has installed no icon set. ## A bin at the floor is `children` and a ternary ```tsx {quantity === 1 ? : undefined} ``` Not a prop of its own. The condition is the caller's — it is a basket row's rule, not a stepper's — and both halves of it are already props this slot has. **A caller's own `onPress` replaces the step** rather than running beside it: removing the row is not also decrementing it. ## The value With no children it is the number written through `formatOptions`, and an **em dash** while there is none — an em dash and not a zero, because a stepper that has never been pressed is holding nothing rather than holding none. A function child is given the number itself, for a unit, a plural, or a word in place of a digit: ```tsx {value => `${value ?? 0} ${value === 1 ? 'convive' : 'convives'}`} ``` It keeps the page's own ink whatever the buttons are painted, because it sits on the pill rather than on a button. ## The number gives way, and never lays over a button `Value` shrinks and then truncates: it is the part of the control that can afford to lose room, because the buttons are touch targets. And the buttons carry a `zIndex` above it, so a number that has run out of room can never end up painted on top of the leading circle — which it otherwise would, since it is written between the two. One line, always. A second line is a row taller than its own buttons. Pass `numberOfLines={0}` if a `Value` of yours really has to wrap. ## Width There is no `fullWidth`. The root hugs its content, and `alignSelf` is a style prop (R14) like any other: ```tsx ``` # Pager Whole pages, one at a time — the onboarding flow, the full-screen feed, the gallery a reader swipes through. ## Import ```tsx import { Pager } from '@xaui/native/pager' ``` ## Usage ```tsx ``` ## A `Pager` has no height of its own Give it one: `flex={1}` to fill a screen, `height={320}` inside a scroll view. `flex: 1` in the recipe would have been the convenient default and it is a trap. React Native expands it to a zero flex-basis, which **overrides an explicit `height`** — so `` would collapse to the height of its dots and the prop the caller reached for would silently do nothing. It is the same reason there is no `fullWidth` on a `Button`: RN's own behaviour is the answer, and the caller says which of the two they want. The track keeps its `flex: 1` and takes whatever the root was given less the dots. Nothing sizes it from outside, which is why it is safe there and not on the root. ## A page is the track, measured Not a prop, and not a fraction of one: a pager's page is the viewport it sits in, on both axes. It is measured once and handed down, because a page given a size in points is a page that is wrong on the next screen. **The measurement is the track's, not the root's.** The indicator sits in the flow under the pages, so the root is taller than the box a page has to fill by exactly the height of the dots — a page sized to the root would overflow it by that much on every screen. Nothing is drawn before the track has been measured. A page at zero size would flash at the start of the run, and every page would be at the same place. ## Where this is not a `Carousel` The two share the paging arithmetic — `indexFromOffset` and `progressFromOffset` in `utils/carousel.ts` are the same functions — and nothing else, because the jobs differ at three points that each change the implementation: | | `Pager` | `Carousel` | | ------------- | ------------------------------------------------ | ------------------------------------------------------------ | | A page's size | the whole track | a **division** of it, by `itemsPerView`, `peek` and the gaps | | The snap | `pagingEnabled` — RN's own whole-viewport paging | `snapToInterval`, one slide plus its gap | | The travel | `scrollTo({ animated: true })` | a hand-run tween on `easeOutCubic` | | The axis | horizontal **or vertical** | horizontal | | The controls | dots | arrows, dots, a counter, thumbnails, autoplay | The travel is the one worth stating plainly. The `Carousel` eases by hand because a slide's move is short enough for the platform's near-linear curve to read as a jump cut. A page's move is a whole viewport, which is the distance the platform's own pagers travel on that same curve — so here it is the right answer rather than the one to work around. ## Both axes `orientation="vertical"` is the full-screen feed; `horizontal`, the default, is the onboarding flow. It is stated as the **axis** rather than a direction, so it needs no RTL branch: a horizontal pager mirrors with its scroll view, which is React Native's own behaviour. The dots follow — a vertical pager's indicator is a column. ## The pages are yours and the dots are the library's `variant`, `color` and `size` reach the dots and nothing else. A pager of screens and a pager of photographs want opposite things inside a page, and the caller is the one who knows which this is. Three variants, none of them an intent — a pager reports nothing, it is a way of arranging what does. They name **the indicator's colour**, and every dot takes it: - `primary` — the accent. The onboarding flow's answer. - `secondary` — the page's own ink, for an indicator that has to read as chrome. - `tertiary` — the raised ground, white on a light theme. `ghost` is absent rather than forgotten. A dot with no fill is not a dot, which is why `InputOTP` has no `ghost` either — a box that is not a box is not a box. A pager over a **photograph** wants `color="#ffffff"` rather than `tertiary`: a photograph is a photograph in both colour modes, and `surface` flips with the theme. ### One colour at two opacities, not two colours The current dot is at full strength and the rest sit at 30%. `DOT_REST_OPACITY` is exported, and the travel between the two is interpolated from the live scroll offset. This is the correction of the component's first shape, and it is worth stating why rather than just what. The dots started as a **pair** of tokens — the current one from the variant, the rest from the neutral `default` fill, which is what `Carousel` does. A pair has to be chosen to contrast with itself, and it cannot be: `tertiary` put `surface` against `default`, which measures `#ffffff` on `#e4e4e7` in light and two near-identical greys in dark. The one variant that exists for a pager over an image was the one whose indicator could not be read, in both colour modes. One colour at two opacities cannot collapse like that — whatever the variant, whichever the colour mode, and for any raw `color` a caller invents. It is also what iOS's own page control does. 30% rather than 50%: the dots are seven points across, and at half strength a small mark reads as the current one seen through something rather than as a mark behind it. A style prop is still not the way to recolour one dot: `` lands after the recipe, so it paints that dot and leaves the opacity travel alone — which is usually not what someone reaching for it meant. A caller who needs something the variants and `color` do not offer composes their own dot against `usePager().offset`, which is what the context publishes it for. ## The dot fades, it does not stretch That is the difference from `Carousel.Dot`, and it is deliberate rather than a simplification. A page control is a fixed row of marks saying how many screens there are and which one you are on, and a mark that grows makes the row's arithmetic move under a reader who is counting it. The carousel's pill is a _progress_ indicator over a series; this is a position among screens. It **follows the drag rather than the settle**: the opacity is interpolated from the live scroll offset on the UI thread, so it travels while the finger is still down. Reading the settled index instead would make it jump once per gesture, after the fact. ## The indicator sits in the flow Under the pages. A caller who wants it over them says so, which is what the style props are for: ```tsx ``` Absolute by default would have made the common case the one that needs undoing. ## Driving it `index` / `defaultIndex` / `onIndexChange`, controlled or not, as everywhere in the library. The index changes as the track **crosses** the halfway point rather than when it stops: `onMomentumScrollEnd` never fires for a wheel or a trackpad, so on the web the index would never move at all, and it lands after the fact, so a "Next" button would sit on the previous page for the length of the deceleration. `usePager()` publishes `goTo`, the settled `index`, the `count` the track counted itself, and the live `offset` as a **shared value** — an indicator of your own that follows the drag frame by frame is `useAnimatedStyle` over it, and it costs no re-render: ```tsx function Skip() { const { goTo, count } = usePager() return } ``` The count comes from the track counting its own children, so it cannot go stale the way a number passed in beside them would. ## Accessibility Each dot is a `button` whose `accessibilityValue` reads "3 of 5" — a dot has no text of its own, and inventing one would be picking a language on behalf of every app that installs this. The root carries no role. The track is the control and it announces itself; a role on the box that holds it would have a screen reader describe a second thing. ## See also - **`Carousel`** — a series of slides with arrows, a counter and thumbnails, where this is whole screens with dots. # PhoneNumberField ## Overview A national phone number beside its country flag and calling code, with a searchable country sheet. Imports from `@xaui/native/phone-number-field`. Run the `phone-number-field` demo screen to try the real component in light and dark mode. `libphonenumber-js` is an **optional** peer of this package, and this component needs it — it supplies the calling codes, the country metadata and the national formatting. It is the only component that imports it, so an app without a phone field never pays for it. ```bash pnpm add libphonenumber-js ``` ## Anatomy ```text PhoneNumberField Label FieldGroup FieldGroup.Prefix → Country Field Description / Error Overlay Content Handle Title Search CountryList Close ``` ## Usage ```tsx import { FieldGroup } from '@xaui/native/field-group' import { PhoneNumberField } from '@xaui/native/phone-number-field' ; Phone number We’ll send a verification code. Choose country ``` Use `value` and `onValueChange` for a controlled field. The value is `{ country: 'FR', nationalNumber: '0612345678' }`; an empty number is an empty string. Changing country preserves the national text. Pasting a complete international number selects its country when allowed by `countries`. Unsupported international pastes leave the current value unchanged. Formatting runs on blur so deletion does not fight inserted spaces. `usePhoneNumberField().phoneNumber` exposes E.164 for a possible number, otherwise `null`; possibility is a length check, not proof of assignment or reachability. `countries` restricts the list; the selected/default country must belong to it. With no initial value, the first allowed country is selected (US when unrestricted). Names and sorting follow `locale`. Searching accepts country name, ISO code or calling code. The sheet uses the existing BottomSheet and requires `react-native-gesture-handler` and a `GestureHandlerRootView`, plus XAUIProvider's portal host. **Country names need `Intl.DisplayNames`, which Hermes does not implement.** The field does not throw without it — every country falls back to its own ISO code, so the list reads `AD`, `CI`, `FR` and sorts that way, and flags, calling codes and search still work. Install `@formatjs/intl-displaynames` and import it before your app's entry point to get localized names on React Native. ## Props Generated from TypeScript by `node tooling/component-docs/generate.mjs phone-number-field`. The root inherits TextField's styles, states, ref and `asChild` behavior. ### PhoneNumberFieldProps Inherited React Native and composed component props remain available. | Prop | Type | Description | | ------------- | -------------------------------------------------- | ----------------------------------------------------------------------- | | value | `PhoneNumberValue \| undefined` | | | defaultValue | `PhoneNumberValue \| undefined` | | | onValueChange | `((value: PhoneNumberValue) => void) \| undefined` | | | locale | `string \| undefined` | Country names and their alphabetical order. | | countries | `readonly CountryCode[] \| undefined` | Restrict the selectable countries. Defaults to all supported countries. | ### PhoneNumberFieldFieldProps Inherited React Native and composed component props remain available. ### PhoneNumberFieldCountryProps Inherited React Native and composed component props remain available. ### PhoneNumberFieldContentProps Inherited React Native and composed component props remain available. ### PhoneNumberFieldSearchProps Inherited React Native and composed component props remain available. ### PhoneNumberFieldCountryListProps Inherited React Native and composed component props remain available. | Prop | Type | Description | | ------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | | renderCountry | `((country: PhoneCountryOption) => ReactNode) \| undefined` | Override a row's contents without replacing its selection behavior. | ## Slots `Field` accepts TextField.Field props except the root-owned value and change handler. `Country` accepts BottomSheet.Trigger props and belongs in FieldGroup.Prefix. `Content`, `Overlay`, `Handle`, `Title` and `Close` reuse BottomSheet. `Search` accepts TextField.Field props with its query owned by the phone root. `CountryList` accepts FlatList props, including `ListEmptyComponent`, and an optional `renderCountry` for row content. Labels, descriptions and errors reuse TextField slots. Each styled node accepts its own style overrides; use `style` on the FlatList. ## Variants The four TextField variants (`primary`, `secondary`, `tertiary`, `ghost`) and sizes (`xs`, `sm`, `md`, `lg`) are inherited, including theme colors, focus and invalid states. ## Accessibility The field is associated with its label and description. Give Country an action label in your app's language; the calling code is exposed as its value. Selected country rows announce their name, calling code and selected state. Label the search field and provide an empty-state message. `isDisabled` prevents both editing and country selection. ## Migration from legacy This is a new component. There is no legacy PhoneNumberField to deprecate. # PieChart The whole, and its parts. ```tsx import { PieChart } from '@xaui/native/pie-chart' 4,5K Appareils ``` The palette, the variants and the ink are the family's — [`chart.md`](../chart/chart.md). A ring is square rather than framed, so it draws its own geometry rather than sitting in the shared plot. ## `innerRadius` is a fraction, not points The hole has to stay in proportion at every size. A caller who writes 40 points gets a donut on a phone and a pie on a tablet. `0` is the pie; the default is the donut. ## The middle is React Native What sits in the hole is `children` in a `View` laid over the canvas — a `Text`, a number, an icon — so it takes the theme's font and its scaling like everything else on the screen. SVG text needs a font file loaded and ignores the platform's text size. It takes no touches, so a press still reaches the slice under it. ## The gap is drawn, not stroked Each slice is shortened by half the gap at either end, so the ring's ground shows through between them. A stroke would sit **on top of** the slice and read as an outline. A slice thinner than the gap keeps a hairline rather than being drawn inside out: a row with a real value should not disappear because it is small. A slice covering the whole circle is drawn as **two half arcs**, because an SVG arc from a point back to itself is a no-op — a single-category pie would otherwise render as nothing at all. ## Its own props | Prop | Type | Default | Notes | | ------------- | ------------ | ------- | -------------------------------------- | | `data` | `Data[]` | — | One row per slice | | `labelKey` | `keyof Data` | — | What names each slice | | `valueKey` | `keyof Data` | — | The quantity | | `innerRadius` | `number` | `0.62` | A fraction of the radius. `0` is a pie | | `gap` | `number` | `1.5` | Degrees between slices | | `children` | `ReactNode` | — | What sits in the hole | A value of zero or below is dropped: a slice of less than nothing has no meaning in a pie, and clamping is what keeps one from eating the ring in the other direction. # Popover A panel anchored to whatever opened it. The component the `Select` was written before: the two share their positioning, their measuring pass and their entrance. ## Import ```tsx import { Popover } from '@xaui/native/popover' ``` ## Anatomy ```tsx … … … … ``` - **`Popover`** — state and resolved style. **It renders no node.** - **`Popover.Trigger`** — what opens the panel, and the rectangle it anchors to. - **`Popover.Overlay`** — the backdrop. Optional, and what closes the panel on a press outside. - **`Popover.Content`** — the panel, positioned against the trigger, in a portal. - **`Popover.Title`** / **`Popover.Description`** — its two texts. - **`Popover.Close`** — anything that closes it. `Popover.Overlay` and `Popover.Content` render into the nearest `PortalHost` rather than where they are written. Their place in the JSX says **when** they exist, not where they appear. ## Usage ### Basic ```tsx Livraison Sous trois jours ouvrés. ``` `asChild` is the normal way to write the trigger. It paints nothing of its own — a popover's trigger is usually a `Button`, an `Icon` or a word in a sentence, and giving it a surface would put a second box around one of those. ### Placement ```tsx ``` `placement` takes all four sides, which is what separates this from `Select.Content`. A select's list is as wide as the field it drops out of, and one hanging off the side of that field reads as a menu; a popover belongs to nothing, so it can sit anywhere. `align` runs along the axis the side does **not** pin — horizontal for a panel above or below, vertical for one beside — and clamps to the screen insets. `avoidCollisions` is on by default and flips to the opposite side, but only when that side has strictly **more** room. A panel two points short stays where it was asked, because a flip on a near miss reads as a glitch. **Both axes are clamped to the insets**, whether or not it flips. The side decides where the panel wants to go; the insets decide where it is allowed to be. A panel beside a trigger with no room for it is pushed inside the screen and may overlap the trigger — which is the right trade: a panel covering the button that opened it is legible, and a panel past the edge of the screen is not. ### No backdrop Omit `Popover.Overlay` and nothing captures the press outside: a `Popover.Close`, or your own state, is what closes the panel. That is the answer for a popover inside a sheet that already dims its background. ### Controlled ```tsx const [isOpen, setOpen] = useState(false) … ``` ### Style as props ```tsx ``` Full RN names, full RN values (R14). Every node takes them. ## Props ### `Popover` | prop | type | default | description | | -------------- | --------------------------- | ------- | ---------------------------- | | `radius` | `RadiusKey` | — | Overrides the panel's corner | | `isOpen` | `boolean` | — | Controlled | | `defaultOpen` | `boolean` | `false` | Uncontrolled | | `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and on close | | `isDisabled` | `boolean` | `false` | Dims the trigger, stops it | There is no `variant`. A popover is the theme's floating surface: it has no emphasis to report and no intent to carry, so a `variant` would name a decision nobody makes. ### `Popover.Content` | prop | type | default | | ----------------- | --------------------------------------- | ------------- | | `placement` | `'top' \| 'bottom' \| 'start' \| 'end'` | `bottom` | | `align` | `'start' \| 'center' \| 'end'` | `center` | | `width` | `number \| 'trigger' \| 'content-fit'` | `content-fit` | | `offset` | `number` | `9` | | `alignOffset` | `number` | `0` | | `avoidCollisions` | `boolean` | `true` | | `insets` | `PopoverInsets` | `12` a side | `width` defaults to `content-fit` where the `Select`'s defaults to `trigger`: matching the width of a word or an icon would give the panel no room at all. **`content-fit` stops at a measure, not at the screen.** Thirteen ems of the body size — about twenty-six characters a line, narrow on purpose. A popover is read at a glance, and a glance is two or three short lines rather than a paragraph; past this it stops being an aside and starts being a sheet with a tail. A sentence always wants more room than it has, so a panel bounded only by the edges is a full-width panel the moment it holds one. `width="full"` is how you say "yes, actually, all of it" — the screen less its insets. Nothing else in the union can: a number is a guess at the screen's width, and `content-fit` refuses on purpose. ### `Popover.Trigger` · `Popover.Close` Everything `Pressable` takes, plus `ViewStyle` as props, plus `asChild`. ### `Popover.Title` · `Popover.Description` Everything `Text` takes, plus `TextStyle` as props. The title is a `header` for a screen reader, so the panel announces as one. ## Motion The panel grows out of the trigger: 200 ms in from `scale: 0.95`, offset eight points **towards** it, so a panel below enters upwards, one above enters downwards, one beside enters sideways. Exit mirrors it at 150 ms — closing is an acknowledgement rather than an arrival, and a dismissal as long as the opening feels like the control is arguing. **The measuring pass.** The panel mounts invisibly for one frame to learn how big it wants to be, then places itself and plays its entrance. Without it `avoidCollisions` has nothing to compare, and a panel that does not fit on the side it was asked for would open off the screen. That frame is why the entrance is keyed on the resolved placement rather than started at mount. ## What it shares with the `Select` | shared | where | | --------------------------------- | -------------------------------- | | the placement arithmetic | `utils/placement.ts` | | the trigger's measurement | `hooks/use-anchor-ref.ts` | | the measuring pass and the origin | `hooks/use-anchored-position.ts` | | the entrance and exit keyframes | `system/anchored/` | Written for the `Select` first, extracted here — §2 bis: at the second use, never by anticipation. `Menu`, `SubMenu` and `Tooltip` read the same four. Two of them exist because of bugs rather than tidiness. The trigger measures again on every open, because `onLayout` never fires on scroll and a trigger inside a `ScrollView` otherwise reports where it used to be. And the position is computed in the **host's** coordinates rather than the window's, because the trigger reports itself against the window while the panel is laid out inside the `PortalHost` — anything the two differ by is exactly the distance the panel would be wrong by. ## Accessibility The trigger is a `button` carrying `expanded`. The overlay announces nothing at all — it is the absence of the panel, and "button" spoken over the whole screen is worse than silence. ## The portal `XAUIProvider` mounts the `PortalHost` these render into, so there is nothing to do. Without a host anywhere, `Portal` renders nothing: the popover opens onto an empty screen with no error. That silence is why the provider mounts one by default. # ProgressBar How far along something is. ## Import ```tsx import { ProgressBar } from '@xaui/native/progress-bar' ``` ## Anatomy ```tsx ``` - **`ProgressBar`** — the root. It clamps the value once, resolves the recipe once (R5) and publishes both to its slots. - **`ProgressBar.Header`** — the line above the rail, its two halves pushed apart. It exists because R4 puts the gap and the alignment on a root rather than on the things being spaced. - **`ProgressBar.Label`** — what is happening. - **`ProgressBar.Value`** — how far along, as text. Formats itself. - **`ProgressBar.Track`** — the rail: the whole of the distance. - **`ProgressBar.Fill`** — the part of it that is behind you. **The fill is a child of the rail, not a layer over it.** It grows to a percentage of the width and the rail clips it, so one `radius` rounds both — an absolutely positioned overlay would need a corner of its own and would get it wrong at 100%. ## There is no `isIndeterminate` An unknown duration is a [`Spinner`](../spinner/spinner.md). That is the split the legacy `Indicator` was two components pretending to be one, and a bar that runs a loop across itself is a spinner drawn as a line. This one reports a quantity, and a quantity it does not have is not a state it should be able to be in. ## Usage ### R3 — the short form ```tsx Téléchargement ``` A text child becomes the label, **and the bar comes with it**: a progress bar with no bar is a line of text. The `Radio`'s rule, for the `Radio`'s reason. Written with no children at all it is the rail alone, which is the form a list row wants: ```tsx ``` ### The range ```tsx ``` `0` to `100` by default. Every argument is a caller's number, so all three are validated once at the root: a value outside the range is clamped, an inverted or empty range reads as empty, and anything that is not finite reads as zero. A fill of `NaN%` is something React Native accepts and draws as nothing at all, with no error anywhere. ### The number beside it ```tsx // 40 % // 1 250,00 € 7 sur 12 // yours ``` `formatOptions` is `Intl.NumberFormat`'s, and **which number it formats follows the style**: a percentage is a share of the range, so it formats the fraction; anything else is about the quantity, so it formats the value. Formatting the fraction as euros would report a 1 250 € goal as `0,63 €`. A Hermes build compiled without ICU has no `Intl` at all; the fallback is the same number with a plain percent sign rather than an exception. The value is in `tabular-nums`, so a number ticking from 9 % to 10 % does not shift the label beside it. ### The motion The fill sweeps to each new width over 240ms. A bar that jumped would render a download reporting every 5% as twenty still frames. ```tsx ``` `false` snaps — for a value the caller is already animating itself. ## Sizes | `size` | Rail | Header type | | ------ | ---- | ----------- | | `sm` | 4 | 14/20 | | `md` | 6 | 16/24 | | `lg` | 8 | 18/28 | **`size` is the rail's thickness, never its width.** A bar's length is its parent's, exactly as a `Button`'s is — which is why there is no `fullWidth` and why the root spans by default. The rail's numbers are off the spacing grid on purpose, like the `Slider`'s: how thin a line can be and still read as a bar has nothing to do with the gaps between things. `radius` is `full` on both the rail and the fill, and it moves both — a squared-off rail holding a rounded fill is the thing the axis exists to prevent. ## Variants and colour | `variant` | Rail | Fill | | ----------- | --------- | ------------ | | `primary` | `default` | `accent` | | `secondary` | `default` | `foreground` | | `success` | `default` | `success` | | `warning` | `default` | `warning` | | `danger` | `default` | `danger` | Five of the ten, and the five that are left out say what this is. `tertiary` and `ghost` are gone because a fill with no fill is not a progress bar; the `*-soft` pairs are gone because the rail already is the soft half of every one of these, and a soft fill on a soft track is one bar you cannot read. The rail is the same neutral under all five: it is the room left to go, and that is not success, warning or danger. `color` is a raw value (R7) and lands on the fill, through the `bgSelected` role — the `Checkbox`'s pair, meaning the same thing on a line instead of in a box. Why a role and not an axis is written up once in [`checkbox.md`](../checkbox/checkbox.md#colour). ## Alignment with the reference implementation **Identical:** the three sizes, the label-and-value header, the clamped range with `minValue` / `maxValue`, `formatOptions` through `Intl`, and Track · Fill as slots. **Three deltas:** | Theirs | Ours | Why | | ------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `color="success"` | `variant="success"` | Their `color` is our `variant`: an enum of intents is the design system's vocabulary, and `color` here is a raw tint (R7). | | `isIndeterminate` | `Spinner` | Two components pretending to be one. A bar with no quantity is a spinner drawn as a line. | | `TrackBackground` under `Track` | `Track` alone | Two nodes for one rail. The rail _is_ the background; the second one existed to be themed separately, which `style` on the slot does. | ## Props ### `ProgressBar` Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | --------------- | -------------------------- | ----------- | ------------------------------------- | | `variant` | `ProgressBarVariant` | `'primary'` | The fill's colour | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The rail's thickness. Never its width | | `radius` | `RadiusKey` | `'full'` | The rail and the fill together | | `color` | `string` | — | A hex tint, on the fill | | `value` | `number` | `0` | Clamped into the range | | `minValue` | `number` | `0` | | | `maxValue` | `number` | `100` | | | `formatOptions` | `Intl.NumberFormatOptions` | percent | What `Value` reads | | `isDisabled` | `boolean` | `false` | Dims it. There is nothing to press | | `asChild` | `boolean` | `false` | Merge into the single child | ### `ProgressBar.Fill` Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus: | Prop | Type | Default | Notes | | ----------- | --------- | ------- | ------------------------------ | | `animation` | `boolean` | `true` | `false` snaps to the new width | ### `ProgressBar.Header`, `.Track` Everything `View` accepts, plus the `ViewStyle` keys as props (R14). ### `ProgressBar.Label`, `.Value` Everything `Text` accepts, plus the `TextStyle` keys as props (R14). `Value`'s children replace the formatted number outright. ## Extending it `useProgressBar()` is exported (R10) and carries the resolved styles plus `fraction`, `value`, `formatOptions` and `isDisabled` — enough to write a second line under the bar, an estimate beside it, or a mark at the point it has to reach, without recomputing a fraction the root already clamped. Outside a `` it throws by name. ## Accessibility - `accessibilityRole="progressbar"` on the root, overridable. - `accessibilityValue` carries the **caller's** range and value — `{ min, max, now }` — not the clamped fraction: "40 sur 100" is what the caller wrote, and rounding it to a percentage here would be answering a question nobody asked. - The label is a `Text` inside the root, so it is read with the value rather than beside it. # ProgressCircle How far along something is, drawn as a ring. ## Import ```tsx import { ProgressCircle } from '@xaui/native/progress-circle' ``` ## Anatomy ```tsx ``` - **`ProgressCircle`** — the root. It clamps the value, resolves the recipe and computes the ring's geometry once, then publishes all three. - **`ProgressCircle.Indicator`** — the ring: the track, and the arc of it that is done. - **`ProgressCircle.Value`** — how far along, written in the middle. Written with no children it is the ring alone, which is the form a row wants. **It is the [`ProgressBar`](../progress-bar/progress-bar.md) bent into a circle** — the same five variants, the same clamped range, the same `formatOptions`, the same 240ms — and it is a second component rather than a `shape` prop on that one because the two share no geometry at all: a bar is a `View` that grows, and this is an SVG path whose dash offset moves. There is no `isIndeterminate` here either. The reason is the bar's, and it is written up [there](../progress-bar/progress-bar.md#there-is-no-isindeterminate). ## `radius` is a number here ```tsx ``` **The one place in this library where `radius` means what it means in geometry**, because a circle has no corner to round. It is a raw number, so it lives outside the style cache and outside the vocabulary (R6): it wins over `size` exactly as a raw `color` wins over a variant's token. Reach for it when the ring has to line up with something already on the screen — an avatar, a row's height. Reach for `size` otherwise: the ladder is the vocabulary. `strokeWidth` is the same kind of prop, and both are clamped. A stroke thicker than the ring is wide would draw a path with a negative radius, which renders nothing, on one platform, with no error. ## Usage ### The number in the middle ```tsx ✓ ``` `Value` is positioned absolutely so it centres on the ring rather than pushing it: the root is a box the size of the circle, and a text node in the flow would make that box taller than the drawing in it. It formats through the root's `formatOptions`, on the same rule as the bar's — the fraction for a percentage, the value for anything else. Children replace it outright. **Not at `sm`.** 32 points across, less two strokes, is narrower than "64 %" is wide. That step is the ring on its own. ### The motion The arc sweeps to each new length over 240ms — the bar's number, because two progress indicators on one screen moving at two speeds is a bug nobody files and everybody sees. It is a **dash offset on one path** rather than a shape rebuilt per value, which is what keeps one rounded cap at each end while it moves, and it is an animated _prop_ rather than an animated style, because `strokeDashoffset` is an SVG attribute. ```tsx ``` The ring starts at twelve o'clock. The turn is on the indicator's wrapper rather than on each path: `Circle`'s own `originX` / `originY` / `rotation` emit an invalid DOM property on web, and one rotation on the box is one thing to read instead of three props on two circles. ## `react-native-svg` An arc with a rounded cap is a stroked path, so `ProgressCircle.Indicator` imports `react-native-svg` — an **optional peer**. The alternative in plain views is two rotated half-discs clipped by a third, which cannot round its own ends. Only that one file imports it, and this component is its own subpath export (`@xaui/native/progress-circle`), so a project that never renders a ring never pays for it. The `Select`'s check and the `Icon`'s chevron are already in the same position. ## Sizes | `size` | Diameter | Stroke | Value type | | ------ | -------- | ------ | ---------- | | `sm` | 32 | 3 | 12/16 | | `md` | 48 | 4 | 14/20 | | `lg` | 64 | 5 | 16/24 | Bigger than the [`Spinner`](../spinner/spinner.md)'s ladder at every step, and that is the difference between the two: a spinner says "wait" and this says how long, which usually means it has a number written in the middle of it. The stroke is centred on the path, so the ring fits its box: the path's radius is half the diameter less half the stroke. ## Variants and colour The `ProgressBar`'s table, unchanged — one neutral ring, and five arcs on it. `color` is a raw value (R7) and lands on the arc through the same `bgSelected` role. The two colours reach the SVG as **strings**, not styles: a path is stroked by a prop rather than by a stylesheet. The recipe still owns them, on two slots it never renders — the same thing the `Tabs` recipe does with its `content` slot — because that is what makes a raw `color` reach the arc through `resolveTint`, which only maps roles a variant declared. ## Alignment with the reference implementation **Identical:** the three sizes, the clamped range, `formatOptions`, the SVG ring with a rounded cap starting at twelve o'clock, and Indicator · Value as slots. **Three deltas:** | Theirs | Ours | Why | | ----------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `size={40}` — the ladder takes a number | `size` is tokens, `radius` is the number | R6: a vocabulary prop takes tokens or it is not a vocabulary. The raw escape hatch gets its own name, like `color` does. | | `strokeWidth` / `trackColor` / `fillColor` on the Indicator | `strokeWidth` on the root, colours from the recipe | R1 — colours are the recipe's, and `variant` plus `color` is the way in. | | `color="success"` | `variant="success"` | Their `color` is our `variant`, as on the bar. | ## Props ### `ProgressCircle` Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | --------------- | -------------------------- | ----------- | --------------------------------------------- | | `variant` | `ProgressCircleVariant` | `'primary'` | The arc's colour | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Diameter, stroke, and the type inside | | `radius` | `number` | — | The ring's radius in points. Wins over `size` | | `strokeWidth` | `number` | — | Clamped to half the diameter | | `color` | `string` | — | A hex tint, on the arc | | `value` | `number` | `0` | Clamped into the range | | `minValue` | `number` | `0` | | | `maxValue` | `number` | `100` | | | `formatOptions` | `Intl.NumberFormatOptions` | percent | What `Value` reads | | `isDisabled` | `boolean` | `false` | Dims it. There is nothing to press | | `asChild` | `boolean` | `false` | Merge into the single child | ### `ProgressCircle.Indicator` Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus: | Prop | Type | Default | Notes | | ----------- | --------- | ------- | ------------------------------- | | `animation` | `boolean` | `true` | `false` snaps to the new length | ### `ProgressCircle.Value` Everything `Text` accepts, plus the `TextStyle` keys as props (R14). Children replace the formatted number. ## Extending it `useProgressCircle()` is exported (R10) and carries the resolved geometry, the two stroke colours, the fraction and the value — enough to draw a second arc, a mark at the target, or an icon in place of the number. Outside a `` it throws by name. ## Accessibility - `accessibilityRole="progressbar"` on the root, overridable. - `accessibilityValue` carries the caller's range and value, not the clamped fraction. - The ring itself is a drawing; what a screen reader reads is that value. # RadarChart Several quantities at once, each on its own axis. ```tsx import { RadarChart } from '@xaui/native/radar-chart' ; ``` The palette, the variants and the ink are the family's — [`chart.md`](../chart/chart.md). ## The data is transposed **A row here is an axis, not a reading along one**, and it is the one thing to get right at the call site. That is what a radar is: several quantities compared across the same set of measures. Reading it the other way round produces a chart with one axis per month, which is a line chart drawn in a circle. ## The edges are always straight The vertices **are** the axes. A curve between two of them would draw a reading on an axis that does not exist, which is why nothing here takes a `curve`. ## `maxValue` is what makes two radars comparable Unset, the web's edge is the highest reading in this chart — so its shape says how the measures compare _within_ it, and nothing at all about any other chart. A dashboard of radars should give all of them the same top. Every axis shares one scale, always. A radar whose axes were scaled apart would be several charts drawn on top of each other. ## Its own props | Prop | Type | Default | Notes | | ------------- | ---------------- | ---------- | -------------------------------------- | | `data` | `Data[]` | — | One row per **axis** | | `axisKey` | `keyof Data` | — | What names each axis | | `yKeys` | `(keyof Data)[]` | — | The series | | `levels` | `number` | `4` | Rings — the chart's only scale | | `maxValue` | `number` | the data's | The top of every axis | | `hasLabels` | `boolean` | `true` | `false` hands the whole box to the web | | `fillOpacity` | `number` | `0.18` | | | `strokeWidth` | `number` | `2` | | | `hasPoints` | `boolean` | `false` | A dot at every vertex | Fewer than three axes draws nothing: two axes is a line, and one is a point. The labels sit just outside the outermost ring, in a fixed box pulled back by half its width — the only placement that works at every angle without measuring the text first. `hasLabels` is what a radar used as a glyph rather than as a figure turns off, and it hands the room back to the web. # RadialChart Several quantities, each as far round its own ring as it has got. ```tsx import { RadialChart } from '@xaui/native/radial-chart' Calories 700 kcal ``` The palette, the variants and the ink are the family's — [`chart.md`](../chart/chart.md). Like the [`PieChart`](../pie-chart/pie-chart.md) and the [`RadarChart`](../radar-chart/radar-chart.md), it is square rather than framed, so it draws its own geometry rather than sitting in the shared plot. ## A ring is not a slice The `PieChart` splits **one** quantity into shares that add up to the whole. This draws **several** quantities that have nothing to do with each other, each against a target of its own. Calories, steps and minutes do not sum to anything, and a donut of the three would be drawing a total nobody measured. That is the whole distinction, and it is the one to get right at the call site. ## The first row is the outermost ring And the palette walks the rows in that order, so the ring that catches the eye first is the row written first. ## Each ring has its own target | what you have | what you write | | ------------------------ | ------------------------------------------- | | a goal per row | `maxKey="target"` | | one goal for all of them | `maxValue={2000}` | | no goal at all | neither — the largest value becomes the top | `maxKey` wins wherever a row carries one, so a chart can mix a per-row goal with a shared fallback. With neither, the biggest ring closes and the rest are read against it. That makes a figure honest about itself and says nothing about any other — the `RadarChart`'s `maxValue` rule, for the same reason: a dashboard of radial charts should give all of them the same top. A reading of zero or below draws no arc: a ring runs from nothing to its target, and a negative value would ask for a dash offset longer than the path. ## The track is the rest of the distance Without it a ring at a fifth is an arc floating in space, with nothing saying how far it had to go — which is the one thing this figure exists to say. `hasTrack={false}` gives it up for a figure that is a glyph rather than a chart. Every track is drawn before every ring, so a ring's rounded cap is never cut by the ground of the one inside it. ## The rings thin rather than disappear The stroke is centred on the path, so a ring drawn at the box's own radius loses its outer half to the canvas edge — and six series at the default thickness ask for more room than a phone-sized figure has. So the geometry is **clamped to the room there actually is**: the gap takes at most half of it, and the thickness takes what is left, divided evenly. What gives is the thickness, because the alternative is a chart that silently drops its innermost rings. Every ring shares one thickness. Rings of different weights read as different charts. ## The middle is React Native The `PieChart`'s arrangement, for the `PieChart`'s reason: what sits in the middle is `children` in a `View` laid over the canvas — a `Text`, a total, an icon — so it takes the theme's font and its scaling like everything else on the screen. It takes no touches. The canvas is turned a quarter turn on its **wrapper** rather than on each circle, which is `ProgressCircle`'s arrangement and its reason: `Circle`'s own `rotation` prop emits an invalid DOM property on web. It also leaves the middle upright, since that is the wrapper's sibling rather than its child. ## Its own props | Prop | Type | Default | Notes | | ----------- | ------------ | ------------------ | -------------------------------------------- | | `data` | `Data[]` | — | One row per ring, the first outermost | | `labelKey` | `keyof Data` | — | What names each ring | | `valueKey` | `keyof Data` | — | The quantity | | `maxKey` | `keyof Data` | — | That row's own target | | `maxValue` | `number` | the largest value | One target for every ring | | `thickness` | `number` | the size's own | Points, raw — it wins over `size` | | `gap` | `number` | half the thickness | Points of bare ground between two rings | | `hasTrack` | `boolean` | `true` | The whole of each ring's distance, behind it | | `children` | `ReactNode` | — | What sits in the middle | ## In a `Chart` The frame carries the words and the legend, and its `seriesCount` is what walks the legend's dots to the same length as the rings: ```tsx Activité du jour ``` # Radio One option out of a set, and the label that says which. ## Import ```tsx import { Radio } from '@xaui/native/radio' ``` ## Anatomy ```tsx ``` - **`Radio`** — the root, and it is the **row, not the circle**. It is the pressable: it owns the selected state, resolves the recipe once (R5) and publishes it to its slots. - **`Radio.Indicator`** — the circle, and the dot inside it. With no children it draws its own dot. - **`Radio.Label`** — what choosing this option means. - **`Radio.Group`** — the set, and the one option in it that is chosen. Optional: a radio over its own `isSelected` works without one. **It is the [`Checkbox`](../checkbox/checkbox.md) in a circle, with one rule changed: a press selects, it never clears.** A set of options has no "none of these" unless one of them says so, and a radio you could tap back to empty would be a checkbox that happens to be round. `onSelectedChange` therefore only ever fires with `true`, and pressing the chosen option fires nothing at all. Everything else is the checkbox's, deliberately: the root is the row so the label chooses the option, R3 wraps a text child into the label _and_ supplies the circle, the three variants and the four sizes are the same tokens, and no slot carries a margin (R4). ## The set ```tsx const [plan, setPlan] = useState('monthly') Tous les mois Tous les ans — deux mois offerts À vie ``` **`Radio.Group` is the context this component was written to read, not a second radio.** An option still owns its circle, its press and its recipe; what it could not know alone is whether it is the chosen one, and that is the whole of what the group publishes. **Exclusivity comes from comparing one value, not from talking to siblings.** The group holds the chosen `value`, each option compares the `value` it stands for, and nothing walks the children — so an option nested in a `Card`, a `List.Item` or a `Fragment` is in the set exactly as much as a direct child is: ```tsx Carte bancaire Virement ``` An option with **no `value`** is not in the set, whatever it is nested in — which is what keeps a standalone radio over its own `isSelected` working unchanged inside one. ### What the set hands down `variant`, `size`, `radius` and `color` are **defaults**, and an option that names its own wins: a set is usually uniform, and the row that differs is a design rather than a mistake. `isDisabled` and `isInvalid` are the two that do not work that way — a disabled set has no enabled option in it, and a set that is wrong is wrong on every row. An option can still be disabled on its own inside an enabled set; it cannot opt back into a disabled one. ```tsx Toutes en lg, violettes et éteintes Sauf le contour, qui est à elle ``` ### Orientation ```tsx … ``` The set lays its options out — that is R4, and the reason `Radio.Group` has a recipe at all. `vertical` is the default; `horizontal` wraps, so three short labels stay one row on a phone and become two on a narrow screen rather than overflowing off it. The gap follows `size`. ### Uncontrolled ```tsx … ``` `value` present means the caller owns the choice; `defaultValue` leaves it with the group. `onValueChange` fires either way, and never with `undefined`: a press selects and never clears, so a set that has a chosen option keeps one. ## Usage ### Controlled, or not ```tsx setPlan('yearly')}> Tous les ans — deux mois offerts Choisie au départ ``` `isSelected` present means the caller owns the value; `defaultSelected` is for the standalone case. Inside a `Radio.Group` neither is needed — the set is the controlled source — and `isSelected` still outranks it, which is what lets one option in a group be driven by something the group knows nothing about. `onPress` fires either way, composed with the selection: it is what a wrapper listens to when it wants the press rather than the change. ### Invalid ```tsx Aucune option choisie ``` The border, the fill and the label turn `danger`, and the resting fill is dropped: an option that is wrong reads as an outline. On a set, put it on the **group** — the error belongs to the question, not to one of its answers. ### Disabled ```tsx Indisponible ``` On the row, not the circle: what is disabled is the control, and the label is part of it. ### A mark of your own ```tsx ``` Children replace the built-in dot and ride the same fade. ### As another element ```tsx … ``` R12 — the caller's element **is** the row, so it takes the children it was written with and the auto-wrap does not apply. ## Sizes | `size` | Circle | Dot | Gap | Label | | ------ | ------ | --- | --- | ----- | | `sm` | 20 | 8 | 8 | 14/20 | | `md` | 24 | 10 | 8 | 16/24 | | `lg` | 28 | 12 | 10 | 18/28 | The same four boxes as the `Checkbox`, so a radio and a checkbox in one form line up. The dot keeps the reference implementation's ratio — 10 in 24 — rather than being tabulated, so it is one dot at four sizes instead of four drawings of one. `radius` is `full` and overridable: a squared-off option in a segmented row is a real design, and every control in this library takes the same prop. ## Variants and colour The `Checkbox`'s three levels, on the same `field*` tokens: | `variant` | Circle at rest | Border | Shadow | | ----------- | ----------------- | ------------- | ------- | | `primary` | `fieldBackground` | `fieldBorder` | `field` | | `secondary` | `default` | `fieldBorder` | — | | `tertiary` | transparent | `fieldBorder` | — | A variant describes the circle **at rest**. Chosen, all three are the accent — or `color`, which lands on the fill through the `bgSelected` role, with the dot on `fgSelected`. The tint is ignored while `isInvalid`, as on the `Checkbox`. Why a role and not an axis is written up once, in [`checkbox.md`](../checkbox/checkbox.md#colour), and it holds here unchanged. ## Alignment with the reference implementation Measured against their `radio.tsx` and `radio.css`. **Identical:** the 24pt circle, the field border and its width, the `field` fill plus shadow on `primary` and the neutral `default` on `secondary`, the accent fill with an `accentForeground` dot at 10/24 of the circle, the danger treatment including the dropped resting fill, and a built-in dot so no icon set is required. **Four deltas:** | Theirs | Ours | Why | | ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | The root is the circle; a label needs `ControlField` | The root is the row, `Label` is a slot | The plan's slots are Indicator · Label, and a label that does not choose the option is a bug waiting to be filed. | | `Radio.IndicatorThumb` is a part | The dot is the indicator's default child | Two nodes to write for one dot. Children replace it, which is the same escape hatch with one component fewer. | | The thumb scales 1.5 → 1 on its own | The fill and the dot arrive together | One `SelectionFill`, shared with the `Checkbox`: what differs between the two controls is the mark, not how it arrives. | | `Radio.IndicatorBackground` (a glass layer) | — | A theme-registered blur layer belongs to their Uniwind theming, which is the premise XAUI does not share. | Their `RadioGroup` is a component of its own with a `RadioGroup.Item` inside it; ours is `Radio.Group` around plain `Radio`s. One import, one option component, and a radio that means the same thing whether or not it is in a set — theirs has two, and the standalone one is the one that cannot be grouped. ## Props ### `Radio` Everything `PressableFeedback` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | ------------------ | ------------------------------- | ------------- | --------------------------------------- | | `variant` | `RadioVariant` | `'secondary'` | The circle at rest | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Circle, dot, gap, label type | | `radius` | `RadiusKey` | `'full'` | A circle, unless you say otherwise | | `color` | `string` | — | A hex tint — the colour once chosen | | `value` | `string` | — | What it stands for in a `Radio.Group` | | `isSelected` | `boolean` | — | Controlled, and it outranks the group | | `defaultSelected` | `boolean` | `false` | Uncontrolled starting value | | `onSelectedChange` | `(isSelected: boolean) => void` | — | Fires with `true` only | | `isInvalid` | `boolean` | `false` | Danger colours, and the tint is ignored | | `isDisabled` | `boolean` | `false` | Dims the row and stops the press | | `animation` | `AnimationProp` | — | The press treatment, as everywhere else | | `asChild` | `boolean` | `false` | Merge into the single child | ### `Radio.Group` Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | --------------- | ---------------------------- | ------------ | ------------------------------------- | | `value` | `string` | — | The chosen option. Controlled | | `defaultValue` | `string` | — | Uncontrolled starting choice | | `onValueChange` | `(value: string) => void` | — | Never fires with `undefined` | | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | `horizontal` wraps | | `variant` | `RadioVariant` | — | Default for every option | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The options' scale, and the gap | | `radius` | `RadiusKey` | — | Default for every option | | `color` | `string` | — | Default for every option | | `isDisabled` | `boolean` | `false` | Every option, and no row opts back in | | `isInvalid` | `boolean` | `false` | Every option — the question is wrong | | `asChild` | `boolean` | `false` | Merge into the single child | ### `Radio.Indicator` Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus: | Prop | Type | Default | Notes | | ----------- | ----------- | ------- | ----------------------------------------------- | | `children` | `ReactNode` | — | Replaces the built-in dot, inside the fill | | `animation` | `boolean` | `true` | `false` shows the dot with no fade and no scale | ### `Radio.Label` Everything `Text` accepts, plus the `TextStyle` keys as props (R14). ## Extending it `useRadio()` is exported (R10) and carries the resolved styles plus `isSelected`, `isDisabled` and `isInvalid` — enough to write a description under the label or a price beside it. Outside a `` it throws by name. `useRadioGroup()` is exported too, and carries the chosen `value`, `select`, and the appearance the set hands down — enough to write an option of your own that is in the set without being a `Radio`. Outside a `` it throws by name. ## Accessibility - `accessibilityRole="radio"` on the root, overridable, with `accessibilityState.checked` following the value and `.disabled` following `isDisabled`. A caller's own `accessibilityState` is **merged**, not spread over. - `aria-invalid` follows `isInvalid`. - `accessibilityRole="radiogroup"` on `Radio.Group`, overridable, so a screen reader announces "2 sur 3" rather than three unrelated controls. A set built without the group needs that role on whatever wraps it. - The whole row is the touch target, which is what makes a 24pt circle reachable. ## Migration from `@xaui/native-legacy` | Legacy | v1 | | ------------------------------ | ------------------------------------------------------------------ | | `label="…"` | `…` — the text child _is_ the label | | `labelAlignment="left"` | write `` before `` | | `labelAlignment="justify-*"` | `justifyContent="space-between"` plus a width, in style props | | `isChecked` / `defaultChecked` | `isSelected` / `defaultSelected` | | `onValueChange` | `onSelectedChange` — and it fires with `true` only | | `themeColor="primary"` | `color={theme.colors.accent}` — a raw value (R7) | | `variant="filled"` | `variant="tertiary"` — a border at rest, the accent once chosen | | `variant="light"` | gone: a dot with no circle. `` with your own mark | | `size="sm" \| "md" \| "lg"` | `size` — the same three | | `radius` | `radius` — a `RadiusKey` now, and still `full` by default | | `fullWidth` | `width="100%"` in style props (R14) | | `isDisabled` | unchanged, on the root | | `labelStyle` | `style` on `` | | `style` | `style` on the root | | `` | `` — same `value` / `defaultValue` / `onValueChange` | The legacy `RadioGroup` also carried `orientation` — unchanged — and the shared `variant` / `size` / `radius` / `themeColor` / `labelAlignment` / `fullWidth` for its options. The first three are handed down as before, `themeColor` becomes `color` with a raw value (R7), and the last two are gone with the props they mirror: `labelAlignment` is JSX order plus a style prop on the row, and `fullWidth` is `width="100%"`. One difference worth reading twice: the legacy group **rendered** its options from an array of items, ours takes them as children. A set of options that comes from data is a `map`, and it is the same `map` the rest of this library asks for. # RangeCalendar A month, and the period chosen in it. ## Import ```tsx import { RangeCalendar } from '@xaui/native/range-calendar' ``` ## Usage ```tsx ``` ## It is a `Calendar` The root is the `Calendar`'s root, unchanged — the same variants, the same `size`, `radius` and `color`, the same month state, the same bounds, the same weekday names. `Header`, `Title`, `PreviousButton`, `NextButton` and `Weekdays` **are** its slots, re-exported rather than wrapped. Only the day cell differs, and only by having a band behind it. That is possible because **`Calendar.Grid` takes a function child**: forty-two cells are generated rather than written, so replacing the cell is the one composition point that component published — and this is what it published it for. The `Calendar` below holds **no chosen day of its own**: a range has two ends and its `value` has room for one, so the cells paint themselves off this component's context instead. ## Three presses, not two | the state | pressing a day | | ---------------- | -------------------------------------------- | | nothing chosen | it becomes the start | | a start, no end | on or after it → it becomes the end | | a start, no end | **before** it → it becomes the new **start** | | both ends chosen | a **new** range starts from it | **A range already chosen starts a new one.** Asking a reader to clear first is asking them to find a control that should not need to exist. **A day before the start becomes the start**, not an end that precedes it. A backwards range is not a range, and silently swapping the two would move a bound the reader did not touch. **A one-day range is allowed.** A one-night stay and a one-day event are real, and a picker that cannot express them is one a caller has to work around. Its cell draws as a single mark with **no band** — a strip under one cell would read as a range somehow wider than the day it contains. Every end is taken to midnight, for `startOfDay`'s reason: a range whose ends carry the moment they were pressed compares unequal to the same two days written by the caller. ## The band Three slots and no more: the cell, the type, the muted day, the today dot and the chosen day are all the `Calendar`'s. A day in a range is one of its cells with a band behind it. The band is **out of flow, and exactly as wide as its cell**. Out of flow so nothing about the day's own layout moves when it appears; exactly as wide because a soft token is translucent — `accentSoft` is the accent at fifteen percent — and a band overhanging into its neighbour is painted twice along the overlap, which reads as a rule down every seam rather than as one unbroken strip. Abutting is safe: Yoga rounds a node's leading and trailing edges to the pixel grid independently, so two adjacent cells share the boundary they meet on. The two ends **stop at the middle of their own cell**, which is where the chosen day's circle is — `start` and `end`, never left and right (R13). A cell is a seventh of the row and the circle in it is a fixed square, so a cap drawn to the cell's edge runs four or five points past the day it belongs to and the period reads as wider than the two days that bound it. From the centre the cap is hidden under the circle and emerges level with its edge, which is also why it carries no radius of its own: the strip's round ends are the two circles. `color` reaches the band through `bgSelected`, the same role the chosen ends take, so a tinted range is tinted throughout. ## See also - **`Calendar`** — one day rather than two. - **`DateRangePicker`** — this month behind a field. # Rating A row of marks, given or shown. ## Import ```tsx import { Rating } from '@xaui/native/rating' ``` ## Usage ```tsx ``` ## One component for the input and the average The fill of each mark is a **fraction**, not a boolean, so a value of 4.3 shows three tenths of the fifth mark. A boolean per mark would have had to round it — and rounding is exactly what an average must not do. `precision` governs **input only**: it is the step a press lands on, and a `value` of any precision displays as it is whatever that says. ## The row is data, not markup `max` says how many marks there are, so the root renders them. Five hand-written marks is a row that disagrees with the prop the moment either changes. `children` is therefore the **glyph** — written once, drawn once per mark: ```tsx ``` ## A mark is the same glyph twice The neutral one sits in the flow and decides how wide the mark is. The filled one is pinned over it inside a clip whose width is the fraction given, so the glyph is cut part-way through rather than swapped for a different one. Which layer an instance is in comes from the **layer**, not from a prop, which is what lets the glyph be written once: `Rating.Icon` reads its own colour from where it was placed. A `tone` prop would have made the caller write the glyph twice and keep the two in step. An empty mark mounts one glyph rather than two — a clip of width zero is a node per mark for every mark not yet given. ### The default mark is `★` in both layers Never a hollow `☆` under a solid one. A solid pair is what every store's rating looks like, and `☆` (U+2606) is not in Android's system face, so the empty half of the row would render as a box on the platform where most of it is read. `★` (U+2605) is present on both. It is a character rather than an SVG, so the component needs no `react-native-svg` for the one glyph it draws itself. ## The press reads where it landed `locationX` over the mark's width is the fraction the finger meant, and it rounds **up** to the nearest `precision` step. That is the only rounding that matches the gesture: a tap anywhere in the first mark is one star rather than zero, and with `precision={0.5}` the left half of the third mark is 2.5 while the right half is 3. Rounding to nearest would make the first sliver of every mark select the one before it — so the leftmost strip of the row would silently rate zero. ## Appearance Three variants, naming which colour a **filled** mark takes: `primary` the accent, `secondary` the neutral foreground, `tertiary` the page's own foreground for a rating on a coloured card. The amber every store uses is not a token, because it is not a role the theme has an opinion about — it is `color="#f59e0b"`, which is what a raw tint is for (R7). `warning` is deliberately not offered in its place: it is a status, and a four-star review is not a warning. The unfilled marks read the **neutral fill** token rather than a faint version of the filled colour. A pale tint of the accent under a row of stars looks like a control that half failed to load; a neutral one looks like a mark that is simply not given yet. It is the same choice the `Carousel`'s inactive dots make, and it is why a raw `color` reaches the filled marks only. | `size` | mark | gap | | ------ | ---- | --- | | `xs` | 14 | 2 | | `sm` | 16 | 2 | | `md` | 20 | 4 | | `lg` | 24 | 4 | **No width in the recipe.** A mark is as wide as the glyph in it, which is what keeps a star and a heart the same row without either being told how wide the other is — and the mark's width is its type size, because a glyph is text and the clip over it has to be measured in the unit the glyph was set in. The two layers take the same size. A fill layer one point off would show a rim of the layer beneath it along every edge. ## What is not here **No label.** A "4,3 out of 5" beside the row is a `Typography` in a `Row` — the screen's, not the rating's (R1). The moment it were a slot it would need an alignment, a format and a language. **No `isClearable`.** Tapping the mark you already chose to go back to nothing is a gesture half the world's rating inputs have and half do not, so it is not a default either way. A caller who wants it holds the value and compares, which is three words and says which behaviour they picked: ```tsx setScore(next === score ? 0 : next)} /> ``` ## Accessibility The marks carry the role, and which role depends on the job: `button` when it is an input, `image` when `isReadOnly` makes it a display — five buttons that do nothing is worse than one picture that says the value, and the read-only row announces `"4.3 out of 5"` on the row itself. The root carries no role. A role there would have a screen reader describe the box that holds the marks as a second thing. A mark carries **no press feedback overlay**: the mark filling _is_ the answer to the press, and a wash over a glyph that is already changing colour reads as two things happening to it. ## See also - **`Slider`** — a continuous value where this is a small number of discrete marks. # Scaffold The app's chrome, painted from the theme: the ground under every screen, the status bar over it, and the options the navigator is dressed with. ## Import ```tsx import { Scaffold } from '@xaui/native/scaffold' ``` ## Usage ```tsx export default function RootLayout() { return ( }}> ) } ``` It has to be **under** `XAUIProvider`, which is what resolves the theme it reads. ## It depends on no navigator `@xaui/native` has no dependency on `expo-router`, on `@react-navigation/native` or on `expo-status-bar` — not a real one, not an optional peer, not a type import. This component does not change that. `Scaffold.Navigator` takes the app's own navigator **as its child** and clones it with five style keys merged into its `screenOptions`. Those keys are React Navigation's spelling, declared structurally here as `ScaffoldScreenOptions`, and the navigator is the thing that knows what to do with them. So a `Stack`, a `Tabs`, a `Drawer`, Expo Router or React Navigation on its own are all dressed by the same component, and the library imports none of them. **Routing stays entirely the app's.** Nothing here writes a route, wraps a screen or touches what the navigator was configured with: `initialRouteName`, the `Screen` children, a nested layout and the navigator's own type all pass through untouched. ## A ladder, not four emphases | variant | header fill | header edge | title ink | | ----------- | ------------ | ----------- | ------------------- | | `primary` | `accent` | — | `accentForeground` | | `secondary` | `surface` | — | `surfaceForeground` | | `tertiary` | `background` | `border` | `foreground` | | `ghost` | `background` | — | `foreground` | It says **how much the header separates from the page**, from a bar you cannot miss down to no bar at all. `ghost` is the default: a header painted in the page's own colour, which is the arrangement almost every app now wants. The page's ground is the theme's `background` under every variant. A scaffold that repainted the page per variant would be a theme rather than a chrome — the variant is about the bar. There is no `success` / `warning` / `danger`: chrome reports nothing. ### The tint follows the variant `color` is a raw value (R7), and it lands where the variant put its tokens, as everywhere else in the library: - `primary` and `secondary` — the **bar**, with contrasted ink derived in OKLab. - `tertiary` — the **title and the hairline**, on the page's own ground. - `ghost` — the **title** alone. That is why the two flat variants name no fill in the recipe. A ghost button paints its label in the tint because it declared no background to take it, and this is the same declaration: `` is a brand title on the page's ground, not a violet page. ## Slots | slot | renders | notes | | -------------------- | --------------------------- | ---------------------------------------- | | `Scaffold.StatusBar` | React Native's `StatusBar` | The ink is the **opposite** of the mode | | `Scaffold.Navigator` | the app's navigator, cloned | Exactly one element, and it stays itself | `Scaffold.StatusBar` takes every `StatusBarProps`, and what you write wins over what was resolved — `translucent` and `hidden` are the app's call, and nothing about them is in the theme. An app that prefers `expo-status-bar` reads `barContent` off `useAppearance` and renders it itself; the one-word spelling is there for exactly that. `Scaffold.Navigator` has **no `asChild`**. The prop distinguishes "render yourself" from "dress my element", and this slot has only the second mode — there is no navigator XAUI could render in place of the app's. Give it anything but a single element and it throws by name. ### The merge gives the app the last word The theme's options go **under** the navigator's own, key by key, and the three style keys blend rather than choose: ```tsx // → headerStyle: { backgroundColor: , height: 96 } ``` That is what `mergeScreenOptions` exists for: `mergeProps` would hand the app's `headerStyle` the whole key, and a navigator asked for a taller header would lose its ground with it. The function form of `screenOptions` is preserved as a function, because React Navigation calls it per route. ## Props | prop | type | default | description | | --------- | ----------------- | ------- | ----------------------------- | | `variant` | `ScaffoldVariant` | `ghost` | How much the header separates | | `color` | `string` | — | The tint (R7) — a raw value | | `asChild` | `boolean` | `false` | Renders the caller's element | Everything else is a style prop (R14) — the ground is a view like any other, so `backgroundColor` is how an app that paints its page from an image or a gradient gets out of the way of the theme's flat ground. ```tsx ``` `asChild` merges the ground into the view the app already has, which is one node instead of two. ## `useScaffold`, for a chrome the slots do not reach ```tsx const { screenOptions, statusBar } = useScaffold() ``` Resolved values, never props to resolve again (R5). It is what an app spreads by hand onto a navigator with keys of its own — a drawer's `drawerStyle`, a tab bar — or hands to an Android navigation-bar module. Outside a ``, `useAppearance` is the same four values with no wiring: this component _is_ `useAppearance` with the wiring done. ## What it deliberately does not do - **No safe-area inset.** The navigator's header already owns the top one, and a scaffold adding its own would fight `react-native-safe-area-context` for the bottom. - **No header of its own.** A header belongs to whichever navigator the app chose, and a status bar belongs to the platform. This hands both what they ask for. - **No colour-mode toggle.** Which control flips the mode, and where it sits, is the app's — write it into the navigator's `headerRight` and the merge lets it through. # SearchField ## Overview A query, with the mark that says so and the cross that takes it back. Imports from `@xaui/native/search-field`. Run the `search-field` demo screen to try the real component in light and dark mode. It **is** a `TextField`: the same root, the same `size`, `radius`, `color`, `labelPlacement`, `isInvalid` and `isDisabled`, and `Label`, `Description` and `Error` are that component's own slots rather than wrappers around them. What differs is the fill — two variants instead of four, neither carrying the `field` shadow — and the two marks. The built-in magnifier is drawn with `react-native-svg`, an **optional** peer of this package. `SearchField.Icon` takes an `as` for a project whose icon set already has one. ## Anatomy ```text SearchField Label FieldGroup FieldGroup.Prefix → Icon Field Clear Description / Error ``` ## Usage ```tsx import { FieldGroup } from '@xaui/native/field-group' import { SearchField } from '@xaui/native/search-field' ; Find products Search by name, category, or SKU ``` **Typing and searching are two callbacks, on purpose.** `onValueChange` fires on every keystroke and on the clear, which is what a list filtered in memory reads; `onSearch` fires on the keyboard's search key, which is what a query costing a request waits for. A single callback for both would make the caller debounce to tell them apart. Use `value` with `onValueChange` for a controlled query, or `defaultValue` and let the field keep its own. `SearchField.Clear` empties the box through the same path a keystroke takes, so a filtered list comes back on its own. It renders nothing while the box is empty — and it is its own `FieldGroup.Suffix`, unlike `Icon`, because a decorator is inset by the field's own padding and an empty one would go on holding room for a cross that is not there. `SearchField.Field` takes everything `TextField.Field` takes except `value`, `defaultValue`, `onChangeText` and `onSubmitEditing`, which are the root's. Its three defaults — `autoCapitalize="none"`, `autoCorrect={false}` and `returnKeyType="search"` — are the caller's to override. ## Props Generated from TypeScript by `node tooling/component-docs/generate.mjs search-field`. The root inherits TextField's styles, states, ref and `asChild` behavior. ### SearchFieldProps Inherited React Native and composed component props remain available. | Prop | Type | Description | | ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | variant | `SearchFieldVariant \| undefined` | The fill. Two, and `primary` is the default. | | value | `string \| undefined` | The query, controlled. Leave it unset and the field keeps its own. | | defaultValue | `string \| undefined` | The query it starts with, uncontrolled. | | onValueChange | `((query: string) => void) \| undefined` | Every keystroke, and the clear — this is what a field filtering as you type reads. | | onSearch | `((query: string) => void) \| undefined` | The search key on the keyboard. It is **not** every keystroke: a query that costs a request is submitted, and one that filters a list in memory is `onValueChange`. | ### SearchFieldFieldProps Inherited React Native and composed component props remain available. ### SearchFieldIconProps Inherited React Native and composed component props remain available. | Prop | Type | Description | | ----- | ------------------------------------------------ | ------------------------------------------------------------------- | | as | `ComponentType \| undefined` | Replaces the built-in magnifier with an icon component of your own. | | size | `number \| undefined` | Overrides the field's own glyph size. | | color | `string \| undefined` | A raw value (R7), never a token. Overrides the placeholder grey. | ### SearchFieldClearProps Inherited React Native and composed component props remain available. ## Slots `Label`, `Description` and `Error` **are** the `TextField`'s slots, re-exported rather than wrapped. `Field` is `TextField.Field` with the query and the search key taken over. `Icon` is `FieldGroup.Icon` with the built-in magnifier as its default mark, so its size and colour follow the field's own without being passed. `Clear` is the shared close button inside its own `FieldGroup.Suffix`: it owns its press state, grows its target with `hitSlop`, and draws its cross from two rotated bars when given no children. Each node accepts its own `style` and style props. ## Variants Two, where the `TextField` has four: | `variant` | Fill | For | | ----------- | ----------------- | ------------------------------------------------------ | | `primary` | `fieldBackground` | A search box alone on a screen. The default. | | `secondary` | `defaultSoft` | A search box on a surface — a sheet, a card, a header. | Neither carries the `field` shadow the `TextField`'s `primary` has: a search box sits alone rather than in a column of siblings, and there the elevation reads as a card that has lost its content. `tertiary` and `ghost` are absent because a field with no fill cannot be told from the text beside it once the placeholder is gone. The edge, the focus colour and the `isInvalid` red are the `TextField`'s, which is handed `tertiary` underneath. `color` is a raw tint (R7) and lands on the fill; the four sizes (`xs`, `sm`, `md`, `lg`) and `radius` are inherited unchanged. ## Accessibility The field carries `accessibilityRole="search"` and is associated with its label and its description, so a screen reader reads both rather than falling back to the placeholder. Give `Clear` an `accessibilityLabel` in your app's language — a cross says "clear" to someone who can see it and nothing at all to someone who cannot; the component warns in development when it is missing. Mark the prefix `isDecorative` so the magnifier hands its touches to the field underneath. `isDisabled` stops the field and the cross together. ## Migration from legacy This is a new component. There is no legacy SearchField to deprecate. # Segment A filter: one of a few options, chosen in place. ## Import ```tsx import { Segment } from '@xaui/native/segment' ``` ## Usage ```tsx Dashboard Analytics ``` ## Anatomy | slot | what it is | | --------------- | -------------------------------------------------- | | `Segment` | The track, and the pill that slides across it | | `Segment.Item` | One option | | `Segment.Label` | The word on it — written for you from a text child | ## It is not `Tabs` They wear the same clothes: a pill sliding under the chosen option inside a filled track, on the theme's own `segment` / `segmentForeground` tokens. They do different jobs. | | `Tabs` | `Segment` | | ----------------- | ------------------------------ | ---------------------------- | | what it is for | places to go | a value something else reads | | what it holds | panels, through `Tabs.Content` | nothing — the value is yours | | what it announces | `tablist` / `tab` | `radiogroup` / `radio` | | the indicator | a slot; `light` has none | drawn by the root, always | A tab bar **wraps content**: its triggers name panels that live under it. A segment names nothing — it holds a value, the way a radio group does, and what reads that value is somewhere else on the screen. Which of the two a control is, is what a screen reader hears, so it cannot be a flag on one component. ## The pill is not a slot `Tabs` makes you write `` because a tab bar can be `light` and have no indicator at all. A segment without its pill is not a segment, so the root draws it and there is nothing to remember. `color` is how you move it — see below. ## Separators, for a list long enough to need dividing `hasSeparator` draws a hairline between the options the pill is nowhere near. It is off by default: the pill already says which option is chosen. **Both edges of the pill stay clear.** A rule running into a raised surface reads as a crack in it, so neither the chosen option's own leading rule nor the one belonging to the option after it is drawn. That is the behaviour iOS has had since the segmented control was introduced, and it is why one does not look like a table. The rule belongs to the option on its **trailing** side, which is what lets an option decide alone: the root has no way to know which of its children is which without reading their props, and reading a child's props is introspection this library does not do. Every option already publishes its rectangle for the pill to slide to, and an ordering is all `hasLeadingSeparator` needs — so before the first layout nothing is drawn, and the rules arrive with the pill rather than a frame ahead of it. ## Props | prop | type | default | description | | --------------- | ------------------------- | ------- | ----------------------------- | | `value` | `string` | — | Controlled | | `defaultValue` | `string` | — | Uncontrolled | | `onValueChange` | `(value: string) => void` | — | Fires on every choice | | `size` | `'sm' \| 'md' \| 'lg'` | `md` | Option padding, gap and type | | `radius` | `RadiusKey` | — | Overrides the track and pill | | `color` | `string` | — | The tint (R7) — a raw value | | `hasSeparator` | `boolean` | `false` | Hairlines between the options | | `isDisabled` | `boolean` | `false` | Dims it, stops every option | | `asChild` | `boolean` | `false` | Renders the caller's element | No `xs`: a segment holds words, and at that height the word gets nothing. ## It hugs its options A segment as wide as the screen with two options in it is a navigation bar pretending to be a filter. A row that has to fill its parent is a `style` away, as everywhere else in this library — there is no `fullWidth` prop. ## The tint reaches the word, not just the pill `color` moves the pill **and** the label on it. `fgSelected` is a role rather than a token named in a state, which is what makes the tint follow into the chosen option's colour — the tint pass re-runs `paint`, not the axes. Without that, a tinted segment would slide a coloured pill under a word that had stopped reading against it. ## The pill slides on the UI thread `useSlidingIndicator`, shared with the `Tabs`. Nothing is drawn before the first layout — an indicator at zero width would flash at the start of the row — and the first placement jumps where every one after it springs, because animating the first reads as the control arranging itself rather than as one at rest. ## Everything else is a style prop `padding`, `gap`, `backgroundColor` — full RN names, full RN values (R14), on the root and on every slot. # Select A field that opens a list. It is the `TextField`'s twin: the same `field*` tokens, the same four levels, the same heights — so the two sit in a form and read as one control. ## Import ```tsx import { Select } from '@xaui/native/select' ``` ## Anatomy ```tsx ``` - **`Select`** — the column, and the state and resolved style behind it. - **`Select.Label`** — what the field is for, above the trigger. - **`Select.Trigger`** — the control. The field the user sees, and the node a `ref` reaches. - **`Select.Value`** — what is chosen, or the placeholder until something is. - **`Select.Indicator`** — the chevron, turning on a spring with the list. - **`Select.Overlay`** — the backdrop. Optional, and what closes the list on an outside press. - **`Select.Content`** — the list, positioned against the trigger and rendered in a portal. - **`Select.GroupLabel`** — a heading over a run of rows, **inside the panel**. - **`Select.Item`** — one row. - **`Select.ItemLabel`** / **`Select.ItemDescription`** — its two lines. - **`Select.ItemIndicator`** — the check on the chosen row. - **`Select.Description`** / **`Select.Error`** — the help line under the field. **The root is the column**, a `View` stacking the label, the trigger and the help line with one `gap` — the `TextField`'s shape and the `Autocomplete`'s, token for token, so a text field and a select on the same form read as one control. `ref`, `style` and R14's style props on the root dress that column; the control itself is `Select.Trigger`, which keeps its own `ref` and its own a11y props. `Select.Overlay` and `Select.Content` render into the nearest `PortalHost` rather than where they are written, so they add nothing to the column. Their place in the JSX says **when** they exist, not where they appear. **`Select.Label` is the field's label, not the panel's.** The heading over a run of rows is `Select.GroupLabel` — the two were one name until the field grew a label of its own. ## Usage ### Basic ```tsx ``` A stringifiable child becomes the row's label (R3), and that same string is what the trigger shows once the row is chosen — the label is written once. ### Label, hint and error ```tsx ``` `isInvalid` paints the trigger's border, turns the label and the description `danger`, and **mounts nothing**: `Select.Error` renders exactly what it is given, so the condition stays where you can read it. The label and the description carry the ids the trigger points at with `aria-labelledby` / `aria-describedby`. ### Composed rows ```tsx Français La langue par défaut ``` `label` is what the trigger shows. It is needed as soon as the children are not one string: an avatar and two lines of text have no single label to infer. The labels are read off the **elements**, before any row mounts. That matters because the list lives in a portal that only exists while it is open — without it, a select with a `defaultValue` would show its placeholder until the user had opened it once. A row wrapped in a component of your own is not reachable that way, and `children` on `Select.Value` is the answer for that case. ### Controlled ```tsx const [value, setValue] = useState('fr') ``` Open state is controllable the same way, with `isOpen` / `onOpenChange`. Which half is controlled is decided on the first render and then held — a component that changes hands mid-life produces a bug nobody can read from the call site. ### Placement ```tsx ``` `width` defaults to `'trigger'`, because a list wider than the control it drops out of reads as a different surface. `'content-fit'` hugs the longest label instead, bounded by the screen insets. `avoidCollisions` is on by default: the list flips to the other side when the one it was asked for does not fit. It flips only when the other side has strictly **more** room, so a list two points short stays where it was put rather than jumping. ### No backdrop ```tsx ``` Omit `Select.Overlay` and only a choice closes the list. That is the answer for a select inside a sheet that already dims its own background. ### Style as props ```tsx ``` Full RN names, full RN values (R14). Every node takes them — the trigger, the panel, the overlay, each row and each of its texts. ### Everything else goes through `style` A gradient panel, a tinted shadow, a transform — `style` is the last word and wins over everything above it. ## Props ### `Select` | prop | type | default | description | | --------------- | --------------------------- | --------- | --------------------------------------- | | `variant` | `SelectVariant` | `primary` | The field's four emphasis levels | | `size` | `'sm' \| 'md' \| 'lg'` | `md` | The **control's** scale, not the list's | | `radius` | `RadiusKey` | — | Overrides the field radius | | `color` | `string` | — | The tint (R7) — a raw value | | `value` | `string` | — | Controlled selection | | `defaultValue` | `string` | — | Uncontrolled selection | | `onValueChange` | `(value: string) => void` | — | Fires on every choice | | `isOpen` | `boolean` | — | Controlled open state | | `defaultOpen` | `boolean` | `false` | Uncontrolled open state | | `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and on close | | `isDisabled` | `boolean` | `false` | Dims the column, stops the press | | `isInvalid` | `boolean` | `false` | Border, label and hint to `danger` | | `asChild` | `boolean` | `false` | The caller's element is the column | Plus everything `View` takes and `ViewStyle` as props — they dress the **column**. ### `Select.Trigger` Everything `Pressable` takes, plus `ViewStyle` as props. `asChild` renders the caller's element as the control. ### `Select.Value` `placeholder`, plus everything `Text` takes and `TextStyle` as props. Single-line by default: a value long enough to wrap would grow a control whose height is fixed. ### `Select.Indicator` `as`, `size`, `color`. Defaults to the chevron this component ships. ### `Select.Overlay` `isDismissable` (default `true`), plus `ViewProps` and `ViewStyle` as props. It paints nothing until a `backgroundColor` says so. ### `Select.Content` | prop | type | default | | ----------------- | -------------------------------------- | ----------- | | `placement` | `'top' \| 'bottom'` | `bottom` | | `align` | `'start' \| 'center' \| 'end'` | `center` | | `width` | `number \| 'trigger' \| 'content-fit'` | `trigger` | | `minWidth` | `number` | — | | `offset` | `number` | `8` | | `alignOffset` | `number` | `0` | | `avoidCollisions` | `boolean` | `true` | | `insets` | `SelectInsets` | `12` a side | ### `Select.Item` `value` (required), `label`, `isDisabled`, `asChild`. `children` may be a function taking `{ isSelected, isPressed, isDisabled }`, which is the escape hatch for a row that paints its own selected state instead of showing the check. ### `Select.Label` · `Select.Description` · `Select.Error` Everything `Text` takes, plus `TextStyle` as props. They are the `TextField`'s three, token for token. `Select.Label` and `Select.Description` carry the ids the trigger points at; `Select.Error` carries none, because it is the description's replacement rather than a second thing to announce. ### `Select.GroupLabel` · `Select.ItemLabel` · `Select.ItemDescription` Everything `Text` takes, plus `TextStyle` as props. The item label truncates; the item description wraps, because the thing it exists to carry is the sentence a label was too short for. ### `Select.ItemIndicator` `as`, `size`, `color`. Its 20-point box renders on every row and only the glyph comes and goes, so choosing never shifts a label. ## Motion Three animations, the reference implementation's values throughout. **The chevron** turns 0 → −180° on a spring: damping 140, stiffness 1000, mass 4. Heavily damped against a very high stiffness, so it arrives in about a fifth of a second and does not overshoot — an oscillating chevron reads as a bug rather than as motion. It is a worklet, so it keeps turning while JavaScript mounts the rows. **The panel** grows out of the trigger: 200 ms in from `scale: 0.95` and eight points offset **towards** the trigger, so a list below enters upwards and one above enters downwards. Exit mirrors it at 150 ms — closing is an acknowledgement, not an arrival, and a dismissal as long as the opening feels like the control is arguing. **The overlay** fades on the same two durations. **The measuring pass.** The panel mounts invisibly for one frame to learn how tall it wants to be, then places itself and plays its entrance. Without it `avoidCollisions` has nothing to compare, and a list that does not fit below would open downwards off the screen. ## Accessibility The trigger is a `button` carrying `expanded`, so a screen reader announces whether the list is open. Rows are `menuitem` and carry `selected`. `Select.GroupLabel` is a `header`, so the group it opens is announced with it. The trigger points at `Select.Label` and `Select.Description` with `aria-labelledby` / `aria-describedby`, so a screen reader announces what the field is for rather than reading the placeholder and hoping. The overlay announces nothing at all — it is the absence of the list, and "button" spoken over the whole screen is worse than silence. ## The portal `Select.Overlay` and `Select.Content` need a `PortalHost`. `XAUIProvider` mounts one, so there is nothing to do. An app that needs the host elsewhere — under a gesture root, or inside its own navigation container — sets `hasPortalHost={false}` on the provider and mounts a `PortalHost` itself. Without a host anywhere, `Portal` renders nothing: the select opens onto an empty screen with no error. That silence is why the provider mounts one by default. ## Migration from `@xaui/native-legacy` The legacy component is `Select`, with `SelectItem`. | Legacy | v1 | | ---------------------------- | ---------------------------------------------- | | `` | `` with its own slots | | `label="…"` on the root | `` | | `description="…"` | `` | | `errorMessage="…"` | `` | | `placeholder="…"` | `placeholder` on `` | | `variant="colored"` | `variant="primary"` | | `variant="light"` | `variant="secondary"` | | `variant="bordered"` | `variant="tertiary"` | | `themeColor="primary"` | `color={theme.colors.accent}` | | `selectionMode="multiple"` | not in v1 — one value, one control | | `customAppearance={{ … }}` | `style` on the slot that key named | **`selectionMode` is gone.** A select that returns several values is a different control with a different affordance, and calling both by one name is what made the legacy props list as long as it is. It comes back as its own component or not at all. # Skeleton The shape of what has not arrived yet. ## Import ```tsx import { Skeleton } from '@xaui/native/skeleton' ``` ## Anatomy ```tsx ``` **One node and no slots.** A placeholder is a rectangle; there is nothing inside it to name. A paragraph of them is three of these in a `Column` — composition doing what a `lines={3}` prop would otherwise hard-code, including the last line being shorter, which is the only reason the paragraph reads as a paragraph. ## Usage A block, sized by the caller: ```tsx ``` A paragraph: ```tsx ``` As a gate, which is what `isLoading` is for: ```tsx {user?.name} ``` A row of one, mirroring the layout it stands in for: ```tsx ``` ## Props | Prop | Type | Default | | ----------- | ------------------------------ | ------- | | `radius` | `RadiusKey` | `'md'` | | `color` | `string` — a raw hex tint (R7) | — | | `isLoading` | `boolean` | `true` | | `animation` | `boolean` | `true` | | `children` | `ReactNode` | — | | `style` | `StyleProp` | — | Plus every `ViewStyle` key as a prop (R14) and every `View` prop. ### There is no `size`, and that is the design Only the caller knows the shape of the thing that is missing, so R14's `width` and `height` are the whole sizing API — full React Native names and values, `width="60%"` as readily as `width={140}`. A `size` token here would be a scale of rectangles nobody's content happens to be. ### No `variant` It had two — the neutral fill and that fill at half — sold as the two backgrounds a placeholder is drawn on. Measured against every surface, in both modes, the second is **less** visible than the first everywhere: | surface | `default` | the old `secondary` | | ---------------------------- | --------- | ------------------- | | the page (light) | **1.216** | 1.100 | | a `default` `Card` (light) | **1.269** | 1.119 | | a `secondary` `Card` (light) | **1.075** | 1.036 | | the page (dark) | **1.336** | 1.123 | | a `default` `Card` (dark) | **1.189** | 1.090 | | a `secondary` `Card` (dark) | **1.000** | **1.000** | So it was never the answer to "this block reads as a hole" — the full fill is the _more_ visible of the two on the surface that claim named. And on a `secondary` `Card` in dark mode both resolve to that surface's own `#27272a` and vanish, which is the case the pair was supposed to cover. A skeleton has to contrast with whatever sits under it, and a fixed token cannot know what that is. Two frozen values were never going to cover three surfaces times two modes. The block paints `default`, and `color` is the way past it — honest about being a raw value rather than a name that promises a system. ### `radius="full"` is the avatar placeholder It is a prop rather than the caller's own `borderRadius` because a circle is a shape the vocabulary names, and it has to survive a theme redrawing every corner in the library. ### No `asChild` R12 asks for one on every root, and this is the second of the two places in the library it is deliberately absent. `children` is already taken here, and it means the opposite of what `asChild` would need: the content the block stands in for, rendered once `isLoading` is `false`. `asChild` hands `Slot` an element to merge the block's styles into. One `children` with two meanings, disambiguated by a second prop, is the kind of API this library exists not to ship. Styling is the escape hatch instead: every `ViewStyle` key is a prop (R14). ## Animation One pulse: the block breathes between full opacity and a half, a second each way, eased in and out. `animation={false}` freezes it at full and mounts no worklet — the branch renders a plain `View`, so a long list frozen for a screenshot costs nothing. **No shimmer**, where the reference implementation's default is one. A shimmer is a gradient sweeping across the block; a gradient needs `react-native-svg`, and that is an optional peer a component in the fifteen-component core cannot require. One animation, so there is nothing for a name to choose between — `animation` is a boolean rather than the reference implementation's `'shimmer' | 'pulse' | 'none'`. ## Accessibility `accessibilityElementsHidden` and `importantForAccessibility="no-hide-descendants"` are set. A block stands in for content that is not there; announcing its absence is `busy` on whatever region is loading, not a stop on each rectangle. Both stay overridable (R9). ## Migration | Legacy | v1 | | ------------------------------------- | --------------------------------------------------- | | `` | `` | | `` | `` | | `` | three `` in a `` | | `` | `` | # SlideButton A button you drag rather than tap — slide the thumb to the end and it fires once. It is the confirmation for an action that must not happen by accident. > **Not part of the 1.0 core.** The fifteen are listed in the plan and this is not one of > them; it ships under `1.x` as a P5 component. ## Import ```tsx import { SlideButton } from '@xaui/native/slide-button' ``` ## Anatomy ```tsx Slide to confirm ``` | Slot | What it is | | ------------------- | ---------------------------------------------------------------------------------------------- | | `SlideButton` | The pill. Owns the geometry, the gesture's shared offset and the confirmed state. | | `SlideButton.Fill` | The trail behind the thumb. Optional — compose it for the affordance, omit it for a bare pill. | | `SlideButton.Label` | The instruction, centred across the whole pill. The thumb slides over it. | | `SlideButton.Thumb` | The handle the finger drags. Draws the built-in chevron, or renders whatever you put in it. | | `SlideButton.Icon` | A mark in the thumb, `system/`'s `Icon` reading the handle's glyph size and colour. | **A bare string is the whole component.** `Slide` composes the fill, the label and the thumb for you. Write the slots out only to drop the fill or to put a mark in the thumb. ## Usage ### The one-shot ```tsx Slide to submit ``` `onConfirm` fires once, when the thumb reaches the threshold. Uncontrolled, the thumb then stays at the end — a slide is a one-shot, and springing back would read as the confirm not having taken. ### Driven, and re-armed ```tsx const [confirmed, setConfirmed] = useState(false) setConfirmed(true)}> {confirmed ? 'Sent' : 'Slide to send'} ``` Pass `isConfirmed` and the thumb follows it: to the end when it is set, home when it is cleared. Setting it back to `false` re-arms the control. ### The threshold ```tsx Slide all the way ``` How far along the track the thumb has to reach for the slide to count, from `0` to `1`. Below it, the thumb springs home on release. The default is `0.9` — far enough to be deliberate, short enough to forgive the last few points. ### A mark in the thumb ```tsx Slide to pay ``` Anything inside `SlideButton.Thumb` replaces the built-in chevron. `SlideButton.Icon` inherits the handle's glyph size and — because the handle is the surface colour whatever the pill does — the theme's foreground. ### Style as props ```tsx SLIDE ``` Every node carries R14 — full RN names, full RN values, scoped to the node they are written on. ## Sizes | `size` | Height | Handle | Label | | ------ | ------------------- | ------------------------------- | ----- | | `sm` | `controlHeights.sm` | (height − 6) tall, 1.5× as wide | `sm` | | `md` | `controlHeights.md` | (height − 8) tall, 1.5× as wide | `md` | | `lg` | `controlHeights.lg` | (height − 8) tall, 1.5× as wide | `lg` | `size` drives the pill's height, the handle and the label's type — **never a width**. Without an `alignSelf`, the pill fills its column, which is RN's own behaviour and the `Button`'s. The label stays centred across the whole pill at every size. The handle is a **horizontal stadium**, wider than it is tall: a slide-to-confirm handle is pushed sideways, so it reads as a thing you shove rather than a knob you turn. It is lifted off the pill by `shadows.surface`, so the chevron on it never has to fight the pill for contrast — and **`isDisabled` puts that lift down** rather than dimming it with everything else. Opacity fades the pill until its edges are most of the way to the page, and a shadow at half strength is then the strongest edge left, sitting a point below a handle whose own outline has gone: it reads as a handle pressed into a dent rather than as a faded control. Nothing disabled should look lifted anyway. For the same reason the pill itself never sets `overflow: 'hidden'`. On iOS that clips the layer's own shadow, and the handle's lower half reaches the pill's edge exactly. The trail is cut to the pill's shape by a window one layer in, so the cut lands on the trail alone. `radius` overrides the pill's corner, which is `full` by default. ## Variants and colour The ten flat variants, the same vocabulary every control in the library takes — a slide-to-delete is a real `danger` use, so the intents stay. | `variant` | Pill at rest | Trail | Label at rest | Label swept | | -------------------------------- | ------------ | -------- | ---------------------- | ------------------ | | `primary` | `accentSoft` | `accent` | `accentSoftForeground` | `accentForeground` | | `success` / `warning` / `danger` | `Soft` | `` | `SoftForeground` | `Foreground` | | `secondary` | `default` | wash | `defaultForeground` | same | | `tertiary` | — (`border`) | wash | `foreground` | same | | `ghost` | — | wash | `foreground` | same | | `*-soft` | `Soft` | wash | `` | same | `secondary` is the default — the neutral grey pill. **The four intents rest soft and earn their colour.** A `Button` is its intent the moment it is on screen, because a tap is the whole interaction. A slide is not: the pill sits under the thumb saying _not yet_, so `primary`, `success`, `warning` and `danger` open on their soft slice and name the vivid one as `bgSelected` — the role the recipe engine already has for "the box once it is on". The trail lays it down as the handle sweeps, so the colour arrives with the commitment rather than before it. **The label is drawn twice**, and that is what keeps it readable. No single text colour survives a pill going from its soft slice to the vivid one: painted for the soft pill it reads about 1.3:1 against the trail, painted for the trail it is invisible at rest. So the second copy takes `fgSelected` and is clipped to exactly the trail's width — a word the trail is halfway through is dark on the half that is still soft and light on the half that is not. Measured against the default theme, every variant clears AA in both modes: 4.9–6.5 at rest and 4.6–5.0 swept in light, 7.5–10.4 and 6.4–10.2 in dark. Where a variant names no `fgSelected` both copies resolve to the same colour and the second one changes nothing. `color` is a raw value (R7). It lands where the variant's tokens do: the pill for the filled ones, the label for `ghost`, the label and border for `tertiary` — and, because `resolveTint` maps every declared role, into the trail on the four that name one. **It never reaches the handle** — the handle stays the surface colour so the chevron on it is readable whatever the pill is doing. **The variants that name no vivid slice sweep a wash of their own foreground** instead, at low opacity — the same colour the label uses, so it reads against every pill without a token of its own. Either way the trail's width is the swept **fraction** of the travel laid over the whole pill — nothing at rest, and full at the end. It is not the handle's raw offset: the handle stops a handle's width short of the trailing cap, because that is where it physically is, and a trail that stopped with it would leave a confirmed slide showing unswept pill. How far the slide has come, not the value. ## How it is put together It is the `Slider`'s gesture without the value. `SlideButton.Thumb` runs a `Gesture.Pan` on `react-native-gesture-handler` — the same **optional** peer the `Slider` reaches for, imported by these two components and nowhere else. The pill measures its own width on layout, and the travel is inset by the thumb and its margin at each end, exactly as the `Slider`'s rail insets the knob. Everything the finger does stays on the UI thread: the thumb's offset is a shared value the pan writes and the fill and the handle both read. The single hop to JS is `scheduleOnRN` on release, once, when the slide has passed the threshold — the confirm is React state and a callback, and neither belongs on a worklet. There is **no `min`, `max` or `step`**, because the only positions that mean anything are "not yet" and "done". ## Accessibility - `accessibilityRole="button"`, overridable. - `accessibilityState` carries `disabled`; a caller's own keys are merged over it. - **A drag is not available to a screen reader.** The control exposes an `activate` action — the way in for VoiceOver and TalkBack — as an `accessibilityAction` rather than an `onPress`, so it stays not-tappable-to-confirm for everyone else, which is the point of it. Give it an `accessibilityLabel` that says what the slide does. - Under RTL the thumb travels toward the leading edge and the built-in chevron flips to point that way on its own — its lit borders are `top` and `end`. ## Props Everything a `View` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | ------------------ | ---------------------- | ------------- | ------------------------------------------------------- | | `variant` | `SlideButtonVariant` | `'secondary'` | The ten flat values | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Height, thumb and label — never a width | | `radius` | `RadiusKey` | `'full'` | The pill's corner | | `color` | `string` | — | A raw tint — the pill, or the label; never the thumb | | `threshold` | `number` | `0.9` | How far to slide, from 0 to 1 | | `isConfirmed` | `boolean` | — | Controlled — pins the thumb at the end, or springs home | | `defaultConfirmed` | `boolean` | `false` | The starting state when uncontrolled | | `onConfirm` | `() => void` | — | Fires once, when the thumb reaches `threshold` | | `isDisabled` | `boolean` | `false` | Dims it, drops the handle's shadow, stops the drag | # Slider A value chosen along a line. ## Import ```tsx import { Slider } from '@xaui/native/slider' ``` `react-native-gesture-handler` is an **optional** peer of this package, and this is the component that needs it. It is imported here and nowhere else, so only an app that reaches for `@xaui/native/slider` pays for it. ## Anatomy ```tsx ``` - **`Slider`** — the value, the range, and the styles the slots read. - **`Slider.Output`** — the value in words. Optional. - **`Slider.Track`** — the line, and the only node that knows how long it is. - **`Slider.Fill`** — the part behind the thumb. - **`Slider.Thumb`** — what the finger holds. ## Usage ### Basic ```tsx ``` ### Two callbacks, and the difference matters `onValueChange` fires on every step the thumb crosses, **including mid-drag**. It is what a live preview reads. `onValueCommit` fires **once**, when the finger lifts. It is where a network call belongs, because the first one can fire fifty times in a second. ### Steps ```tsx ``` Eleven stops at 5, 15, 25 … The snap counts steps **from the minimum** rather than rounding the value, so that range stops where it says it does and not at 10, 20, 30. `step={0}` is continuous. ### Formatting the output ```tsx {value => `${value} %`} ``` `children` may be a function of the value. A `format` prop would have been the same thing with less room in it. ### Style as props ```tsx ``` Full RN names, full RN values (R14). Every node takes them. ## Props ### `Slider` | prop | type | default | description | | --------------- | ------------------------- | ------- | ---------------------------------- | | `size` | `'sm' \| 'md' \| 'lg'` | `md` | Track thickness, thumb width, type | | `radius` | `RadiusKey` | — | Overrides every corner | | `color` | `string` | — | The tint (R7) — a raw value | | `value` | `number` | — | Controlled | | `defaultValue` | `number` | `0` | Uncontrolled | | `onValueChange` | `(value: number) => void` | — | Every step, mid-drag included | | `onValueCommit` | `(value: number) => void` | — | Once, when the finger lifts | | `min` | `number` | `0` | | | `max` | `number` | `100` | | | `step` | `number` | `1` | `0` is continuous | | `isDisabled` | `boolean` | `false` | | No `variant`, and no `xs`. A slider reports a quantity rather than an intent — a `danger` volume control would be colouring a number — and a track four points tall is a line, not a control. ### `Slider.Thumb` `accessibilityValueText` turns the raw number into what a screen reader reads. ## How it is put together **The travel is inset by half a thumb at each end.** Otherwise the thumb hangs over the track's ends at the minimum and the maximum, and the fill runs out from under it. The fill runs to the thumb's **centre** rather than to the raw proportion, for the same reason. **A press anywhere on the track moves the thumb there.** It is the half of a slider people forget: dragging a narrow thumb is a fine gesture on a mouse and a poor one on a finger. **The thumb grows 15% under the press rather than moving.** The finger is already covering it, so the scale is what you see in the gap around it — and it is the only confirmation a slider can give that the drag has started. Stiff and well damped: a confirmation should arrive, not wobble. **The value crosses to the JS thread and the scale does not.** The pan computes the new position on the UI thread and hands the value back over `scheduleOnRN`, which is the one hop that has to happen — the value is React state. The scale stays where it is. ## Not here yet **A range slider**, with two thumbs. The context already carries what a second thumb would need, and `useSlider` is exported for anyone who wants to write one — but two thumbs is two values, a different type for `value`, and rules about which one gives way. Worth its own change. **A vertical slider.** The same arithmetic on the other axis, but also a different gesture and a different layout. ## Accessibility The thumb is `adjustable`, carrying `min`, `max` and `now`, and it answers the platform's increment and decrement actions by one `step` — without that the platform guesses one percent of the range, which on a slider of five stops moves nothing. ## Migration from `@xaui/native-legacy` | Legacy | v1 | | -------------------------- | ---------------------------------- | | `value` / `onValueChange` | unchanged | | `onSlidingComplete` | `onValueCommit` | | `minimumValue` | `min` | | `maximumValue` | `max` | | `themeColor="primary"` | `color={theme.colors.accent}` | | `customAppearance={{ … }}` | `style` on the slot that key named | # Snackbar A temporary, controlled message inspired by the legacy Snackbar rather than by the v1 Toast queue. Several messages share a vertical anchor through `Snackbar.Stack`, so they never overlap. ## Import ```tsx import { Snackbar } from '@xaui/native/snackbar' ``` ## Usage ```tsx Enregistré. Annuler ``` Several independently controlled messages go in one stack: ```tsx Enregistré. Connexion interrompue. ``` ## Anatomy - `Snackbar` owns visibility and its timer, plus placement and portal when used alone. - `Snackbar.Stack` owns one placement and portal for several snackbars, then lays them out vertically without overlap. - `Snackbar.Message` is the announcement. - `Snackbar.Actions` groups trailing controls. - `Snackbar.Action` runs an action and dismisses by default. - `Snackbar.ActionLabel` is the action text; raw action text is wrapped in it. - `Snackbar.Close` only dismisses. `duration={0}` keeps it visible. `position`, `insetHorizontal`, `insetVertical`, `maxWidth` and `isPortalled` correspond to the legacy layout controls. The legacy `message`, `actionLabel`, `showCloseAffordance` and `customAppearance` configuration props become explicit slots and each slot accepts its own style props. Inside `Snackbar.Stack`, each Snackbar keeps its own visibility, timer, content and actions; the stack takes over placement, width and portalling. Its `position`, `spacing`, `insetHorizontal`, `insetVertical`, `maxWidth` and `isPortalled` props control the shared anchor. The last child stays closest to the selected edge: a bottom stack grows upward and a top stack grows downward. The root has the `alert` role and a polite live region. Entrance and exit run through Reanimated. # Spinner The wait, drawn. A circle whose arc turns until whatever is loading has loaded. ## Import ```tsx import { Spinner } from '@xaui/native/spinner' ``` ## Anatomy ```tsx ``` **Two rings and no slots.** The root is the track — the full circle in the variant's ink, at a fraction of its opacity — and its one child is the arc that turns over it: the same circle with a quarter missing. The two are one figure rather than two parts, so there is nothing between them for a slot to name. ## Usage ```tsx ``` Beside a label, in a row: ```tsx Synchronisation… ``` Frozen, for a screenshot or a test: ```tsx ``` ## Props | Prop | Type | Default | | ----------- | ------------------------------------------------------------------------------------------- | ----------- | | `variant` | `'primary' \| 'secondary' \| 'default' \| 'tertiary' \| 'success' \| 'warning' \| 'danger'` | `'primary'` | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | | `color` | `string` — a raw hex tint (R7) | — | | `animation` | `boolean` | `true` | | `style` | `StyleProp` | — | Plus every `ViewStyle` key as a prop (R14) and every `View` prop. ### `variant` names an ink A spinner has no fill, so a variant here names **the colour of the thing itself** rather than a colour that reads on a surface. That is why `primary` is `accent` and not `accentForeground` — the value it resolves to on a `Chip`, where there _is_ a fill. | `variant` | ink | | ----------- | ---------------------- | | `primary` | `accent` | | `secondary` | `accentSoftForeground` | | `default` | `foreground` | | `tertiary` | `muted` | | `success` | `success` | | `warning` | `warning` | | `danger` | `danger` | **No `ghost`**: a spinner with no ink is not a spinner. **No `-soft` slices**: a soft slice is a fill softened, and there is no fill here. ### `size` is the diameter Sixteen, twenty, twenty-four and thirty-two points — the reference implementation's three steps plus the one our ladder adds between the first two. The stroke thickens once, at `lg`, because a 2pt ring on a 32pt circle reads as a hairline and a 3pt ring on a 16pt one reads as a doughnut. A spinner is a circle, so `size` is the only measurement it has. R14's `width` is available and deliberately not what `size` means — a spinner that filled its parent would be a progress bar. ## Accessibility `accessibilityRole="progressbar"` and `accessibilityState={{ busy }}` are set, and both stay overridable (R9). There is no default `accessibilityLabel`: only the caller knows what is loading, and "loading" alone is what the role already announces. ## Notes ### No `asChild` The one root in the library without one (R12). `Slot` merges a root's props into a single element, and that element keeps its own children — so a caller's element could become the track, but the arc is a second node it has no way to receive. Handing over the track alone would render a ring that never turns, silently. Styling is the escape hatch instead: every `ViewStyle` key is a prop (R14), and `style` is still the last word. ### Why borders and not an SVG stroke The reference implementation draws one arc fading from opaque to 55%, which needs a `linearGradient` and therefore `react-native-svg`. That package is an **optional peer** here, and a component in the fifteen-component core cannot require one. Two circles of a single ink at two opacities read as the same figure, cost two views, and pull in nothing. ### `Button.Spinner` is not this component with props The button's spinner takes its diameter and its colour from the button's own recipe, which has already resolved both. Handing them to `` would be R6 in reverse — a vocabulary prop carrying a computed number. What the two share is the turn, and that lives in `useRotation`. ## Migration | Legacy | v1 | | -------------------------------- | ------------------------------ | | `` | `` | | `` | `` | | `` | `` | # Stepper Where you are in a sequence of steps. ## Import ```tsx import { Stepper } from '@xaui/native/stepper' ``` ## Usage ```tsx Compte Créez votre compte Profil ``` ## Anatomy | slot | what it is | | --------------------- | ------------------------------------------------------ | | `Stepper` | The container, and the thing that numbers its children | | `Stepper.Item` | One step | | `Stepper.Indicator` | Its circle, and the rail the circle sits on | | `Stepper.Content` | What the step is about | | `Stepper.Title` | The step's name | | `Stepper.Description` | The line under it — vertical steppers only | ## The value is the caller's, always There is **no `defaultValue` and no `onValueChange`**. Nothing inside a stepper can move the value: a step is not a control, it is a report. The number comes from the form, the wizard or the route that actually knows which step it is on, and an uncontrolled stepper would be a piece of state that could never change. `value` counts from **one**, so `value={2}` is "step 2 of 4" — the number you would say out loud, not an array index. Out of range is not an error. `value={0}` leaves every step upcoming, which is the honest reading of a flow that has not started, and a value past the last step completes them all, which is what a finished one looks like. ## The root numbers its children An item declares no index and no key of its own. JSX order is step order (R4), so inserting a step in the middle renumbers the rest by being there: ```tsx … {/* completed */} … {/* completed */} … {/* current */} ``` It is the reasoning that puts the `Accordion`'s separators on its root: what an item cannot know about its neighbours belongs to the thing that has them all. A step reads its own standing through `useStep`, or through a function child: ```tsx {({ status }) => (status === 'current' ? : )} ``` ## Three statuses, and they are an order | status | indicator | title | line leaving it | | ----------- | ------------------ | ---------- | --------------- | | `completed` | accent disc, check | foreground | travelled | | `current` | accent ring | foreground | track | | `upcoming` | neutral ring | muted | track | Every step before the current one is `completed` and every step after it is `upcoming` — a stepper with two current steps is not a stepper. A **completed step keeps its full contrast**: it is a thing you did, not a thing greyed out. What recedes is the road ahead, so the eye finds where it is rather than counting from the top. The line under the current step is still track, not progress: the stepper has not left that step yet. ## Two orientations, and they differ by more than the axis `vertical` puts the indicator beside the text and aligned to the **top** of it, with the line running down through whatever height that text takes. It is the layout that can carry a description at all. `horizontal` centres each indicator over a label and gives every step the same width, so the circles land at even intervals whatever the labels say. There is no room under a label for a second line, which is why `Stepper.Description` is a vertical affair. ### Why the connectors belong to the indicator A vertical line has to run from under one circle to the next **through the text beside it**, and only something inside that row can measure that height. That is the opposite of the `Accordion`, whose separator spans the full width and can only be drawn by the root. A horizontal step carries **two** halves, one on each side of its circle, so the circle stays centred over its label whatever the neighbours do — two halves meeting at the item boundary draw one continuous rail. The two ends of that rail are drawn transparent rather than dropped: removing them would slide the first and last circles off their labels. `hasConnector={false}` leaves the circles on their own. ## A step is not pressable There is no `onPress` and no `isLocked`. A stepper where a completed step takes you back is one composition away: ```tsx setStep(1)}>… ``` and one where tapping ahead skips a form's validation is not something this component should make easy. ## The indicator draws its own mark With no children it shows its number, and a check once the step is behind you — drawn out of two borders (`utils/check-glyph`, the same tick the `Checkbox` uses) so a stepper works in a project that has installed no icon set. Children replace both: ```tsx ``` ## Props | prop | type | default | description | | -------------- | ------------------------------ | ---------- | ---------------------------- | | `value` | `number` | `1` | Which step, counted from one | | `orientation` | `'vertical' \| 'horizontal'` | `vertical` | Which way the steps run | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `md` | Indicator and type | | `color` | `string` | — | The tint (R7) — a raw value | | `hasConnector` | `boolean` | `true` | Whether the rail is drawn | | `asChild` | `boolean` | `false` | Renders the caller's element | `size` moves the indicator and the type — **never a width**. A vertical stepper is as wide as its parent lets it be and a horizontal one splits that width evenly, which is RN's own behaviour and the reason there is no `fullWidth` prop here either. ## A tint paints the progress, not the track `color` moves the travelled line, the ring around the step you are on and the disc behind the ones you are past. The road ahead stays grey — the untravelled track is written from the theme rather than named as a role, which is what makes that guaranteed rather than incidental. A stepper whose whole rail took the tint would have stopped showing progress. ## Accessibility The **root** announces the progress — `accessibilityRole="progressbar"` with an `accessibilityValue` of `{ min: 1, max: , now: value }`, so a screen reader says "step 2 of 4" once rather than a status on each of the four. Both stay overridable (R9). The titles and descriptions are read as the text they are. An indicator's number is decoration on top of that announcement — give it an `accessibilityLabel` if you replace it with something that carries meaning of its own. ## Everything else is a style prop `padding`, `gap`, `backgroundColor` — full RN names, full RN values (R14), on the root and on every slot. There is nothing here a prop had to be invented for. # Surface A ground for other things to sit on. ## Import ```tsx import { Surface } from '@xaui/native/surface' ``` ## Usage ```tsx Sur le fond de la page Et un cran dedans ``` **One node and no slots**, which is the point: a surface is a fill, a corner and some padding, and every other component in this library that needed those three has been writing them out again. ## It is not a `Card` A card has **decided things for you**. It is always lifted, it has a header, a body and a footer, and its levels carry an emphasis. A surface has decided nothing: the levels are a ladder, the shadow is asked for, and what goes on it is entirely yours. Reach for a card when the thing is a card; reach for a surface when you need a ground. ## A ladder, not three emphases | variant | fill | edge | typical place | | ----------- | ------------------ | -------- | -------------------- | | `primary` | `surface` | — | on the page | | `secondary` | `surfaceSecondary` | — | inside a `primary` | | `tertiary` | `background` | `border` | inside a `secondary` | A surface reports nothing — it is the thing other reporting sits on — so the levels say **where** something belongs rather than how loud it is. Three is as deep as that reading survives; a fourth would be a shade nobody could place. ### `tertiary` is an edge, not a third grey It takes the page's own `background` and draws itself with a `border`. Below a `secondary` there is no grey left that still reads as a level, so what marks the level is the line. Both are tokens the theme states per mode, so the edge lands **darker than the ground in light and lighter than it in dark** — `#fafafa` inside `#e4e4e7`, `#09090b` inside `#27272a` — with no branch written in the recipe. Two consequences to know. A `tertiary` sitting _directly_ on the page shares its fill, so only the border draws it — the variant working rather than a bug, since it is meant for inside a `secondary`, where the fill also reads as a step back down. And a **tinted** `tertiary` loses its edge: `color` lands where the variant put its tokens, and here that is the fill _and_ the border, so both come back the same colour. A tinted surface is a filled surface at every level — reach for `borderColor` as a style prop if you want the edge to survive the tint. ## Props | prop | type | default | description | | ------------ | ------------------------------ | --------- | ---------------------------- | | `variant` | `SurfaceVariant` | `primary` | Which ground | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `md` | Padding, gap and corner | | `radius` | `RadiusKey` | — | Overrides the corner | | `color` | `string` | — | The tint (R7) — a raw value | | `isElevated` | `boolean` | see below | Whether it is lifted | | `asChild` | `boolean` | `false` | Renders the caller's element | `size` moves the padding, the gap and the corner — **never a height**. A surface is a ground: how tall it is, is how tall what is on it is. ## Elevation is asked for `isElevated` defaults to **true for `primary` only**. A shadow under a ground that barely differs from the page reads as dirt rather than as height, so the quieter two are flat until you say otherwise. That is the second thing separating this from a `Card`, which is always lifted. Whether a ground is above the one under it is the layout's business: the same `secondary` is flat inside a card and lifted floating over a list. ## Everything else is a style prop `padding`, `borderRadius`, `borderWidth`, `borderColor` — full RN names, full RN values (R14). There is nothing here a prop had to be invented for, which is why this component's props list is six lines long. ## Where it should be reused `Card`, `Popover`, `Accordion` and `Dialog` each write out a fill, a corner and a shadow of their own. None of them reads this yet — that is a refactor, not a component, and it wants its own change so a regression in one of the four is not hidden inside a new file. # Switch A setting that is on or off, and takes effect the moment it is flipped. ## Import ```tsx import { Switch } from '@xaui/native/switch' ``` ## Anatomy ```tsx ``` - **`Switch`** — the root, and it is the **row, not the track**. It is the pressable: it owns the state, resolves the recipe once (R5) and publishes it to its slots. - **`Switch.Track`** — the bar the knob slides on, and the surface that says on or off. - **`Switch.Thumb`** — the knob. The only thing on this component that moves. - **`Switch.Label`** — what the setting is. **The root is the row** so that tapping the label flips the switch — the `Checkbox`'s shape, for the `Checkbox`'s reason. **R3 — and the track comes with the auto-wrap:** ```tsx Mode sombre // is exactly Mode sombre ``` With no children at all it is the track alone — the form a settings list wants, where the label is the row's own text. ## A switch is not a checkbox They look like the same control with two skins and they are not: | | `Checkbox` | `Switch` | | ------------- | --------------------------------- | --------------------- | | When it acts | Later, when the form is submitted | **Immediately** | | What it says | An intention | A state of the system | | `isInvalid` | Yes — the value can be wrong | **No** | | Indeterminate | Yes — a "select all" needs it | No | There is no `isInvalid` here because a setting that has already taken effect has no later moment at which it can be wrong. A setting that cannot be turned on is `isDisabled`, with the reason written beside it. ## Usage ### Controlled, or not ```tsx Mode sombre Synchroniser en Wi-Fi seulement ``` `onPress` still fires, composed with the flip. ### The two shapes ```tsx Le pouce à l'intérieur Le pouce par-dessus ``` `variant` is a **geometry** axis on this component, which is unusual and deliberate — both shapes are the accent when they are on, and what changes is where the knob sits: - **`primary`** (the default) rides the thumb **inside** the track, clear of its edges by the track's own inset. The shape everything else in this library shares: a filled surface with something on it. - **`secondary`** makes the track a thin bar and stands the thumb **over** it, overhanging above and below. Less ink, and the knob reads as the thing you drag. They are the legacy component's `inside` and `overlap`, under the library's own two names — the same two shapes, the same measurements, and one vocabulary across the whole v1 API instead of a third pair of words for this component alone. ### A glyph on the knob ```tsx ``` The knob's children travel with it. Anything else you put in the track stays where you put it — a glyph at each end, for instance, which is what the reference implementation's `StartContent` and `EndContent` are; here they are two `View`s you position, because the track is a node you were given rather than one that was hidden from you. ### Disabled ```tsx Géré par votre organisation ``` On the row, not the track: what is disabled is the control, and the label is part of it. ### As another element ```tsx … ``` R12 — the caller's element **is** the row, so it takes the children it was written with and the auto-wrap does not apply. ## Sizes `primary` — the knob inside the track: | `size` | Track | Knob | Inset | Travel | Label | | ------ | ------- | ---- | ----- | ------ | ----- | | `sm` | 44 × 26 | 20 | 3 | 18 | 14/20 | | `md` | 48 × 28 | 22 | 3 | 20 | 16/24 | | `lg` | 56 × 32 | 26 | 3 | 24 | 18/28 | `secondary` — the knob over the bar: | `size` | Track | Knob | Inset | Travel | Label | | ------ | ------- | ---- | ----- | ------ | ----- | | `sm` | 44 × 18 | 24 | 0 | 20 | 14/20 | | `md` | 48 × 18 | 26 | 0 | 22 | 16/24 | | `lg` | 56 × 20 | 30 | 0 | 26 | 18/28 | `md` is the legacy switch measured — a 48 × 28 track with a 22 knob — and the reference implementation's is the same 48 wide. **The width is part of the control**, unlike everywhere else in the library, where `size` never touches it: a switch is a fixed shape, and a track that stretched with its parent would be a progress bar. The travel is not a number anyone writes: it is `width − knob − 2 × inset`, arithmetic the root does on the flattened style, because a slide happens in a worklet and a worklet needs a number rather than a style to flatten every frame. `radius` is the **track's** corner, `full` by default. The knob stays round — a squared track with a round knob is a real design; the reverse is not. ## Colour ```tsx Teinté ``` A raw tint (R7), and here it is **the colour the switch turns on to**, with a knob derived to read against it. The track at rest keeps its neutral: a switch that is off is off in every brand. It reaches there through the `bgSelected` / `fgSelected` roles, which the tint pass repaints — the same mechanism as the `Checkbox`'s, written up once in [`checkbox.md`](../checkbox/checkbox.md#colour). The knob at rest is the theme's `white`, one of the two places in the library where a primitive is named on purpose: a switch's knob is white in both modes, the way the platform's own is, because it has to read against the neutral track **and** against the accent one. ## Animation The track's colour is **crossed, not swapped** — `interpolateColor` between the two ends — and the knob slides on the same 175ms, so a flip reads as one movement rather than as a repaint under a jump. The number lives in `switch.style.ts` and neither slot owns it, which is what keeps them arriving together. `animation={false}` on `Switch.Track` or `Switch.Thumb` puts that one at its end state with no worklet mounted at all. `animation` on the **root** is the library's usual press knob — `false`, `'disabled'`, `'disable-all'`, or the object — and belongs to `PressableFeedback`. **The knob moves with `translateX`, not with an edge.** R13 bans a directional inset, and a transform is not one — but a transform does not mirror under RTL either, so the sign is flipped by hand against `I18nManager.isRTL`. It is the one place in the library that reads it, and it reads it for a movement rather than for a layout. ## Alignment with the reference implementation Measured against their `switch.tsx` and `switch.css`, and against the legacy component this one replaces. **Identical to the reference implementation:** the 48pt track at `md`, the knob's `field` shadow, the accent track when on with a contrasting knob, the 175ms crossfade, and a default knob so nothing has to be written for the common case. **Four deltas:** | Theirs | Ours | Why | | ------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | The root is the track; no label | The root is the row, `Label` is a slot | The `Checkbox`'s reason, and the legacy component had a `label` too. | | A 28 × 20 pill knob | A round knob, 22 at `md` | The legacy switch's, and the platform's. A pill knob in a round track is a shape neither of our two variants is. | | No shape variants | `primary` / `secondary` | The legacy component shipped both, and dropping one at the migration would be a redesign rather than a port. | | `StartContent` / `EndContent` | Children of the track | The track is a slot here, so a glyph at either end is a `View` you place — one API instead of two named positions. | ## Props ### `Switch` Everything `PressableFeedback` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | ------------------ | ------------------------------- | ----------- | --------------------------------------- | | `variant` | `'primary' \| 'secondary'` | `'primary'` | The shape: knob inside, or over the bar | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Track, knob, gap, label type | | `radius` | `RadiusKey` | `'full'` | The track's corner | | `color` | `string` | — | A hex tint — the colour it turns on to | | `isSelected` | `boolean` | — | Controlled | | `defaultSelected` | `boolean` | `false` | Uncontrolled starting value | | `onSelectedChange` | `(isSelected: boolean) => void` | — | Fires on every flip, either mode | | `isDisabled` | `boolean` | `false` | Dims the row and stops the press | | `animation` | `AnimationProp` | — | The press treatment, as everywhere else | | `asChild` | `boolean` | `false` | Merge into the single child | **No `isInvalid`.** See above. ### `Switch.Track`, `Switch.Thumb` Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus: | Prop | Type | Default | Notes | | ----------- | ----------- | ------- | --------------------------------------------------- | | `children` | `ReactNode` | — | The knob, for the track; a glyph, for the knob | | `animation` | `boolean` | `true` | `false` shows the end state with no worklet mounted | ### `Switch.Label` Everything `Text` accepts, plus the `TextStyle` keys as props (R14). ## Extending it `useSwitch()` is exported (R10) and carries the resolved styles, the two colour pairs, the travel and the state — enough to write a glyph that changes at each end of the track, or a second label: ```tsx import { useSwitch } from '@xaui/native/switch' function SwitchState() { const { isSelected, labelStyle } = useSwitch() return {isSelected ? 'Activé' : 'Désactivé'} } ``` Outside a `` it throws by name. ## Accessibility - `accessibilityRole="switch"` on the root, overridable, with `accessibilityState.checked` following the value and `.disabled` following `isDisabled`. A caller's own `accessibilityState` is **merged**, not spread over. - **The label is inside the control**, so the switch announces itself with its text. A switch with no label needs an `accessibilityLabel`. - The whole row is the touch target. - **The label does not change with the state.** "Mode sombre" is the setting whether it is on or off; a label reading "Activé" would say what the track and the role already say, twice. ## Migration from `@xaui/native-legacy` | Legacy | v1 | | ------------------------------ | ------------------------------------------------------------- | | `label="…"` | `…` — the text child _is_ the label | | `labelAlignment="left"` | write `` before `` | | `labelAlignment="justify-*"` | `justifyContent="space-between"` plus a width, in style props | | `variant="inside"` | `variant="primary"` — the same shape, the library's name | | `variant="overlap"` | `variant="secondary"` — likewise | | `isSelected` / `onValueChange` | `isSelected` / `onSelectedChange`, plus `defaultSelected` | | `themeColor="primary"` | `color={theme.colors.accent}` — a raw value (R7) | | `size="sm" \| "md" \| "lg"` | `size` — the same three | | `radius` | `radius` — a `RadiusKey` now, still `full` by default | | `fullWidth` | `width="100%"` in style props (R14) | | `isDisabled` | unchanged, on the root | | `labelStyle` | `style` on `` | | `style` | `style` on the root | # Table Rows and columns, with a shell round them. ## Import ```tsx import { Table } from '@xaui/native/table' ``` ## Usage ```tsx Nom Rôle {people.map(person => ( {person.name} {person.role} ))} …
``` ## Three nodes, and each earns its place - **`Table`** is the shell: the fill, the border, the corner, the shadow. It clips, and it does not move. - **`Table.ScrollContainer`** is the horizontal scroller. - **`Table.Content`** is the column inside it, and the node that is allowed to be **wider than the shell**. Folding them together is what makes a wide table either clip its own rows or drag its border across the screen. The header is inside the scroller with the body, because a header that stayed put while its cells moved sideways would be a header naming the wrong values. `Table.Footer` is **outside** the scroller: a pager that slid off with a wide table would be a pager nobody could reach. ## Widths are declared by the column and read by position `Table.Column` registers its `width` at its index; `Table.Cell` reads the width at its own. Neither names the other, and a table stays aligned because both run the same two lines against the same number: ``` width === undefined ? { flex: 1 } : { width } ``` A column with no width takes an equal share of what is left. `Table.Content`'s `minWidth` is what stops flexible columns squeezing below a legible width on a narrow phone. The space between columns is the row's **`gap`**, not a padding on the cell — a padding would double at the table's two edges, and none at all lets a name and a role run together the moment a flexible column shrinks to its content. ## The table never reorders anything Sorting reports the press and the caller sorts their own collection: ```tsx const sorted = useMemo(() => { if (sort === undefined) return people const way = sort.direction === 'ascending' ? 1 : -1 return [...people].sort((a, b) => String(a[sort.column]).localeCompare(…) * way) }, [people, sort]) ``` A table that sorted for you would have to understand every cell's value, and the only thing that does is the code that built the row. **Three presses, not two.** A new column starts ascending, the same column turns round, and a descending column pressed again **clears the sort** — every table that cannot get back to its own order makes a reader reload the screen to do it. ## Selection `selectionMode` is `none`, `single` or `multiple`; rows are named by their `id` and reported as an array of keys. - **`single` replaces, and pressing the chosen row again clears it.** A single-selection list with no way back to none is a list a reader can only get wrong once. - **The header's box has three states** — nothing, some, all. One that only knew the first two would tell a reader who has chosen four of twenty rows that they have chosen none. - **It counts only rows that can be chosen**, so a table with a disabled row still has a box that can be filled. - **It keeps keys chosen outside this table.** One page of a filtered list must not clear a choice made on another. `Table.SelectionCell` reads its _row_ rather than its position, so it works wherever in the row it is written — a table whose boxes are on the trailing edge is the same JSX with the slot written last. Pressing the row does the same thing, deliberately: a box the size of a fingertip inside a row the size of a hand should not be the only way to choose one. ## There is no `virtualized` prop A table of ten thousand rows is a `FlatList`, and `Table.Body` takes `asChild` so it **is** one: ```tsx person.id} renderItem={({ item }) => …} /> ``` A prop would have meant this component owning a `data` and a `renderItem` — the shape the whole v1 API was written to get away from — and owning them badly, since a virtualized list has a dozen props a table would then forward one by one. ## Variants `primary` is a raised card, for a table among other things on a page. `secondary` is flat — the page's own ground with a filled header band — for a table that _is_ the screen. No `success` or `danger`: a table reports what is in it, and the intent belongs to a `Chip` in a cell rather than to the shell round all of them. ### The tint does not touch the shell `color` lands on a chosen row and on the sort mark. It is kept off the root **explicitly**, because `bg` names `surface` — a bare token — and `resolveTint` would map it to the tint like any other, turning a blue app's table entirely blue. A tint on a container means the thing it _marks_, not the ground everything sits on. ## Size `size` moves the row's height, the cell's inset, the type and the shell's corner. The height is **fixed**: a value too long is truncated rather than deforming the table. The corner sits a level below the `Card`'s at the same size, and `radius` overrides it. A card is one padded surface and can take a wide curve; a table is a stack of square rows behind a shell that clips them, so past `lg` the top row's corner starts eating into the first cell's text while every row underneath stays flat. ## The rules are hairlines Every line _inside_ the table — under the header, between two rows, above the footer, and between two column names — is one device pixel, `StyleSheet.hairlineWidth`, as the `List`'s and the `Menu`'s are. Only the shell's own outline is a full point. The rule between column names is the header's alone: a row's `gap` already says where one field ends, and a grid ruled in both directions is a spreadsheet. It is drawn out of flow, so it costs no width and a header column still starts on the same edge as the cells under it. ## See also - **`List`** — for rows that are not a grid. - **`Chip`** — what a status cell usually holds. # Tabs A row of tabs, and what each one shows. ## Import ```tsx import { Tabs } from '@xaui/native/tabs' ``` ## Anatomy ```tsx … … ``` - **`Tabs`** — holds what is chosen, and every trigger's measured rectangle. - **`Tabs.List`** — the row the triggers sit in, and the box the indicator slides inside. - **`Tabs.Trigger`** — one tab. It measures itself. - **`Tabs.Label`** — its text. - **`Tabs.Indicator`** — the one node that says which tab is chosen. - **`Tabs.Content`** — what a tab shows. ## Usage ### Basic ```tsx Tout Non lus … ``` A stringifiable trigger becomes its label (R3), which is what makes the common case one line per tab. ### Three shapes ```tsx … ``` | variant | track | indicator | chosen label | | ----------- | ---------------- | ---------------- | ------------------- | | `primary` | filled, rounded | a pill behind it | `segmentForeground` | | `secondary` | a hairline under | a two-point rule | `foreground` | | `light` | none | none | `accent` | Three affordances rather than the same one louder, which is why the union is three rather than the usual four. `light` is the quietest a tab bar can be and still be one: no track, no rule, nothing but the chosen tab's label going to the accent. The colour is the whole signal, which is why it goes to the accent rather than to plain ink — a tab merely darker than its neighbours is not chosen, it is just darker. It belongs over content that is already busy, and not where a bar has to be found before it can be read. `Tabs.Indicator` draws nothing under `light`, whether you leave it in or take it out. ### Widening the bar ```tsx Tout ``` The list hugs its tabs by default. A tab bar as wide as the screen with three tabs in it is a segmented control pretending to be a navigation bar, so widening it is a decision you make rather than one you undo. ### No indicator Leave `Tabs.Indicator` out and the label's colour is the only thing saying which tab is chosen. That is a legitimate bar, and it is why the indicator is a slot rather than something the list conjures. ### Style as props ```tsx ``` Full RN names, full RN values (R14). Every node takes them. ## Props ### `Tabs` | prop | type | default | description | | --------------- | ------------------------------------- | --------- | ------------------------------- | | `variant` | `'primary' \| 'secondary' \| 'light'` | `primary` | A pill, a rule, or colour alone | | `size` | `'sm' \| 'md' \| 'lg'` | `md` | Trigger padding, gap and type | | `radius` | `RadiusKey` | — | Overrides the track and pill | | `color` | `string` | — | The tint (R7) — a raw value | | `value` | `string` | — | Controlled | | `defaultValue` | `string` | — | Uncontrolled | | `onValueChange` | `(value: string) => void` | — | Fires on every choice | | `isDisabled` | `boolean` | `false` | Dims the bar, stops every tab | No `xs`. A tab is a target before it is a label, and at that height there is nothing left of it. ### `Tabs.Trigger` `value` (required), `isDisabled`, `asChild`. `children` may be a function taking `{ isSelected, isPressed, isDisabled }`. ### `Tabs.Content` `value` (required). **Mounted when its tab is chosen, absent otherwise** — a tab bar over four screens of content should not have four screens of content mounted. A panel that must keep its state across a switch is one you hold the state for, which is the same trade every router makes. ## Motion The indicator is **one node sliding**, not a border on each tab appearing and disappearing. The triggers publish their rectangles on layout, the root keeps them, and the indicator springs between them on the UI thread — so it keeps travelling while whatever the new tab shows is mounting. Softer than the chevron's spring: damping 20 against stiffness 220 at mass 0.6. That one turns 180 degrees and must not overshoot; this one slides a few dozen points, and a touch of overshoot is what makes it feel attached to the press. **The first placement jumps rather than springing.** Animating it would slide the pill in from the start of the row on mount, which reads as the tab bar arranging itself rather than as a control at rest. Until a rectangle exists the indicator renders at zero opacity, so there is no flash at the start of the row either. ## Not here yet **A scrollable list.** The reference implementation's `Tabs.ScrollView` centres the chosen tab when the bar overflows, and that means the indicator has to account for a scroll offset the triggers' own layout does not report. Worth its own change. **`Tabs.Separator`.** A hairline between tabs in the `secondary` shape. ## Accessibility The list is a `tablist`, each trigger a `tab` carrying `selected`, each panel a `tab`. ## Migration from `@xaui/native-legacy` | Legacy | v1 | | -------------------------- | ---------------------------------- | | `` | one `` per tab | | `activeIndex` | `value` — a string, not a position | | `onIndexChange` | `onValueChange` | | `variant="segmented"` | `variant="primary"` | | `variant="underline"` | `variant="secondary"` | | `customAppearance={{ … }}` | `style` on the slot that key named | **A tab is named, not numbered.** An index breaks the moment a tab is inserted, and it is what made the legacy component's `activeIndex` a prop nobody could hold correctly. # TagGroup A wrapping set of tags you can turn on, and take off. ## Import ```tsx import { TagGroup } from '@xaui/native/tag-group' ``` ## Anatomy ```tsx … ``` - **`TagGroup`** — what is selected, what is disabled, and every slot's resolved style. - **`TagGroup.List`** — the wrapping row. - **`TagGroup.Item`** — one tag. - **`TagGroup.ItemLabel`** — its text. - **`TagGroup.ItemRemoveButton`** — takes it off. ## This is not a row of `Chip`s A **chip** is a piece of metadata that is always the same. A **tag** is one you can turn on, take off, or both. The selection state and the removal are the component; the pill around them is the least of it. That is also why the two do not share a recipe — a chip has ten variants because it reports an intent, and a tag has two grounds because it reports nothing at all until it is selected. ## Usage ### Selectable ```tsx Français ``` A stringifiable child becomes the label (R3). `selectionMode` is `'none'` by default: a group of tags is a set of labels until you say otherwise. ### Removable ```tsx setTags(t => t.filter(x => x !== id))}> Français ``` **The cross renders nothing without an `onRemove`.** Removing a tag is your list changing, and a cross that appeared to work while the list stayed put would be worse than one that is plainly not there. It is also written out rather than drawn by the item, because a tag you can turn on and a tag you can take off are different controls and most groups are only one of the two. ### Style as props ```tsx ``` Full RN names, full RN values (R14). Every node takes them. ## Props ### `TagGroup` | prop | type | default | | --------------------- | ----------------------------------- | --------- | | `variant` | `'default' \| 'surface'` | `default` | | `size` | `'sm' \| 'md' \| 'lg'` | `md` | | `radius` | `RadiusKey` | — | | `color` | `string` | — | | `selectionMode` | `'none' \| 'single' \| 'multiple'` | `none` | | `selectedKeys` | `readonly string[]` | — | | `defaultSelectedKeys` | `readonly string[]` | — | | `onSelectionChange` | `(keys: readonly string[]) => void` | — | | `onRemove` | `(id: string) => void` | — | | `disabledKeys` | `readonly string[]` | `[]` | | `isDisabled` | `boolean` | `false` | | `isDeselectable` | `boolean` | `true` | ### `TagGroup.Item` `id` (required), `isDisabled`, `asChild`. `children` may be a function taking `{ isSelected, isPressed, isDisabled }`. ## Two grounds, not two emphases `default` is the theme's neutral fill; `surface` is the card colour. **They swap** so a tag never disappears into what is behind it — a group on a card wants `default`, a group on the page wants `surface`. A selected tag leaves both and takes the accent's soft slice, which is the only place this component uses colour. ## Wrapping is the point A tag group is a set of the same kind of thing, and a set that scrolls sideways hides how many of it there are — which is the one fact a reader wants from a row of tags. ## The selection rule One pure function, and it returns the list **unchanged** whenever a press changes nothing: `'none'` refuses every press, `'single'` on the already-selected tag clears it unless that would empty a group the caller asked to keep one. `useControllableState` drops a set to the value it already holds, so `onSelectionChange` never fires for a change that did not happen. Ten tests cover it. ## Accessibility The list is a `list`, each tag a `button` carrying `selected` and `disabled`. The cross takes the tag's own `isDisabled` — a disabled tag that can still be removed is not disabled — and warns in development when it has no `accessibilityLabel`, because a cross announces as nothing on its own. # TextArea A multiline field, with the label, the hint and the error that make it usable. > **Not part of the 1.0 core.** The fifteen are listed in the plan and this is not one of > them; it ships under `1.x` as a P5 component. ## Import ```tsx import { TextArea } from '@xaui/native/text-area' ``` ## Anatomy ```tsx ``` ### It is a `TextField` Not "like" one — it **is** one. `TextArea` renders the `TextField`'s root: the same recipe, the same resolved context, the same four variants, the same `size`, `radius`, `color`, `labelPlacement`, `isInvalid` and `isDisabled`. `TextArea.Label`, `TextArea.Description` and `TextArea.Error` are literally the `TextField`'s slots, re-exported rather than wrapped — a wrapper would add three components to the tree to change a `displayName`, and the string it would change is the one telling you the truth. **Only `TextArea.Field` differs**, by three things: `multiline`, the text pinned to the top, and a height counted in lines. That is also the reference implementation's answer — its `TextArea` is twenty lines rendering its `TextField` with the same three defaults. A component of its own is what a caller looks for; sharing every line of it is what keeps the two from drifting. So **everything in [`input.md`](../text-field/text-field.md) applies here**, and this page only covers what is added. ## Usage ### Basic ```tsx ``` ### `rows` and `maxRows` ```tsx ``` `rows` is the starting height, in lines. `maxRows` is the ceiling: past it the field stops growing and scrolls. **Unset, it grows for as long as the text does** — and has nothing to scroll, which is why `scrollEnabled` follows `maxRows` rather than being a prop of its own. Both sit on the **root** rather than on the field, for the reason `size` does: the root is where the field's shape is decided, and the slot reads what it resolved. **They are raw values** (R6), like `color`: they resolve outside the style cache from the line height the size chose, which is what lets `rows={7}` exist without seven entries in the cache. | `size` | Line | Padding | `rows={3}` | `rows={4}` | | ------ | ---- | ------- | ---------- | ---------- | | `xs` | 20 | 6 | 72 | 92 | | `sm` | 24 | 8 | 88 | 112 | | `md` | 24 | 8 | 88 | 112 | | `lg` | 28 | 10 | 104 | 132 | ### A fixed height The reference implementation's text area is a **fixed** 128 that scrolls rather than one that grows. That is a style prop away (R14), rather than a second API: ```tsx ``` ### Everything the `TextField` does ```tsx ``` `labelPlacement="inside"` composes: the label keeps its room at the top and the first line starts below it, because the four inside-label compounds write the same `paddingTop` to the text area as they do to the field. ## Props ### `TextArea` Everything the [`TextField` root](../text-field/text-field.md#input) accepts, plus: | Prop | Type | Default | Notes | | --------- | -------- | ------- | ---------------------------------------------- | | `rows` | `number` | `3` | The starting height, in lines | | `maxRows` | `number` | — | The ceiling. Past it the field scrolls instead | ### `TextArea.Field` Everything [`TextField.Field`](../text-field/text-field.md#inputfield) accepts except `multiline`, which it sets. `scrollEnabled` defaults to whether `maxRows` was given. ### `TextArea.Label`, `TextArea.Description`, `TextArea.Error` The `TextField`'s, unchanged. See [`input.md`](../text-field/text-field.md). ## Extending it `useTextArea()` carries **only** the two row counts — the one thing the `TextField`'s own context cannot, because `rows` is not the `TextField`'s business and a prop that does nothing in the common case reads as broken. Everything visual comes from `useTextField()`, because the styles were resolved there. ```tsx import { useTextField } from '@xaui/native/text-field' import { useTextArea } from '@xaui/native/text-area' ``` ## Accessibility The `TextField`'s, unchanged: the wrapper has no role, the field points at the label with `aria-labelledby` and at the description with `aria-describedby`, and `aria-invalid` follows `isInvalid`. See [`input.md`](../text-field/text-field.md#accessibility). ## Migration from `@xaui/native-legacy` | Legacy | v1 | | -------------------- | ----------------------------------------------------------- | | ` ``` ### Disabled ```tsx Identifiant ``` `editable` is not a public prop — it is `disabled` under another name, and R8 keeps that off the surface. `isDisabled` on the root dims the column and stops the field. ### As another element ```tsx … ``` The caller's element **is** the column. The slots still read the root's context. ### Sizes `size` drives the field's minimum height, its padding, the gaps and the type — **never the width**. A field with no width fills its parent in a column, which is RN's own behaviour and the reason there is no `fullWidth` prop. | `size` | Field min-height | Padding | Field | Label | Description / Error | | ------ | ---------------- | ------- | ----- | ----- | ------------------- | | `xs` | 32 | 10 | 14 | 14/20 | 12/16 | | `sm` | 40 | 12 | 16 | 14/20 | 12/16 | | `md` | 48 | 12 | 16 | 16/24 | 14/20 | | `lg` | 56 | 16 | 18 | 18/28 | 16/24 | `md` is the reference implementation's input measured: a 48pt minimum, 12pt of horizontal padding, a 16/24 label above the field and a 14/20 line below it. **A minimum and not a fixed height**, which is the one place this component departs from the rule the `Button` and the `Chip` follow. A `TextInput` with `multiline` holds three lines of the user's own text and has to grow; a control whose content is not the developer's cannot be truncated into shape. The reference implementation reaches the same conclusion with `min-height`. The label and the help text carry a small horizontal inset — half the `md` field's padding — so the column reads as one block. It does not scale: it is an optical alignment, not a measurement. ### Variants The library's four emphasis levels, narrowed like the `Card`'s — and this is the **first real use of the theme's `field*` family**, the tokens P0 derived for exactly this component and nothing else has read since. | `variant` | Background | Border | Focus border | Shadow | | ----------- | ----------------- | ------------- | ------------------ | ------- | | `primary` | `fieldBackground` | `fieldBorder` | `fieldBorderFocus` | `field` | | `secondary` | `default` | `fieldBorder` | `fieldBorderFocus` | — | | `tertiary` | transparent | `fieldBorder` | `fieldBorderFocus` | — | | `ghost` | transparent | — | — (no border) | — | The four names split the reference implementation's two-name `primary | secondary` by saying what each of their ends already is: - **`primary`** is their `primary` — the `fieldBackground` fill — plus the theme's `field` shadow, the elevation their flat token only implies. - **`secondary`** is their `secondary`, the neutral `default` fill, and **the default here**: on a plain background a white field is its border and nothing else, while on a card the `fieldBackground` token _is_ the card's own colour. - **`tertiary`** is the border alone, the same drop the `Button`'s `tertiary` makes. - **`ghost`** is neither, and it has no border to move — its focus shows in the caret alone. Reach for `tertiary` when a focus ring matters. The first three name the `fieldBorder` edge and `ghost` gives it up. Its **width** is the theme's `borderWidth.field` — the same knob the reference implementation exposes as `--field-border-width`, and the one shipped default where the two libraries differ: **they ship `0`**, so their input is a fill with no visible edge, and we ship `1`. `createTheme({ borderWidth: { field: 0 } })` reproduces theirs exactly. A field **reports nothing** — an error is `isInvalid`, which is a state and not a variant — so `success`, `warning` and `danger` are absent here exactly as they are on the `Card`. ### Label placement ```tsx Courriel On ne le partage jamais. ``` `outside` (the default) leaves the label above the box, in the column's flow. `inside` lifts it into the box, above the text: the label is taken **out of flow** and placed against the box's own padding, so **the JSX is identical either way** and nothing is reparented (R4). The field then pays for the room — `paddingTop` clears the line and the box grows by the same amount, so the text keeps the height its `size` promised. Because the inside label positions itself against the top of the root, it assumes the field is the first thing left in the column's flow. Write `TextField.Description` and `TextField.Error` after the field, which is where they belong anyway. | `size` | Outside min-height | Inside min-height | Inside label | | ------ | ------------------ | ----------------- | ------------ | | `xs` | 32 | 48 | 12/16 | | `sm` | 40 | 52 | 12/16 | | `md` | 48 | 52 | 12/16 | | `lg` | 56 | 60 | 14/20 | The inside height is built from the two lines the box now holds rather than added to the control height — `controlHeights` already pays for centring one line — and floored at the control height, so a theme that raises `controlHeights` never ends up with an inside field shorter than the outside one beside it. ### Focus The border darkens towards the mode's ink — `fieldBorderFocus`, which is `fieldBorder` mixed 26% towards `fieldForeground`. **No ring and no accent**: a form where every focused field flashes the brand colour is a form where the accent has stopped meaning "the action". The focus state lives on the **root**, because the recipe resolves on it (R5), while the node that hears the event is `TextField.Field` — the field composes the caller's `onFocus` and `onBlur` with the two the context published, so your handlers run and the border still moves. ### Alignment with the reference implementation Measured against their `input.css`, `text-field.css`, `label.css`, `description.css`, `field-error.css` and `variables.css` rather than eyeballed. **Identical at `md`:** the 4pt spacing unit, the `12/16 · 14/20 · 16/24 · 18/28` type scale (Tailwind v4's defaults, which they use), the 48pt minimum, 12pt of horizontal padding, the 6pt column gap, the 6pt inset on the label and the help text, a 16/24 `medium` label in `foreground`, a 14/20 `muted` description, a 14/20 `danger` error, a `400` field text and the `field-placeholder` colour. The whole radius scale shares their multipliers (`0.25 · 0.5 · 0.75 · 1 · 1.5 · 2 · 3 · 4`) and the field radius is `base × 1.75` in both. We read `fieldForeground` where they read `foreground`; the two resolve to the same value in both modes, and the field token is the one that can be themed apart later. **Two deltas, both deliberate and both in the theme rather than in this component:** | Token | The reference implementation | XAUI | Why | | ------------------- | ---------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `borderWidth.field` | `0` | `1` | Their input is a fill with no visible edge. Ours keeps a hairline, because `tertiary` — the border alone — has nothing left to be without it. Same knob, different shipped default: `createTheme({ borderWidth: { field: 0 } })` reproduces theirs. | | radius base | `8` → field `14` | `12` → field `21` | `RADIUS_BASE` is a P0 decision that draws every corner in the library. Changing it for one component would be incoherent; changing it globally is a theme decision, not this one. | **Two things we add that they do not have:** a focus state — their CSS has none, and the theme derived `fieldBorderFocus` for it — and the `field` shadow on `primary`, which is what makes it read as raised rather than as a second flat fill. ### Colour ```tsx ``` A raw tint, never a token (R7). It lands where the variant put its tokens, and because the focus colour is a role like any other it is also what the field borders on focus. **On a `primary` or a `secondary` the tint is the fill** — a solid coloured box with contrasted text, consistent with a tinted `Button` and rarely what a form wants. `tertiary` and `ghost` are where a tint is useful on a field. ### Style as props Every node takes its own style keys as props (R14) — full React Native names, full React Native values, no hidden scale: ```tsx Courriel ``` ### Everything else goes through `style` - **`selectionColor`, `cursorColor`, `placeholderTextColor`** are `TextInput` props — write them on `TextField.Field`. The placeholder already takes the theme's `fieldPlaceholder`; the prop is there to override it. - **A leading or trailing adornment** — a search glyph, a clear button, a unit — is **[`FieldGroup`](../field-group/field-group.md)**. It goes where the field goes and replaces nothing else: ```tsx Recherche ``` ## Props ### `TextField` Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | ---------------- | ------------------------------ | ------------- | -------------------------------------------------- | | `variant` | `TextFieldVariant` | `'secondary'` | The four levels above | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | The field's minimum height, padding, gaps, type | | `radius` | `RadiusKey` | — | Overrides the theme's `field` radius | | `labelPlacement` | `'outside' \| 'inside'` | `'outside'` | `inside` lifts the label into the box, out of flow | | `color` | `string` | — | A hex tint, placed by the variant | | `isInvalid` | `boolean` | `false` | Danger colours, and focus is suppressed | | `isDisabled` | `boolean` | `false` | Dims the column and stops the field | | `asChild` | `boolean` | `false` | Merge into the single child instead of rendering | **`TextInputProps` are not here.** They belong to `TextField.Field`. ### `TextField.Field` Everything `TextInput` accepts **except `editable`**, plus the `TextStyle` keys as props (R14) — `value`, `defaultValue`, `onChange`, `onChangeText`, `maxLength`, `multiline`, `readOnly` and the rest, under React Native's own names. **There is no `type`.** That is an HTML prop; React Native splits it into `inputMode`, `keyboardType`, `secureTextEntry` and `autoComplete`, and the field takes all four: ```tsx ``` `placeholderTextColor` defaults to the theme's `fieldPlaceholder` and stays overridable. `onFocus` and `onBlur` are composed, never replaced. ### `TextField.TextArea` Everything `TextField.Field` accepts except `multiline`, which it sets, plus: | Prop | Type | Default | Notes | | --------- | -------- | ------- | ---------------------------------------------- | | `rows` | `number` | `3` | The starting height, in lines | | `maxRows` | `number` | — | The ceiling. Past it the field scrolls instead | ### `TextField.Label`, `TextField.Description`, `TextField.Error` Everything `Text` accepts, plus the `TextStyle` keys as props (R14). `Label` and `Description` carry the ids the field points at; passing `nativeID` overrides them. ## Extending it The context hook is exported, so a third party can write their own slot against the same resolved values the built-in ones read: ```tsx import { useTextField } from '@xaui/native/text-field' function TextFieldCounter({ length, max }) { const { descriptionStyle } = useTextField() return ( {length} / {max} ) } ``` Used outside a `` it throws by name, pointing at the misplaced component rather than failing three frames later on an undefined style. ## Accessibility - **The wrapper has no role.** The control is the field inside it, and a role on the column would give a screen reader a second element to stop on before reaching it. - `TextField.Field` points at the label with `aria-labelledby` and at the description with `aria-describedby`, so a screen reader announces "Courriel, champ de saisie" instead of falling back to whatever the placeholder happens to say. - `aria-invalid` follows `isInvalid`, and `accessibilityState.disabled` follows `isDisabled`. - **`TextField.Error` sets no live region**, deliberately: an error that changes while you are still typing would be re-announced on every keystroke. The field's `aria-invalid` is what reports the state. - **A placeholder is not a label.** A field with only a placeholder loses its name the moment a value is typed — write a `TextField.Label`, and hide it with `` only if the design truly cannot show one. ## Migration from `@xaui/native-legacy` The legacy component is `TextInput`, and its props are the ones this table names. | Legacy | v1 | | ----------------------------------------------------- | ------------------------------------------------------------------------ | | `label="…"` | `…` | | `description="…"` | `…` | | `errorMessage="…"` | `…`, mounted by you, plus `isInvalid` | | `value` / `defaultValue` | the same two props, on `` | | `onValueChange` | `onChangeText` on `` — RN's name. `onChange` works too | | `labelPlacement="inside"` | `labelPlacement="inside"` — a static label, not a floating one | | `variant="colored"` | `variant="primary"` | | `variant="light"` | `variant="secondary"` | | `variant="bordered"` | `variant="tertiary"` | | `variant="underlined"` | `variant="ghost"` + `borderBottomWidth` on `` | | `themeColor="primary"` | `color={theme.colors.accent}` | | `size="sm" \| "md" \| "lg"` | `size` — now `xs` … `lg`, and the legacy `sm` is the new `xs` | | `radius` | `radius` — a `RadiusKey` now, not the legacy `Radius` | | `isSecured` | `secureTextEntry` on `` — RN's own name | | `isReadOnly` | `readOnly` on `` | | `isDisabled` / `isInvalid` | unchanged, on the root | | `isClearable` | a `Pressable` in a `` that sets the value to `''` | | `TextArea` with `minRows` / `maxRows` | `` — a slot, not a component | | `fullWidth` | removed — that is already the default in a column | | `startContent` / `endContent` | `` / `` around the field | | `customAppearance={{ container }}` | `style` on the root | | `customAppearance={{ input }}` | `style` on `` | | `customAppearance={{ label }}` | `style` on `` | | `customAppearance={{ helperText }}` | `style` on `` / `` | | `customAppearance={{ inputContainer, inputWrapper }}` | gone with the wrappers they styled | # TimePicker A field that opens a clock. ## Import ```tsx import { TimePicker } from '@xaui/native/time-picker' ``` ## Usage ```tsx ``` ## It owns almost nothing The trigger **is** a `Select`'s trigger — the same resolved styles, the same four field levels, the same focus and invalid treatment — by construction rather than by resemblance, so a select and a time field in one form cannot drift apart. The panel is a `BottomSheet`. What it adds is the dial and the wiring. **`variant` dresses the field and never reaches the dial**, which is not a field: it has one appearance and takes it from the theme. `color` is the exception — a raw tint (R7) lands on the chosen mark, the hand and the hub. ## A sheet, not an anchored panel A clock face is close to three hundred points square, which beside a field on a phone is the screen. It comes up from the bottom, where the thumb is — the `DateField.Sheet` argument, and where the legacy put it too. ## The dial **Two rings on a twenty-four hour face.** 1–12 outside and 13–00 inside, which is the only way twenty-four numbers fit on a circle without the labels touching. `00` rather than `24`: midnight is the start of a day, not its end, and the hour reported is 0. **Twelve labels on the minutes, and no marks between them.** A number on every minute is a smudge and a dot on every minute is clutter; the unlabelled minutes are still targets, they are just not drawn. `minuteStep` coarsens them, for a picker that only wants quarters. **The whole face is the control.** A touch anywhere on it moves the hand to the value under the finger; keep the finger down and the hand follows it, live, so an unlabelled `07` is reached by turning the hand onto it. One gesture, whether the reader aims at a number or turns the hand round to it. **The choice settles on release.** Choosing an hour hands the dial on to the minutes and choosing a minute closes the sheet — do either on touch-down and the ring flips under the finger with nothing left to turn. So the frames of a drag only write the value, and the release picks it. A tap is that pair with nothing in between. The pan claims the touch **on contact** rather than after a hold. A hold let the sheet win — `BottomSheet.Content` wraps its children in a pan of its own with no threshold, so a finger's jitter slid the sheet down before the dial's hold had elapsed. The marks keep their button role for a screen reader, which activates them directly and never reaches the pan. **Choosing an hour goes straight to the minutes**, which is the one thing that makes a two-ring dial feel like one gesture rather than two. Choosing a minute closes the sheet, because at that point the time is complete — `closeOnSelect={false}` keeps it open. The geometry is `utils/clock.ts` and it is tested: the quarter turn that puts twelve at the top, the sign that keeps it _above_ the centre in a coordinate system that grows downwards, and the conversion from `atan2`'s own convention. ## The pieces are composable `TimePicker.Sheet` with no children assembles `TimePicker.Display` and `TimePicker.Clock`. Writing them yourself is how a title goes above the dial or a confirm row below it — and how the dial goes on a page with no sheet at all: ```tsx ``` ### The display The two big numbers, and pressing one switches the ring. Which of the two is lit says which ring is on screen — without it a reader who reopened the sheet on the minutes would think the hours had been forgotten. The period is **two halves of one control** rather than a toggle, unlike `TimeField.Period`: there is room here, and a reader choosing a time from nothing should see both options rather than press one to find the other. `AM` and `PM` sit **side by side** in a small pill, well below the dial's numbers in size — they caption the time rather than being part of it. The display is **as wide as the face beneath it** and the pill is out of flow, pinned to its trailing edge: the two numbers keep the middle of that box, so the time reads dead centre over the dial whether the reader is on a twelve- or a twenty-four hour clock. ### The indicator With no `as` and no children it draws a clock from three views — a ring and two hands, at ten past ten, where a clock is always drawn. The `CloseButton`'s cross settles what to do about an icon set the library does not ship. An `Icon` passed instead takes the field's size and colour through `IconContext` without being told either. ## Props `hourCycle` comes from `locale` through `Intl` when it is not given. `formatOptions` is `Intl.DateTimeFormatOptions`, so the field reads the locale's own punctuation and period marker rather than a template written here. The root **renders no node**: `ref`, `style` and the a11y props live on `TimePicker.Trigger`. ## See also - **`TimeField`** — a time _typed_ rather than chosen. - **`DatePicker`** — the same arrangement for a date. # Timeline What happened, in order, with a line through it. ## Import ```tsx import { Timeline } from '@xaui/native/timeline' ``` ## Usage ```tsx 09:12 Commande passée Paiement accepté. ``` ## The air between two entries is inside the one above them There is **no `gap` on the root**, and there cannot be: the rail runs the full height of its entry, so a gap would be a break in the line. `density` is the entry's own bottom padding — and it lives on `Timeline.Content`, not on `Timeline.Item`. A padding on the entry is _inside_ its box, so the rail, a child of that box, stops above it and the line breaks at every step. On the content it makes the row taller instead, and the rail, stretched to the row, runs through it. ## `status` is not `variant` A variant says how loud something is. These say **what happened**, which is why a timeline is the one place in the library where a per-item prop names an intent. | status | the marker | | --------- | ------------------------- | | `default` | the page's ink | | `muted` | the separator's grey | | `current` | the accent, as a **ring** | | `success` | green | | `warning` | amber | | `danger` | red | `current` is the odd one and deliberately so: a ring rather than a disc, so "being done" tells itself apart from "done" without relying on a hue. **The six markers are resolved once, on the root.** An entry picks one rather than resolving its own recipe (R5) — six resolutions on the root are six cache hits, and one per entry on a fifty-entry list is not. ### The tint reaches two of them `color` lands on `default` and `current`, and the **root names those two explicitly**. It has to: a token named `success` is a bare name, so `resolveTint` maps it to the tint like any other and a blue app would turn its green "succeeded" marker blue. A timeline's greens and reds mean what happened, and a tint that repainted them would be a tint that lied. ## The rail is two halves `Timeline.Rail` with no children draws the arrangement every timeline is: the upper half of the line, the marker, the lower half. Children replace all three. ```tsx ``` **`marker` is how a composed rail says how tall its marker is.** The upper half of the line is a height, and the only height the rail can work out on its own is the dot's — so a rail carrying a 28pt circled icon and told nothing places it eight points below the title it labels. It clamps at zero: something taller than the title's own line cannot be centred on it without hanging over the top of the entry. **Two halves rather than one line, and that is what makes `align` work.** Below the marker both are a share of the height, so it centres; above it the upper half is a fixed inset — what is left of half the title's line once the dot's own half is taken off — so the dot's centre lands on the middle of the first line of the text. One connector could do neither. That inset is **derived, not chosen**: `(lineHeight(title) - marker) / 2`. Written by hand it drifts, and a marker a point or three below the words it labels is exactly what a reader reads as a crooked list. **The end segments are left off**: the first entry has nothing above it and the last has nothing below it, and a line running off the top of a list is a list that has been cut. `force` draws one anyway, for a timeline that continues past what is on screen. A segment that is left off is still **drawn, just not painted** — only its colour goes, its flex stays. The marker's place is decided by what is above and below it, so a half that collapses to nothing lifts the first dot to the top of its entry and drops the last one to the bottom; on `center` that is the whole height of an entry's worth of error. **The rail's column is a `minWidth`, not a width.** A composed marker wider than the dot — a circled icon, a number — widens the column instead of spilling out of it and landing on the words. The line stays centred in it either way. ## The gutter `Timeline.Item` is a row with a `gap`, and that is where the air between the time, the rail and the words lives. It cannot be padding inside the rail: the rail's own slack is what centres the line in it, so widening it to make room for the text moves the line rather than the text. ## `Timeline.Leading` The column before the rail — a time, a date, a step number. **Right-aligned, a fixed width, and tabular figures**, which together are what make a column of times read as a column: letting each time be as wide as its own text is what makes them ragged, and proportional digits leave `11:06` narrower than `10:43` even after that. **It sits on the line the marker sits on** — the middle of the title's first line, not the top of the row. A time is set smaller than the title it labels, so a cell that simply starts at the top puts the time above both the dot and the words. It follows `align` for that: an inset on a `start` entry, `alignSelf: 'center'` on a `center` one. A `Text`, because that is what it almost always is. Something taller goes in a `View` you write. A time written on the **right** is content rather than a leading cell, and it has the same two requirements: put it on the title's row and give it `fontVariant: ['tabular-nums']`. ## `align` `start` (the default) puts the marker level with the first line of the title, which is what a list of events wants. `center` centres it against the whole entry, which is what a list of two-line cards wants. `itemAlign` on the root is the default; an entry's own `align` wins. ## Order is JSX order `Timeline.Leading`, then the rail, then the content — R4, and nothing here reorders them. A timeline whose times sit on the right is that JSX written the other way round. ## See also - **`List`** — for rows that are not a sequence. # Toast A notice that arrives because something happened, and leaves on its own. ## Import ```tsx import { Toast, ToastHost, useToast } from '@xaui/native/toast' ``` ## Anatomy ```tsx ;{app} // anywhere below it const { toast } = useToast() toast({ render: () => ( Enregistré Vos modifications sont sur le serveur. ), }) ``` - **`ToastHost`** — the queue, the timers and the stack. Mounted once. - **`useToast`** — how anything asks for one. - **`Toast`** — the card. Presentational, and replaceable. - **`Toast.Title`** — what happened. The one node the variant colours. - **`Toast.Description`** — the detail. - **`Toast.Actions`** — the row of things you can do about it. - **`Toast.Close`** — anything that sends this one away early. ## The split, and why it is the whole design **`Toast` does not know it is in a queue**, when it will leave, or what is stacked under it. The host owns all three. That is what lets `render` return anything at all — the queue never looks at it. `Toast` is the card this library ships, not the card the host requires. `Toast.Close` still knows which toast it belongs to without being told: the host provides the dismiss **around** each entry, and the card folds it into the context its slots read. A close button two levels down needs nothing passed to it. ## Usage ### The host ```tsx {app} ``` It renders into the nearest `PortalHost`, so the stack sits over navigation rather than inside whatever screen asked for it. ### One that waits for an answer ```tsx toast({ duration: 0, render: … }) ``` `0` keeps it until something dismisses it — for the toast that asks a question rather than reporting an answer. ### Dismissing early ```tsx const id = toast({ render: … }) dismiss(id) ``` `render` is also handed `{ dismiss }`, which is the same thing without holding the id. ## Props ### `ToastHost` | prop | type | default | description | | ------------ | ------------------- | -------- | --------------------------------- | | `placement` | `'top' \| 'bottom'` | `bottom` | Which edge the stack sits against | | `duration` | `number` | `4000` | Milliseconds. `0` keeps them | | `maxVisible` | `number` | `3` | How many cards are visible | Past `maxVisible` a card is **transparent, not discarded**. It keeps its timer and its place, and dismissing the front one promotes it into view — so a burst of six shows all six rather than losing three. ## The stack The cards are not laid out one under another. **Every one is anchored to the same edge**, and its depth is entirely in its transform, so a pile of eight costs the height of one: | depth | translateY | scale | | --------- | -------------------- | ------ | | 0 (front) | `0` | `1` | | 1 | `10` toward the edge | `0.97` | | 2 | `20` | `0.94` | Both numbers are the reference implementation's, read off their `toast.animation.ts`: `translateY: [0, 10]` and `scale: [1, 0.97]`, interpolated over the index. The shoulder a card leaves is `10 − 0.03 × height`, around 7 points on a two-line toast — enough to say there is another one, not enough to be read as a second card. **The ladder does not stop.** Their interpolation clamps the front side only, so the fourth card is genuinely further back than the third rather than sitting exactly on it and reading as one. What ends the ladder is `maxVisible`, past which the card is transparent. Newest is painted last, so it lands in front without a `zIndex`. A card past `maxVisible` takes no touches: a transparent card is still a target, and a press meant for the front one must not land on something nobody can see. ## The swipe The front card is thrown away by dragging it **away from its edge** — up on a top stack, down on a bottom one. The pile empties one card at a time, each swipe promoting the next. It goes past **50 points or 500 points a second**, the reference implementation's thresholds, and either alone is enough: distance without velocity refuses a flick that clearly meant it, velocity without distance refuses a slow deliberate push. A toast is glanced at, so both readings count. Dragged the wrong way it **resists rather than refuses** — the whole screen's travel maps onto 40 points, so the card answers the finger without pretending it can go there. Under a finger it sinks half a percent, which is meant to be felt rather than seen. The throw carries on at the speed the finger left it (`withDecay`, velocity × 1.5) and the record goes a moment later, so a hard flick leaves faster than a soft one and neither is cut off at the frame the finger lifted. **Only the card in front.** The ones behind show a seven-point shoulder — a target under any reasonable minimum, and dragging the second card out from under the first reads as a glitch rather than as a dismissal. The gesture runs on `react-native-gesture-handler`, an **optional** peer of this package. It is reached only through `@xaui/native/toast`, so a project that never imports a toast never loads it. Set `isSwipeable={false}` to leave the pile alone. ### `Toast` | prop | type | default | description | | --------- | -------------- | --------- | --------------------------- | | `variant` | `ToastVariant` | `default` | Colours the **title** only | | `radius` | `RadiusKey` | — | Overrides the card's corner | `default` · `accent` · `success` · `warning` · `danger`. ## The variant paints the title, and nothing else A red card sliding in from the edge of the screen reads as the app breaking; a red line of text reads as the thing you just did failing. The surface stays the theme's floating one whatever happened — which is also what lets two toasts of different kinds stack without the pile looking like a paint chart. It uses the **soft** foregrounds rather than the full colours: a toast is read at a glance and from the corner of the eye, and `danger` at full strength on an overlay surface is a shout where the soft one is a statement. The description stays muted whatever the variant. The title already said in colour what kind of thing happened, and saying it twice leaves nothing for the eye to rank. ## Motion It **slides** from the edge it will sit against, and leaves the same way — 260 ms in, 180 ms out. That is what separates it from every other overlay here. A dialog and a popover appear where they are, because they were asked for; a toast arrives, because something happened. Motion across the screen's edge is the difference between the two. ## Accessibility The card is an `alert` with `accessibilityLiveRegion="polite"`, so it is announced when it arrives rather than when the reader reaches it — a toast that waits its turn in the reading order has usually gone by the time it is read. `useToast` outside a host **warns and does nothing** rather than throwing. A missing host is a setup mistake in the app shell, and a screen that crashes on its way to reporting that a save succeeded has turned a good outcome into a bad one. ## It replaces `Snackbar` The legacy component is `Snackbar`. It is the same object under two names, and the reference implementation calls it `toast`; the roadmap's P5.18 closes with this. | Legacy | v1 | | ----------------------------- | -------------------------------------- | | `` | `…` | | `action={{ label, onPress }}` | a `Button` inside `` | | `duration` | `duration` on the call, or on the host | | `themeColor="danger"` | `variant="danger"` | # ToggleButton ## Overview A button that keeps whether it is active. It is for independent choices such as Like, Favourite, Pin or Mute — one press turns the choice on, the next turns it off. ```tsx import { ToggleButton } from '@xaui/native/toggle-button' ;Like ``` It follows the `Button` scale and composition model, but selection is part of its value, not a momentary pressed visual. ## Anatomy ```tsx ``` - **`ToggleButton`** — the pressable root. It owns or receives selection, resolves the recipe once and publishes the selected styles and values. - **`ToggleButton.Icon`** — an icon that inherits the root's size and current colour. - **`ToggleButton.Label`** — single-line text that inherits the current colour and type. The view depth is one: `PressableFeedback > (Icon | Text)`. JSX order is screen order, and neither slot carries a margin. ## Usage ### Uncontrolled `defaultSelected` supplies the initial value; the root owns it afterwards. ```tsx Favourite ``` ### Controlled ```tsx const [liked, setLiked] = useState(false) Like ``` `onSelectedChange` fires with the proposed next value in both modes. A controlled root does not store that value; the caller decides whether it moves. ### Changing the mark A render child receives the current state. This is how the reference's outline heart turns into a filled heart without a prop configuring the inside of the component. ```tsx {({ isSelected }) => ( <> Like )} ``` ### Text shorthand Stringifiable children are wrapped in `ToggleButton.Label` automatically. ```tsx Pin ``` ### Exclusive group `ToggleButton.Group` gives a set one selected value. A member joins by naming `value`; it can still be nested in a layout, because the group communicates through context instead of walking its children. ```tsx Start Center End ``` The group is horizontal and wrapping by default. `orientation="vertical"` makes a column. Its `variant`, `size`, `radius`, `color` and `isDisabled` are member defaults; a member can still name its own appearance, while a disabled group always disables every member. ### Icon only ```tsx {({ isSelected }) => ( )} ``` `isIconOnly` removes the horizontal padding and squares the root on its fixed height. An accessible label is required in practice; its absence warns in development. ### Style props Every node accepts its React Native style keys directly, after the recipe and before `style`: ```tsx Pin Pin ``` They are raw React Native values, not hidden token steps. `style` remains the last word. ## Props `ToggleButton` accepts `PressableFeedback` props and `ViewStyle` props, plus: | Prop | Type | Default | Notes | | ------------------ | ---------------------------------------------- | ----------- | --------------------------------------- | | `variant` | `'primary' \| 'secondary' \| 'ghost'` | `'primary'` | Visual emphasis | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Height, padding, gap, radius and type | | `radius` | `RadiusKey` | by size | Overrides the shape the size chose | | `color` | `string` | — | Raw tint following the variant emphasis | | `value` | `string` | — | Joins a `ToggleButton.Group` | | `isSelected` | `boolean` | — | Controlled value | | `defaultSelected` | `boolean` | `false` | Initial uncontrolled value | | `onSelectedChange` | `(isSelected: boolean) => void` | — | Receives the proposed next value | | `isDisabled` | `boolean` | `false` | Dims the root and stops interaction | | `isIconOnly` | `boolean` | `false` | Square root with no horizontal padding | | `asChild` | `boolean` | `false` | Merges the root into one child | | `children` | `ReactNode \| (state => ReactNode)` | — | Text, composed slots or a render child | | `style` | `StyleProp \| (state => StyleProp)` | — | Last style layer | ## Slots ### `ToggleButton.Label` Accepts `Text` props and `TextStyle` props. `numberOfLines` defaults to `1` so a long label truncates instead of deforming the fixed-height control. ### `ToggleButton.Icon` Accepts `Icon`'s three forms: `as`, an SVG child, or `source`. `size` and `color` inherit from the root; an explicit value wins. `useToggleButton()` is exported for custom slots. It returns the resolved label and icon values with `isSelected`, `isPressed` and `isDisabled`, and throws by name outside a root. ## Variants | `variant` | Resting background | Resting content | Selected background | Selected content | | ----------- | ------------------ | ----------------------- | ------------------- | ---------------------- | | `primary` | `default` | `defaultForeground` | `accent` | `accentForeground` | | `secondary` | `defaultSoft` | `defaultSoftForeground` | `accentSoft` | `accentSoftForeground` | | `ghost` | transparent | `foreground` | transparent | `accent` | `primary` is the filled default. `secondary` keeps a softer fill both at rest and when selected. `ghost` never paints a background; selection changes only its text and icon colour. `color` replaces the relevant family through the tint pass rather than entering the style cache. ## Accessibility - The root has `accessibilityRole="button"` by default and remains overridable. - `accessibilityState.selected` always reflects the current value and is merged with state supplied by the caller. - `aria-pressed` mirrors the same value for React Native Web. - In a group, members use the `radio` role and `aria-checked`; the group is announced as a `radiogroup`. - `accessibilityState.disabled` follows `isDisabled`. - An icon-only root warns without `accessibilityLabel` or `aria-label`. - Press handlers are composed: the toggle, `onPress` and the internal pressed state all survive together. ## Migration from legacy There is no legacy `ToggleButton`; this is a net-new v1 component. A legacy `Button` that manually switched its appearance can move its boolean directly: | Before | After | | ------------------------------------------- | --------------------------------------------------------------- | | ` ``` Nothing in a component's recipe sets a width, so React Native's own behaviour answers: stretched in a column, hugging its content in a row. That is why there is no `fullWidth` prop anywhere in the library — and `width="100%"` says it explicitly when it is wanted. ### Nested ```tsx Facture #2024-118 payée ``` ### As another element ```tsx … ``` ## Props | Prop | Type | Default | Notes | | --------- | ---------------------- | ------- | ---------------------------------------- | | `asChild` | `boolean` | `false` | The child element becomes the axis (R12) | | `style` | `StyleProp` | — | Applied last | Plus every `ViewStyle` key as a prop (R14), **minus `flexDirection`**, and every `ViewProps` React Native defines. `flexDirection` is not exposed because it is the component's identity: a `Row` that could be told to lay out as a column would be a `View` with a longer name. A genuine reversal goes through `style={{ flexDirection: 'row-reverse' }}` — and note that `row` already flips in an RTL layout, which is the case that usually matters. ## `Stack` — things on top of each other ```tsx Titre en surimpression ``` `Row` places children across, `Column` down, `Stack` on top of each other. - **`Stack`** declares `position: 'relative'` — the containing block. Without it an absolutely positioned child resolves against a distant ancestor, often the whole screen. - **`Stack.Item`** declares `position: 'absolute'`. Where the layer sits is R14: `top`, `bottom`, `start`, `end` and `zIndex` are `ViewStyle` keys already exposed as props. **The first child stays in the flow and gives the stack its size**; the items float over it without affecting it. Overlaying is composed rather than inferred. A `Stack` that absolutely positioned every child but the first would have to guess which one sets the size, and would change meaning the day a caller reordered them. Note `start` / `end` rather than `left` / `right`: R13 bans the physical pair, and these flip with the writing direction — which is what a caption over an image should do. ## `Grid` — a fixed number of columns, wrapping ```tsx ``` **The column width is measured, not expressed as a percentage.** `width: '33.33%'` resolves against the content box and knows nothing about the gaps between cells, so three of them plus two gaps overflow their row and the third wraps — the classic gutter bug. The root reads its own width through `onLayout` and publishes the exact column width instead. For one frame, before the measurement lands, a cell falls back to the naive percentage. The alternative — rendering nothing until measured — flashes an empty grid. Every child is wrapped in a one-column cell, so components are dropped straight in. `Grid.Item` is only needed for a span, and its width carries the gaps it swallows: two columns plus the gap between them line up exactly with the cells above. `gap` is `Grid`'s own prop rather than one of R14's, because the root has to **read** it to size the columns. It carries React Native's meaning all the same — `gap={8}` is 8 points, the same gap `Row` takes as a style prop. `Grid` has no `asChild`: it measures itself, so it has to be the node it renders. `Grid.Item` has none either — the cell _is_ the layout, and merging it into a caller's element would hand them a width they did not ask for. ## Why `Column` exists at all It declares React Native's default, so it changes nothing on its own. It is there for what it **says**: in a layout whose axes are all named, direction is read rather than inferred, and a `Column` keeps its own direction the moment it is composed into a `Row`. ## What is deliberately not here The legacy `view/` folder had twenty-six entries. Four are kept — the two axes, `Stack` and `Grid`. Three lost their reason to exist when R14 landed, and are not being ported: | Legacy | v1 | | ----------------------------------- | ----------------------------------------------------------- | | `` | `` — R14, and one view node fewer | | `
` | `alignItems="center" justifyContent="center"` on the parent | | `` between two items | `justifyContent="space-between"` on the parent | Each of them added a view node to say what a style prop on an existing node already says, and view depth is exactly what the v1 `Button` set out to reduce. `Container` goes the same way: a `View` with a background is `backgroundColor` on the node that needed it. The rest of the legacy lot — `Flex`, `Expanded`, `Flexible`, `SizedBox`, `AspectRatio`, `FractionallySizedBox`, `Wrap`, `Positioned`, `Align`, `Margin` — is either R14 or `Stack`, and none of it is planned. # WheelPicker A column of options you turn, and the one at the middle is the answer. ## Import ```tsx import { WheelPicker } from '@xaui/native/wheel-picker' ``` ## Anatomy ```tsx 09 ``` - **`WheelPicker`** — the wheel. It resolves the recipe, computes the geometry once and draws the band. It has no value of its own. - **`WheelPicker.Column`** — one turning column, and **the thing that has a value**. - **`WheelPicker.Item`** — one row. A `Text`, not a control. ## The column has the value, not the wheel A time is two columns and a date is three, so a wheel with a single value would be a wheel that can only ever be one of them. The `WheelDatePicker`, `WheelTimePicker` and `WheelDateTimePicker` of P5.25 are all **this component with a different set of columns** and the arithmetic to fill them — which is why it is here first, and why the roadmap calls it "the spinning column the three below share". ```tsx {HOURS.map(h => ( {h} ))} {MINUTES.map(m => ( {m} ))} ``` ## The scroll is the control There is no press to select. The row at the middle **is** the choice, so a column snaps to a row and reports whichever one it stopped at — which is what makes this a wheel rather than a short list, and why the rows are `Text` nodes rather than pressables. A row you could tap would be a second way to choose that the band does not describe. **It reports at rest, never while turning.** One flick passes nine rows, and every one of them is a value some caller would have written to a form. `onScrollEndDrag` covers a slow drag that stops without momentum and `onMomentumScrollEnd` covers the flick; both are needed, and neither fires for the other. Coming back to rest on the row you started from fires nothing at all. ## The band is the root's One shape laid across every column, rather than one per column: two columns at different widths would show the seam between their two bands. It is `pointerEvents: 'none'`, so it marks the middle without taking the touch that turns the wheel under it. It is written by the root rather than by the caller, unlike the `Tabs`'s indicator — that one is a choice about a decoration, and this is where the middle _is_. ## The rows fade and lean away Not decoration: it is the whole of what says this is a drum with more of it out of sight rather than a list that happens to have stopped. The row at the middle is upright, full strength and in the band's colour; two rows out it is at a quarter opacity, 82% of its size and turned 55°. The turn is read from the column's scroll offset **on the UI thread**, through a shared value. A position crossing the bridge every frame would animate at the rate React re-renders rather than at the rate the finger moves — which on a fast flick is the difference between a drum and a slideshow. ## `visibleCount` is forced odd ```tsx … ``` The whole control is built on there being a **middle row**. An even count has two rows equally near the centre, and the band would sit over the seam between them. A count is rounded **up** to the next odd number, because a caller who asked for four wanted more than three, not less. It is a raw number rather than a token, like the `ProgressCircle`'s `radius`, so it lives outside the style cache: the wheel's height is `visibleCount` rows and is applied after the recipe rather than inside it. ## There is no `loop` An endless drum is not a scroll view with a flag on it — it is a list with no end, faked by rewriting the data around the finger and jumping the offset back whenever it drifts too far. That trick belongs to the caller's data, where the caller knows how many months there are; here it would be a component quietly renumbering its own children. ## Sizes | `size` | Row | Type | | ------ | --- | ----- | | `sm` | 32 | 14/20 | | `md` | 36 | 16/24 | | `lg` | 44 | 18/28 | **`size` is the row's height, never the wheel's width.** A wheel spans its parent and its columns divide that between them, which is RN's own behaviour — a caller who wants a narrow one writes a width on the wheel or a `flex` on a column. `md` is iOS's picker row measured: 36 points, which is what puts five of them in the 180 the platform's own wheel is. The row heights are off the spacing grid on purpose, like the `Slider`'s rail: how tall a row has to be before a list of them reads as something you can aim at has nothing to do with the gaps between things. ## Variants and colour | `variant` | Band | The row on it | | ----------- | ------------- | ---------------------- | | `primary` | `accentSoft` | `accentSoftForeground` | | `secondary` | `default` | `defaultForeground` | | `tertiary` | two hairlines | `foreground` | | `ghost` | — | `foreground` | Four emphasis levels and **no intent**: a wheel reports a choice, and a choice is neither a success nor a danger. What the variant names is the band. `tertiary` is two hairlines rather than a box, because the band marks a row in a column of rows and a full border round it reads as a field the wheel is inside. `ghost` names neither a fill nor a border, and that is a design rather than an omission — the rows already say which one is chosen, and on a busy screen the band is the part that reads as chrome. `color` is a raw value (R7) and lands on the band. `secondary` names `defaultForeground` rather than `foreground` for exactly that: `resolveTint` reads the role off the token's own name, a bare `foreground` is a neutral and maps to the tint **itself**, and a band painted the same colour as the row sitting on it is a row you cannot read. ## Props ### `WheelPicker` Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus: | Prop | Type | Default | Notes | | -------------- | ---------------------- | ------------- | --------------------------------- | | `variant` | `WheelPickerVariant` | `'secondary'` | What the band is | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The row's height and its type | | `radius` | `RadiusKey` | `'lg'` | The band's corner | | `color` | `string` | — | A hex tint, on the band | | `visibleCount` | `number` | `5` | Rows on screen. Forced odd, min 3 | | `isDisabled` | `boolean` | `false` | Every column, and none opts out | | `asChild` | `boolean` | `false` | Merge into the single child | ### `WheelPicker.Column` Everything `ScrollView` accepts, plus the `ViewStyle` keys as props (R14), plus: | Prop | Type | Default | Notes | | --------------- | ------------------------- | ------- | -------------------------- | | `value` | `string` | — | Controlled | | `defaultValue` | `string` | first | Uncontrolled starting row | | `onValueChange` | `(value: string) => void` | — | At rest only | | `isDisabled` | `boolean` | `false` | Stops this column's scroll | ### `WheelPicker.Item` `value`, its children, and the `TextStyle` keys as props (R14). ## Extending it `useWheelPicker()` carries the resolved styles and the geometry — the row height above all, since the snap interval, the padding and the rest position are all computed from it. `useWheelPickerColumn()` carries the live offset, the resting index and each row's position. Both are exported (R10), and both throw by name outside their parent. ## Accessibility - **The column carries the label**, not the rows: `accessibilityLabel="Heures"` on a column is what a screen reader announces, and every row announcing itself would announce the whole drum. - Rows other than the chosen one are hidden from the accessibility tree for the same reason. - `isDisabled` stops the scroll rather than dimming a control that still moves. # Widget A thing on a dashboard: a title, a card, and a line about it underneath. ## Import ```tsx import { Widget } from '@xaui/native/widget' ``` ## Usage ```tsx Tokens consommés 30 derniers jours Mis à jour il y a 2 minutes ``` ## It is a card held in a soft frame That is the whole of it, and it is worth stating plainly because a widget and a card look alike in a screenshot. A **card** puts its content flush on its own ground: the header, the body and the footer are all the same surface, and what separates them is space. A **widget** is a quiet `defaultSoft` ground with no border. The header and the footer sit straight on it, and `Widget.Content` is the one card — one step **up** from the frame (`surface` in light, `surfaceSecondary` in dark; see [No variant](#no-variant)). What is inside the card reads as the thing being shown; the title and the timestamp around it, on the soft ground, read as the frame's own labels — **not** part of the thing. Reach for a card when the content _is_ the card's content. Reach for a widget when the frame is a label around something with its own edges. ## The card's corner is derived, not chosen An inner corner is the outer one **less the gap between them** — here the frame's **vertical** padding, the band above and below the card that a near-full-width card visibly sits within: ``` cardRadius = theme.radius[radius] − theme.spacing(paddingY) ``` Two arcs that do not follow that rule run at different rates, and the inset stops reading as a card the frame is holding and starts reading as a sticker laid on it. It is the one thing this component's shape depends on, so it is not a prop. It is clamped at zero, because a large padding under a small corner would otherwise ask for a negative radius — an `xs` widget at `radius="sm"` has a square card, correctly. A `radius` prop therefore moves **both** corners. That is what the forty `size × radius` compounds in the recipe are for: an axis sees only its own prop, and this value needs two. ## No variant A widget has **one look** — there is no `variant`, and no primary/secondary/tertiary. The frame is always `defaultSoft`. The card is `surface` in light — white against a near-white frame, so the step up is obvious — and `surfaceSecondary` in dark, where `surface` would land on top of what `defaultSoft` composites to and vanish (and dark mode drops the surface shadow, so colour is the only separation left). What moves is structural: - `size` — the padding, the gaps, the corner and the type. - `radius` — the frame's corner (the card's follows it). - `isElevated` — whether the card is lifted off the frame. ## Slots | slot | node | what it is | | -------------------- | ------ | ------------------------------------------------------------------- | | `Widget` | `View` | the soft frame | | `Widget.Header` | `View` | the row above the card — heading on one side, anything on the other | | `Widget.Heading` | `View` | the title and its description, as one block | | `Widget.Title` | `Text` | what this is. An `accessibilityRole="header"` | | `Widget.Description` | `Text` | what it is showing — the period, the unit, the caveat | | `Widget.Content` | `View` | the card | | `Widget.Footer` | `Text` | when it was last updated, over what range, what it excludes | Every one of them is optional and none of them has a fixed order — a widget that is a card and nothing else is a `Widget` with one `Widget.Content` in it. ### Why `Heading` exists The gap between a title and its subtitle is a different gap from the one between that block and the legend beside it, and R4 puts layout on a root — so two gaps need two roots. It also shrinks rather than pushing, so a long title wraps instead of squeezing the trailing content off the row. ### `Widget.Content` is a ground, not a chart slot A figure, a table, a list of rows, a map — whatever the widget is showing goes there, and the only thing the slot knows about it is that it is a level up from the frame. It **clips**, which matters for the case this component exists for: a chart's own box is a rectangle, and a rectangle in a rounded card shows its corners. ### `Widget.Footer` is a `Text` Because that is what it almost always is — a timestamp, a range, a caveat. A footer that needs a control in it is a `View` you write, and this slot is what you put in it: ```tsx Mis à jour il y a 2 minutes ``` ## `isElevated` is on by default The soft frame stays flat against the page; the shadow lands on the **card**, and that is what separates it from the frame it sits in. A widget is one of several on a dashboard, and the raised card is what makes each one read as its own object. In **dark mode** there is no shadow to land — `theme.shadows.surface` is empty by design — so the card leans entirely on the colour step, which is why it uses `surfaceSecondary` there rather than `surface`. ```tsx ``` Flat, the card falls back on that colour step alone — enough on most themes, and the right call when the widget is already inside a bordered container that does the separating. ## Size `size` moves the padding, the gaps, the corner and the type — **never a height**. A widget is as tall as what is in it. | size | frame padding (x / y) | card padding | corner | title | | ---- | --------------------- | ------------ | ------ | ----- | | `xs` | 2 / 3 | 2 | `xl` | `sm` | | `sm` | 2.5 / 3.5 | 2.5 | `2xl` | `md` | | `md` | 3 / 4 | 3 | `2xl` | `lg` | | `lg` | 3.5 / 5 | 3.5 | `3xl` | `xl` | The frame's **side** inset is one step tighter than its vertical one: the card carries most of the width, so a wide band on either side of it is margin the frame does not need. On the sides it comes out level with the card's own inset, which keeps the horizontal rhythm even from the frame edge to the content; the vertical band stays wider, and is what the card's corner is derived from. ## Accessibility `Widget.Title` is an `accessibilityRole="header"`, which is what a screen reader jumps between — so a dashboard of widgets is navigable by their titles. Pass your own `accessibilityRole` to override it, on a widget whose title is not a heading. The root has no role of its own: a widget is a grouping, and what is inside it carries the semantics. ## Props Every node takes R14's style props for its own style type — `ViewStyle` on the views, `TextStyle` on the three texts — and forwards `ref`, `testID` and the a11y props. `useWidget()` is exported: it returns the resolved styles, so a slot of your own — a toolbar in the header, a second card under the first — is written the same way the shipped ones are.