@payfit/unity-components
Version:
285 lines (221 loc) • 8.4 kB
Markdown
---
name: unity-find-component
description: >
Load when selecting a Unity component is unclear or before creating a custom
UI primitive that may already exist in @payfit/unity-components. Use it to
find the existing component, then fall back to React Aria plus uy: classes
only when Unity has no fit.
metadata:
type: core
library: '@payfit/unity-components'
library_version: '2.x'
sources:
- 'PayFit/hr-apps:libs/shared/unity/components/src/index.ts'
- 'PayFit/hr-apps:libs/shared/unity/icons/src/components/icon/parts/IconSprite.tsx'
- 'PayFit/hr-apps:libs/shared/unity/icons/src/generated/index.ts'
- 'PayFit/hr-apps:libs/shared/unity/components/OVERVIEW.md'
- 'PayFit/hr-apps:AGENTS.md'
---
Routing skill for selecting a Unity component before writing UI code. Walk
the catalog, then the decision tree, then the commonly-confused pairs.
## Component Catalog
Inspect the current exports in `src/index.ts`, then use the decision tree below.
For a categorized catalog of commonly used exports, read
[references/component-catalog.md](references/component-catalog.md) only when
component discovery requires it.
## Decision Tree
For every UI need, walk these three levels in order. Stop at the first that
fits.
### Level 1 — Use Unity directly
If a named export covers the use case, import it. No abstractions over the
top.
```tsx
import {
Button,
Dialog,
DialogActions,
DialogContent,
Pill,
} from '@payfit/unity-components'
export function ConfirmDelete({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
return (
<Dialog isOpen={isOpen} onOpenChange={onClose}>
<DialogContent>
<Pill color="danger">Destructive</Pill>
</DialogContent>
<DialogActions>
<Button variant="secondary" onPress={onClose}>
Cancel
</Button>
<Button color="danger" onPress={onClose}>
Delete
</Button>
</DialogActions>
</Dialog>
)
}
```
### Level 2 — Fall back to React Aria + uy:\* classes
Build your own primitive only when Unity has no equivalent. Compose React
Aria primitives, style with the `uy:` prefix, and merge classes with
`uyMerge` / `uyTv`.
```tsx
import { uyTv } from '@payfit/unity-themes'
import { ToggleButton } from 'react-aria-components'
const toggleStyles = uyTv({
base: 'uy:inline-flex uy:items-center uy:gap-100 uy:rounded-100 uy:px-200 uy:py-100',
variants: {
isSelected: {
true: 'uy:bg-surface-action uy:text-content-on-action',
false: 'uy:bg-surface-secondary uy:text-content-neutral',
},
},
})
export function CustomPivot({ label }: { label: string }) {
return (
<ToggleButton className={({ isSelected }) => toggleStyles({ isSelected })}>
{label}
</ToggleButton>
)
}
```
### Level 3 — Midnight (last resort, deprecated)
Only when (a) Unity has no equivalent, (b) React Aria + `uy:*` cannot
realistically rebuild it, and (c) the feature ships against a deadline. Open
a follow-up to migrate. Per `AGENTS.md`, Midnight is deprecated; never
import it into a new module.
## Commonly Confused Pairs
- `Badge` vs `Pill`: `Badge` is a numeric/dot indicator anchored to another
element (notification count). `Pill` is a standalone label/status chip
with text content.
- `Card` vs `SelectableCard...` vs `NavigationCard`: `Card` is the generic
container. `SelectableCardCheckboxGroup` / `SelectableCardRadioGroup`
wrap cards as form inputs. `NavigationCard` wraps a card as a router-aware
link.
- `Button` vs `IconButton` vs `RawLinkButton`: `Button` for actions with
text. `IconButton` / `CircularIconButton` for icon-only actions
(requires `aria-label`). `RawLinkButton` renders as an anchor but styled
like a button — use when the action navigates.
- `Menu` vs `Popover`: `Menu` is a list of actionable items keyed by
keyboard (Enter/Arrow). `Popover` is a free-form floating panel and
requires a `title`.
- `Dialog` vs `PromoDialog`: `Dialog` for confirmation / edit flows.
`PromoDialog` for marketing / onboarding announcements; requires
`PromoDialogHero`.
- `Table` vs `DataTable`: `Table` is the layout primitive (header, body,
rows, cells) with no behavior. `DataTable` wires Tanstack Table for
sorting, filtering, pagination, virtualization, bulk actions.
- `ErrorState` vs `Alert`: `ErrorState` is a full-area empty-replacement
for "this section failed to load." `Alert` is an inline banner that
coexists with surrounding content.
## Icon Source Convention
`Icon` takes a typed `src` prop of type `UnityIcon` — a literal union of
PascalCase names with a `Filled` or `Outlined` suffix (~310 values, from
`@payfit/unity-icons`). Strings outside that union are a type error. Do
not cast to `UnityIcon`; the cast bypasses the sprite-id guard and the
icon silently renders empty.
```tsx
import type { UnityIcon } from '@payfit/unity-icons'
import { Icon } from '@payfit/unity-components'
const icon: UnityIcon = 'MagnifyingGlassOutlined'
;<Icon src={icon} size={20} />
```
## Forms Notice
Tanstack Form (`useTanstackUnityForm`) is the only supported form system.
The React Hook Form path — `useUnityForm` plus the legacy `TextField` /
`SelectField` / `NumberField` etc. RHF wrappers exported from the same
index — is deprecated. Do not author new code with `useUnityForm`. See
`unity-tanstack-form`.
## Common Mistakes
### HIGH Hand-roll component that already exists
Wrong:
```tsx
const Tag = ({ children }) => (
<span className="uy:rounded-full uy:px-200 uy:py-100 uy:bg-surface-primary">
{children}
</span>
)
```
Correct:
```tsx
import { Pill } from '@payfit/unity-components'
;<Pill>{children}</Pill>
```
The hand-rolled span re-derives Pill's tokens by guesswork and drifts from
the design-system source-of-truth on every theme update.
Source: libs/shared/unity/components/src/components/pill/Pill.tsx
### HIGH Use React Aria primitive directly when Unity wraps it
Wrong:
```tsx
import { Button as AriaButton } from 'react-aria-components'
;<AriaButton>Click</AriaButton>
```
Correct:
```tsx
import { Button } from '@payfit/unity-components'
;<Button variant="primary">Click</Button>
```
The bare React Aria Button has no Unity theming, intl, or styling
defaults; you ship an unstyled element with no `uy:*` classes.
Source: libs/shared/unity/components/src/components/button/Button.tsx
### HIGH Reach for Midnight when Unity has an equivalent
Wrong:
```tsx
import { Button, Modal } from '@payfit/midnight'
```
Correct:
```tsx
import { Button, Dialog } from '@payfit/unity-components'
```
Midnight is deprecated; new screens that import it cannot match the Unity
theme tokens and will require a migration pass later anyway.
Source: AGENTS.md "Do NOT use (deprecated)"
### MEDIUM Combine Input + FormField when \*Field exists
Wrong:
```tsx
<FormField label="Name" error={errors.name}>
<Input {...register('name')} />
</FormField>
```
Correct:
```tsx
<form.AppField name="name">
{field => <field.TextField label="Name" />}
</form.AppField>
```
Manual `FormField` + `Input` skips the label-for/aria-describedby wiring,
the `field.state.meta` error plumbing, and the required-state inference
that the `*Field` components handle.
Source: libs/shared/unity/components/src/components/form-field/FormField.tsx; index.ts:205-223
### HIGH Pass an untyped string to Icon src and guess the name
Wrong:
```tsx
<Icon src="search" size={20} />
<Icon src="trash-filled" />
const name: string = 'trash'
<Icon src={name as UnityIcon} />
```
Correct:
```tsx
import { Icon } from '@payfit/unity-components'
import type { UnityIcon } from '@payfit/unity-icons'
<Icon src="MagnifyingGlassOutlined" size={20} />
<Icon src="TrashFilled" />
type Props = { icon: UnityIcon }
```
The `UnityIcon` literal union encodes the exact sprite ids; lowercase or
kebab-case strings have no matching `<symbol id>` in the injected sprite,
so `<use href="#search">` resolves to nothing and the SVG renders empty.
Source: libs/shared/unity/icons/src/components/icon/parts/IconSprite.tsx; generated/index.ts (UnityIcon type)
## See also
- For projects not yet configured for Unity, use the repository's
`@payfit/nx-tools:setup-unity` generator
- `unity-migrate-from-midnight` — when Level 3 fallback hits a Midnight
screen, follow this skill to replace it
- `unity-tanstack-form` — the only supported form authoring path