@podoba/react 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/package.json +39 -0
- package/src/components/button.tsx +53 -0
- package/src/components/checkbox.tsx +64 -0
- package/src/components/context-menu.tsx +397 -0
- package/src/components/dialog.tsx +203 -0
- package/src/components/disclosure.tsx +36 -0
- package/src/components/dropdown-menu.tsx +143 -0
- package/src/components/input.tsx +70 -0
- package/src/components/radio.tsx +72 -0
- package/src/components/section-tabs.tsx +103 -0
- package/src/components/select.tsx +115 -0
- package/src/components/separator.tsx +36 -0
- package/src/components/switch.tsx +40 -0
- package/src/components/tabs.tsx +40 -0
- package/src/components/text.tsx +68 -0
- package/src/components/textarea.tsx +59 -0
- package/src/components/toast.tsx +177 -0
- package/src/components/tooltip.tsx +66 -0
- package/src/components/view-toggle.tsx +101 -0
- package/src/index.ts +37 -0
- package/src/layout/app-shell.tsx +184 -0
- package/src/layout/card.tsx +41 -0
- package/src/layout/page-container.tsx +36 -0
- package/src/layout/persistent-page-shell.tsx +134 -0
- package/src/layout/section.tsx +35 -0
- package/src/layout/topbar.tsx +220 -0
- package/src/utils/uic.ts +232 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { ComponentProps, Key, ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button as RACButton,
|
|
4
|
+
ComboBox as RACComboBox,
|
|
5
|
+
type ComboBoxProps as RACComboBoxProps,
|
|
6
|
+
Input as RACInput,
|
|
7
|
+
Label,
|
|
8
|
+
ListBox,
|
|
9
|
+
ListBoxItem,
|
|
10
|
+
type ListBoxItemProps,
|
|
11
|
+
Menu as RACMenu,
|
|
12
|
+
MenuItem as RACMenuItem,
|
|
13
|
+
type MenuItemProps,
|
|
14
|
+
MenuTrigger,
|
|
15
|
+
Popover,
|
|
16
|
+
} from 'react-aria-components'
|
|
17
|
+
import { uic } from '../utils/uic'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Topbar — sticky application header (ported pattern from gs-manager's
|
|
21
|
+
* `Topbar` / `AppShell.topbar`; gs AppHeader bar ~52px → `h-14`).
|
|
22
|
+
*
|
|
23
|
+
* The gs-manager original is a flex row (`justify-between`, `height: 72px`,
|
|
24
|
+
* bottom border, background) with a left "brand row" (logo + primary subnav)
|
|
25
|
+
* and a right "actions" cluster (theme toggle, user info, logout). That SCSS
|
|
26
|
+
* intent is reimplemented here in Tailwind via `uic`, decomposed into slots so
|
|
27
|
+
* `@app/ui` consumers compose their own brand / nav / actions content:
|
|
28
|
+
*
|
|
29
|
+
* <Topbar>
|
|
30
|
+
* <Topbar.Brand>…</Topbar.Brand>
|
|
31
|
+
* <Topbar.Nav>…</Topbar.Nav> // grows to fill, holds NavLink items
|
|
32
|
+
* <Topbar.Actions>…</Topbar.Actions>
|
|
33
|
+
* </Topbar>
|
|
34
|
+
*
|
|
35
|
+
* Landmark: renders a `<header>` so it is an accessible banner landmark.
|
|
36
|
+
* AppShell keeps it visually sticky via its grid rows (`auto 1fr`).
|
|
37
|
+
*/
|
|
38
|
+
// gs-manager `Topbar.topbar`: title/logo left, nav + actions clustered on the
|
|
39
|
+
// right — with a 1px bottom border (`--color-border`). gs sets
|
|
40
|
+
// `padding: 0 spacing-6`; the horizontal page padding is supplied by AppShell's
|
|
41
|
+
// sticky topbar row, so only the fixed height + border live here. The border sits
|
|
42
|
+
// on this header so it aligns (inset) with the padded content below. Height is the
|
|
43
|
+
// gs AppHeader bar (~52px) → `h-14` (56px).
|
|
44
|
+
const TopbarRoot = uic('header', {
|
|
45
|
+
displayName: 'Topbar',
|
|
46
|
+
baseClass: 'flex h-14 w-full items-center gap-5 border-b border-border',
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// gs-manager `.logo`: nav-tab-sized label (13px compact, weight-500, tight
|
|
50
|
+
// tracking) — matches the AppHeader nav-item scale, NOT a page heading.
|
|
51
|
+
const TopbarBrand = uic('div', {
|
|
52
|
+
displayName: 'Topbar.Brand',
|
|
53
|
+
baseClass:
|
|
54
|
+
'flex min-w-0 items-center gap-3 text-compact leading-4 font-medium tracking-tight text-fg',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// `ml-auto` pushes the nav (and the actions after it) to the right, matching gs's
|
|
58
|
+
// title-left / nav-right layout.
|
|
59
|
+
const TopbarNavBase = uic('nav', {
|
|
60
|
+
displayName: 'Topbar.Nav',
|
|
61
|
+
baseClass: 'ml-auto flex min-w-0 items-center gap-2 overflow-x-auto',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// `@app/ui` ships no i18n — the accessible name of the nav landmark is REQUIRED
|
|
65
|
+
// from the consumer (translated there), never an English default baked in here.
|
|
66
|
+
type TopbarNavProps = ComponentProps<typeof TopbarNavBase> & { 'aria-label': string }
|
|
67
|
+
const TopbarNav = (props: TopbarNavProps) => <TopbarNavBase {...props} />
|
|
68
|
+
|
|
69
|
+
const TopbarActions = uic('div', {
|
|
70
|
+
displayName: 'Topbar.Actions',
|
|
71
|
+
baseClass: 'ml-auto flex items-center gap-4',
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* NavLink — a single primary-navigation item. Mirrors gs-manager's AppHeader
|
|
76
|
+
* nav-tab styling (`Button.module.scss` `_tab`): `6px 13px` padding
|
|
77
|
+
* (→ `py-[6px] px-[13px]`), `radius-sm` (2px) corners, 13px dense text, neutral-400
|
|
78
|
+
* foreground → neutral-100 fill on hover/active. The active tab is the SAME weight
|
|
79
|
+
* as the resting tab (only the fill + color change — gs `_tab` has no weight bump).
|
|
80
|
+
* gs maps BOTH `--color-background-hover` and `--color-background-active` to
|
|
81
|
+
* `--color-neutral-100`, so a single `surface-muted` token covers both states
|
|
82
|
+
* (no distinct active token to map). Renders an `<a>` by default; pass `asChild`
|
|
83
|
+
* to delegate to a router `Link`.
|
|
84
|
+
*
|
|
85
|
+
* Active state keys on the bare `data-active` attribute (presence). `@buzola/router`'s
|
|
86
|
+
* `<Link>` sets `data-active=""` when the target matches the current URL (and, with
|
|
87
|
+
* `activeExact={false}`, on prefix match too), so the selector must be `data-[active]`,
|
|
88
|
+
* NOT `data-[active=true]` — the latter never matches buzola's empty-string value.
|
|
89
|
+
*/
|
|
90
|
+
const TopbarNavLink = uic('a', {
|
|
91
|
+
displayName: 'Topbar.NavLink',
|
|
92
|
+
baseClass:
|
|
93
|
+
'inline-flex items-center rounded-sm px-[13px] py-[6px] text-compact leading-4 whitespace-nowrap ' +
|
|
94
|
+
'text-fg no-underline transition-colors hover:bg-surface-muted hover:text-fg outline-none ' +
|
|
95
|
+
'focus-visible:ring-2 focus-visible:ring-ring ' +
|
|
96
|
+
'data-[active]:bg-surface-muted data-[active]:text-fg',
|
|
97
|
+
// `active` is accepted both as an explicit prop (uic emits `data-active=""` when
|
|
98
|
+
// true, nothing when false/unset — see dataAttribute) and, via `asChild`, from
|
|
99
|
+
// @buzola/router's <Link> which also sets `data-active=""` on match. Either way the
|
|
100
|
+
// `data-[active]` presence selectors above apply. NOTE the value is the EMPTY
|
|
101
|
+
// string, so a `data-[active=true]` selector would never match — keep `data-[active]`.
|
|
102
|
+
variants: {
|
|
103
|
+
active: { true: '', false: '' },
|
|
104
|
+
},
|
|
105
|
+
variantsAsDataAttrs: ['active'],
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
export type WorkspaceOption = {
|
|
109
|
+
id: string
|
|
110
|
+
name: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type WorkspaceSwitcherProps = Omit<
|
|
114
|
+
RACComboBoxProps<WorkspaceOption>,
|
|
115
|
+
'children' | 'items'
|
|
116
|
+
> & {
|
|
117
|
+
/**
|
|
118
|
+
* Visible (or screen-reader-only) label for the combobox. REQUIRED — `@app/ui`
|
|
119
|
+
* ships no i18n, so the consumer passes a translated string.
|
|
120
|
+
*/
|
|
121
|
+
label: ReactNode
|
|
122
|
+
/** Workspaces available to the current user (from the `whoami` query). */
|
|
123
|
+
workspaces: WorkspaceOption[]
|
|
124
|
+
/** Hide the visible label, keeping it for assistive tech only. */
|
|
125
|
+
hideLabel?: boolean
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* WorkspaceSwitcher — RAC `ComboBox` listing the user's workspaces. Phase 1
|
|
130
|
+
* is presentational: the consumer feeds `workspaces` (from `whoami`) and wires
|
|
131
|
+
* `selectedKey` / `onSelectionChange`. RAC supplies the combobox ARIA pattern,
|
|
132
|
+
* typeahead filtering and keyboard nav.
|
|
133
|
+
*/
|
|
134
|
+
export const WorkspaceSwitcher = ({
|
|
135
|
+
label,
|
|
136
|
+
workspaces,
|
|
137
|
+
hideLabel = true,
|
|
138
|
+
...props
|
|
139
|
+
}: WorkspaceSwitcherProps) => (
|
|
140
|
+
<RACComboBox {...props} items={workspaces} className="flex flex-col gap-1">
|
|
141
|
+
<Label className={hideLabel ? 'sr-only' : 'text-sm font-medium text-fg'}>{label}</Label>
|
|
142
|
+
<div className="flex items-center">
|
|
143
|
+
<RACInput
|
|
144
|
+
className={
|
|
145
|
+
'h-9 w-48 rounded-md border border-border bg-surface px-3 text-sm text-fg outline-none ' +
|
|
146
|
+
'data-[focused]:ring-2 data-[focused]:ring-ring'
|
|
147
|
+
}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
<Popover className="min-w-[var(--trigger-width)] rounded-md border border-border bg-surface p-1 shadow-lg">
|
|
151
|
+
<ListBox>
|
|
152
|
+
{(workspace: WorkspaceOption) => (
|
|
153
|
+
<WorkspaceSwitcherItem id={workspace.id} textValue={workspace.name}>
|
|
154
|
+
{workspace.name}
|
|
155
|
+
</WorkspaceSwitcherItem>
|
|
156
|
+
)}
|
|
157
|
+
</ListBox>
|
|
158
|
+
</Popover>
|
|
159
|
+
</RACComboBox>
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const WorkspaceSwitcherItem = uic(ListBoxItem, {
|
|
163
|
+
displayName: 'WorkspaceSwitcher.Item',
|
|
164
|
+
baseClass:
|
|
165
|
+
'flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-sm text-fg outline-none ' +
|
|
166
|
+
'data-[focused]:bg-surface-muted data-[selected]:font-medium',
|
|
167
|
+
}) as (props: ListBoxItemProps) => ReactNode
|
|
168
|
+
|
|
169
|
+
export type UserMenuProps = {
|
|
170
|
+
/** Trigger label / avatar content (e.g. user initials or name). */
|
|
171
|
+
trigger: ReactNode
|
|
172
|
+
/**
|
|
173
|
+
* Accessible label for the trigger button. REQUIRED — `@app/ui` ships no
|
|
174
|
+
* i18n, so the consumer passes a translated string.
|
|
175
|
+
*/
|
|
176
|
+
triggerLabel: string
|
|
177
|
+
/** Called when the user activates a menu item; the item's `id` is passed. */
|
|
178
|
+
onAction?: (key: Key) => void
|
|
179
|
+
children: ReactNode
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* UserMenu — RAC `Menu` for the account cluster (settings link, sign out).
|
|
184
|
+
* gs-manager rendered avatar + name + a logout button inline; here it is a
|
|
185
|
+
* proper menu so additional items (settings, profile) compose cleanly. RAC
|
|
186
|
+
* handles the menu ARIA pattern, focus trapping and keyboard nav.
|
|
187
|
+
*/
|
|
188
|
+
export const UserMenu = ({ trigger, triggerLabel, onAction, children }: UserMenuProps) => (
|
|
189
|
+
<MenuTrigger>
|
|
190
|
+
<RACButton
|
|
191
|
+
aria-label={triggerLabel}
|
|
192
|
+
className={
|
|
193
|
+
'flex h-9 items-center gap-2 rounded-md px-2 text-sm font-medium text-fg outline-none ' +
|
|
194
|
+
'transition-colors hover:bg-surface-muted ' +
|
|
195
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
|
|
196
|
+
}
|
|
197
|
+
>
|
|
198
|
+
{trigger}
|
|
199
|
+
</RACButton>
|
|
200
|
+
<Popover className="min-w-40 rounded-md border border-border bg-surface p-1 shadow-lg">
|
|
201
|
+
<RACMenu onAction={onAction} className="outline-none">
|
|
202
|
+
{children}
|
|
203
|
+
</RACMenu>
|
|
204
|
+
</Popover>
|
|
205
|
+
</MenuTrigger>
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
export const UserMenuItem = uic(RACMenuItem, {
|
|
209
|
+
displayName: 'UserMenu.Item',
|
|
210
|
+
baseClass:
|
|
211
|
+
'flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-sm text-fg outline-none ' +
|
|
212
|
+
'data-[focused]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
|
|
213
|
+
}) as (props: MenuItemProps) => ReactNode
|
|
214
|
+
|
|
215
|
+
export const Topbar = Object.assign(TopbarRoot, {
|
|
216
|
+
Brand: TopbarBrand,
|
|
217
|
+
Nav: TopbarNav,
|
|
218
|
+
NavLink: TopbarNavLink,
|
|
219
|
+
Actions: TopbarActions,
|
|
220
|
+
})
|
package/src/utils/uic.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uic — a small, self-contained component factory combining:
|
|
3
|
+
* - clsx (conditional class composition)
|
|
4
|
+
* - cva (class-variance-authority — type-safe variants)
|
|
5
|
+
* - tailwind-merge (dedupe conflicting Tailwind utilities)
|
|
6
|
+
* - Radix Slot (the `asChild` pattern for string elements)
|
|
7
|
+
*
|
|
8
|
+
* Ported from Contember's `uic` helper, but with the two `@contember/*`
|
|
9
|
+
* dependencies inlined so this package stays dependency-light:
|
|
10
|
+
* - `dataAttribute(v)` (was `@contember/utilities`) — boolean → ''/undefined,
|
|
11
|
+
* null/undefined passthrough, everything else → String(v). Used to emit
|
|
12
|
+
* `data-*` attributes for variant-driven CSS hooks.
|
|
13
|
+
* - `useStableMemo(obj)` (was `@contember/react-utils`'s `useObjectMemo`) —
|
|
14
|
+
* memoizes the variant object keyed on a shallow JSON signature so the
|
|
15
|
+
* className recompute only runs when a variant value actually changes.
|
|
16
|
+
*
|
|
17
|
+
* Signature (matches the source — `baseClass` / `displayName` naming kept):
|
|
18
|
+
* uic(Component, {
|
|
19
|
+
* baseClass?, variants, defaultVariants?, compoundVariants?,
|
|
20
|
+
* variantsAsDataAttrs?, passVariantProps?, defaultProps?,
|
|
21
|
+
* displayName?, style?
|
|
22
|
+
* })
|
|
23
|
+
* Returns a `forwardRef` component with type-safe variant props plus
|
|
24
|
+
* `asChild` (Slot) support when `Component` is a string element.
|
|
25
|
+
*
|
|
26
|
+
* Uses `createElement` (not JSX) so this stays a `.ts` file.
|
|
27
|
+
*/
|
|
28
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
29
|
+
import { cva } from 'class-variance-authority'
|
|
30
|
+
import { type ClassValue, clsx } from 'clsx'
|
|
31
|
+
import {
|
|
32
|
+
type ComponentProps,
|
|
33
|
+
type ComponentRef,
|
|
34
|
+
createElement,
|
|
35
|
+
type CSSProperties,
|
|
36
|
+
type ElementType,
|
|
37
|
+
forwardRef,
|
|
38
|
+
type ReactNode,
|
|
39
|
+
useMemo,
|
|
40
|
+
useRef,
|
|
41
|
+
} from 'react'
|
|
42
|
+
import { extendTailwindMerge } from 'tailwind-merge'
|
|
43
|
+
|
|
44
|
+
// tailwind-merge configured for OUR theme extensions (packages/ui/tailwind.config.ts).
|
|
45
|
+
// Default tailwind-merge only recognises stock scale keys — without this,
|
|
46
|
+
// `text-compact` (custom fontSize) would be classified as a text COLOR and
|
|
47
|
+
// wrongly deduped against `text-fg-*`, and `px-nav-x` (custom spacing) would
|
|
48
|
+
// not participate in padding conflict resolution at all.
|
|
49
|
+
const twMerge = extendTailwindMerge({
|
|
50
|
+
extend: {
|
|
51
|
+
classGroups: {
|
|
52
|
+
'font-size': [
|
|
53
|
+
{
|
|
54
|
+
text: [
|
|
55
|
+
// Size-only text ramp (must be registered or they'd be treated as
|
|
56
|
+
// text COLORs and wrongly merged against `text-fg-*`).
|
|
57
|
+
'micro',
|
|
58
|
+
'caption',
|
|
59
|
+
'label',
|
|
60
|
+
'compact',
|
|
61
|
+
'callout',
|
|
62
|
+
'body',
|
|
63
|
+
'subtitle',
|
|
64
|
+
'title',
|
|
65
|
+
'headline',
|
|
66
|
+
'display',
|
|
67
|
+
// Heading ramp (size + line-height).
|
|
68
|
+
'heading1',
|
|
69
|
+
'heading2',
|
|
70
|
+
'heading3',
|
|
71
|
+
'heading4',
|
|
72
|
+
'heading5',
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
theme: {
|
|
78
|
+
spacing: ['nav-x'],
|
|
79
|
+
// Custom card/panel radius key (rounded-panel) → dedupes against other
|
|
80
|
+
// rounded-* utilities. xl/2xl are stock keys tailwind-merge already knows.
|
|
81
|
+
radius: ['panel'],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// --- inlined `dataAttribute` (was @contember/utilities) ---------------------
|
|
87
|
+
type DataAttrValue = boolean | string | number | undefined | null
|
|
88
|
+
const dataAttribute = (value: DataAttrValue): string | undefined => {
|
|
89
|
+
if (typeof value === 'boolean') {
|
|
90
|
+
return value ? '' : undefined
|
|
91
|
+
}
|
|
92
|
+
if (value === undefined || value === null) {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
return String(value)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- inlined `useObjectMemo` (was @contember/react-utils) --------------------
|
|
99
|
+
// Returns a stable reference for `obj` that only changes when its shallow JSON
|
|
100
|
+
// signature changes, so a downstream `useMemo` keyed on it stays cheap.
|
|
101
|
+
const useStableMemo = <T extends object>(obj: T): T => {
|
|
102
|
+
const signature = JSON.stringify(obj)
|
|
103
|
+
const ref = useRef<{ signature: string; value: T }>({ signature, value: obj })
|
|
104
|
+
if (ref.current.signature !== signature) {
|
|
105
|
+
ref.current = { signature, value: obj }
|
|
106
|
+
}
|
|
107
|
+
return ref.current.value
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- variant typing (verbatim from source) ----------------------------------
|
|
111
|
+
type StringToBoolean<T> = T extends 'true' | 'false' ? boolean : T
|
|
112
|
+
type ConfigSchema = Record<string, Record<string, ClassValue>>
|
|
113
|
+
|
|
114
|
+
export type ConfigVariants<T extends ConfigSchema | undefined> = T extends ConfigSchema
|
|
115
|
+
? {
|
|
116
|
+
[Variant in keyof T]?: StringToBoolean<keyof T[Variant]> | null | undefined
|
|
117
|
+
}
|
|
118
|
+
: {}
|
|
119
|
+
|
|
120
|
+
type ConfigVariantsMulti<T extends ConfigSchema | undefined> = T extends ConfigSchema
|
|
121
|
+
? {
|
|
122
|
+
[Variant in keyof T]?: StringToBoolean<keyof T[Variant]> | StringToBoolean<keyof T[Variant]>[] | undefined
|
|
123
|
+
}
|
|
124
|
+
: {}
|
|
125
|
+
|
|
126
|
+
type DataAttr<T extends ConfigSchema | undefined> = T extends ConfigSchema ? `data-${keyof T & string}` : never
|
|
127
|
+
|
|
128
|
+
type AnyComponent = (props: Record<string, unknown>) => ReactNode
|
|
129
|
+
|
|
130
|
+
type Config<T extends ConfigSchema | undefined, El extends ElementType> = {
|
|
131
|
+
baseClass?: ClassValue
|
|
132
|
+
variants?: T
|
|
133
|
+
passVariantProps?: string[]
|
|
134
|
+
defaultProps?: Partial<
|
|
135
|
+
ComponentProps<El> & {
|
|
136
|
+
[K in `data-${string}`]?: DataAttrValue
|
|
137
|
+
}
|
|
138
|
+
>
|
|
139
|
+
defaultVariants?: ConfigVariants<T>
|
|
140
|
+
compoundVariants?: ((ConfigVariants<T> | ConfigVariantsMulti<T>) & { className?: string })[]
|
|
141
|
+
variantsAsDataAttrs?: (keyof ConfigVariants<T>)[]
|
|
142
|
+
displayName?: string
|
|
143
|
+
style?: CSSProperties
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type NoInfer<T> = T & { [K in keyof T]: T[K] }
|
|
147
|
+
|
|
148
|
+
export const uiconfig = <T extends ConfigSchema | undefined>(config: Config<T, AnyComponent>) => config
|
|
149
|
+
|
|
150
|
+
export const uic = <El extends ElementType, Variants extends ConfigSchema | undefined = undefined>(
|
|
151
|
+
Component: El,
|
|
152
|
+
config: Config<Variants, NoInfer<El>>,
|
|
153
|
+
) => {
|
|
154
|
+
// cva's generics are keyed on the concrete variant schema; `uic` is generic
|
|
155
|
+
// over an unknown schema, so we widen to `ConfigSchema` via `unknown` (never
|
|
156
|
+
// `any`) and call the result with a plain variant record below.
|
|
157
|
+
type Cva = (props?: Record<string, unknown>) => string
|
|
158
|
+
const cls = cva(config?.baseClass, {
|
|
159
|
+
variants: config?.variants,
|
|
160
|
+
defaultVariants: config?.defaultVariants,
|
|
161
|
+
compoundVariants: config?.compoundVariants,
|
|
162
|
+
} as unknown as Parameters<typeof cva>[1]) as unknown as Cva
|
|
163
|
+
const passVariantProps = config?.passVariantProps ? new Set(config.passVariantProps) : undefined
|
|
164
|
+
|
|
165
|
+
const component = forwardRef<
|
|
166
|
+
ComponentRef<El>,
|
|
167
|
+
ComponentProps<El> & {
|
|
168
|
+
asChild?: boolean
|
|
169
|
+
children?: ReactNode
|
|
170
|
+
className?: string
|
|
171
|
+
} & ConfigVariants<Variants>
|
|
172
|
+
>((props, ref) => {
|
|
173
|
+
const { children, ...rest } = props as Record<string, unknown> & {
|
|
174
|
+
className?: string
|
|
175
|
+
children?: ReactNode
|
|
176
|
+
}
|
|
177
|
+
const classNameProp = rest.className as string | undefined
|
|
178
|
+
delete rest.className
|
|
179
|
+
|
|
180
|
+
const variants: Record<string, unknown> = {}
|
|
181
|
+
for (const key in config?.variants) {
|
|
182
|
+
variants[key] = rest[key]
|
|
183
|
+
if (key in rest && !passVariantProps?.has(key)) {
|
|
184
|
+
delete rest[key]
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const variantsMemoized = useStableMemo(variants)
|
|
188
|
+
|
|
189
|
+
const dataAttrs: Partial<Record<DataAttr<Variants>, string | undefined>> = {}
|
|
190
|
+
if (config?.variantsAsDataAttrs && config.variants) {
|
|
191
|
+
for (const key of config.variantsAsDataAttrs) {
|
|
192
|
+
const keyAsString = key.toString()
|
|
193
|
+
const variantValue =
|
|
194
|
+
(props as Record<string, DataAttrValue>)[keyAsString] ??
|
|
195
|
+
(config.defaultVariants?.[key] as DataAttrValue)
|
|
196
|
+
dataAttrs[`data-${keyAsString}` as DataAttr<Variants>] = dataAttribute(variantValue)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const inlineStyle = rest.style as CSSProperties | undefined
|
|
201
|
+
const style = useMemo(
|
|
202
|
+
() => (config?.style ? { ...config.style, ...inlineStyle } : inlineStyle),
|
|
203
|
+
[inlineStyle],
|
|
204
|
+
)
|
|
205
|
+
const finalClassName = useMemo(
|
|
206
|
+
() => twMerge(clsx(cls(variantsMemoized), classNameProp)),
|
|
207
|
+
[variantsMemoized, classNameProp],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
let FinalComponent: ElementType = Component
|
|
211
|
+
if (props.asChild && typeof Component === 'string') {
|
|
212
|
+
FinalComponent = Slot
|
|
213
|
+
delete rest.asChild
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return createElement(
|
|
217
|
+
FinalComponent,
|
|
218
|
+
{
|
|
219
|
+
ref,
|
|
220
|
+
className: finalClassName,
|
|
221
|
+
...config.defaultProps,
|
|
222
|
+
...dataAttrs,
|
|
223
|
+
...rest,
|
|
224
|
+
style,
|
|
225
|
+
},
|
|
226
|
+
children,
|
|
227
|
+
)
|
|
228
|
+
})
|
|
229
|
+
component.displayName = config?.displayName ?? 'uic'
|
|
230
|
+
|
|
231
|
+
return component
|
|
232
|
+
}
|