@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.
@@ -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
+ })
@@ -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
+ }