@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,203 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ Dialog as RACDialog,
4
+ DialogTrigger as RACDialogTrigger,
5
+ type DialogProps as RACDialogProps,
6
+ Heading,
7
+ Modal,
8
+ ModalOverlay,
9
+ } from 'react-aria-components'
10
+ import { uic } from '../utils/uic'
11
+
12
+ /**
13
+ * Dialog — modal dialog built on React Aria Components.
14
+ *
15
+ * RAC `Modal` provides focus trapping, scroll locking, `Esc` to close and
16
+ * `aria-modal` semantics automatically. Compose with `DialogTrigger` for the
17
+ * open/close state, or drive it controlled via `Modal`'s `isOpen`.
18
+ */
19
+ export const DialogTrigger = RACDialogTrigger
20
+
21
+ // gs modal backdrop — gs `Dialog.module.scss` overlay: a neutral grey scrim
22
+ // (`rgba(179,179,179,0.5)`) under a light `blur(2px)`.
23
+ const Overlay = uic(ModalOverlay, {
24
+ displayName: 'DialogOverlay',
25
+ baseClass:
26
+ 'fixed inset-0 z-50 flex items-center justify-center p-4 ' +
27
+ 'bg-[rgba(179,179,179,0.5)] backdrop-blur-[2px] ' +
28
+ 'data-[entering]:animate-in data-[exiting]:animate-out',
29
+ })
30
+
31
+ // gs content card: white bg, 20px padding (`p-5`), 8px radius (`rounded-lg`), and
32
+ // the gs `Dialog.module.scss` card shadow — a light single-layer
33
+ // `0 2px 8px rgba(0,0,0,.06)`. No border: gs defines the card with the shadow alone.
34
+ const StyledModal = uic(Modal, {
35
+ displayName: 'DialogModal',
36
+ baseClass:
37
+ 'rounded-lg bg-surface p-5 outline-none shadow-[0px_2px_8px_0px_rgba(0,0,0,0.06)]',
38
+ })
39
+
40
+ /**
41
+ * Modal width presets. `md` (default) is the form/confirm dialog (gs DialogContent
42
+ * default, 586px); `sm` is a tighter confirm; `lg`/`xl` host wider, taller content
43
+ * (e.g. a multi-step workspace stepper); `full` is a near-fullscreen canvas (e.g.
44
+ * the output-template picker gallery). The taller presets cap height with an internal
45
+ * scroll so content never overflows the viewport.
46
+ */
47
+ export type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
48
+
49
+ // gs size presets (exact px max-widths from the designer's spec): sm 420 / md 586
50
+ // / lg 720 / xl 900. All cap to `90vw` width and `85vh` height with internal
51
+ // scroll. Default is `md` (gs DialogContent default = 586px).
52
+ const SIZE_CLASS: Record<DialogSize, string> = {
53
+ sm: 'w-[90vw] max-w-[420px] max-h-[85vh] overflow-y-auto',
54
+ md: 'w-[90vw] max-w-[586px] max-h-[85vh] overflow-y-auto',
55
+ lg: 'w-[90vw] max-w-[720px] max-h-[85vh] overflow-y-auto',
56
+ xl: 'w-[90vw] max-w-[900px] max-h-[85vh] overflow-y-auto',
57
+ // Our addition (not in gs): a fixed near-fullscreen canvas (flex column so the
58
+ // body fills and any inner region can scroll with the header/footer pinned).
59
+ full: 'w-[95vw] max-w-[95vw] h-[92vh] flex flex-col',
60
+ }
61
+
62
+ /** Title size scales with the modal: small dialogs stay compact, large canvases get
63
+ * a proper heading (gs picker headline). */
64
+ // gs `.title` = heading-1 (1.75rem/28px) · weight 500 (medium) · 30px line. sm/md
65
+ // were 18px/600; all sizes now use the gs medium weight (not semibold).
66
+ const TITLE_CLASS: Record<DialogSize, string> = {
67
+ sm: 'text-heading1 font-medium tracking-tight',
68
+ md: 'text-heading1 font-medium tracking-tight',
69
+ lg: 'text-heading2 font-medium tracking-tight',
70
+ xl: 'text-heading2 font-medium tracking-tight',
71
+ full: 'text-heading1 font-medium tracking-tight',
72
+ }
73
+
74
+ export type DialogProps = RACDialogProps & {
75
+ /** Accessible dialog title — rendered as the labelling heading. */
76
+ title?: ReactNode
77
+ /**
78
+ * Optional sub-text under the title (e.g. "Invite collaborators and manage
79
+ * visibility"). Rendered muted; pairs with `title` to form a header block.
80
+ */
81
+ description?: ReactNode
82
+ /**
83
+ * Optional pill rendered inline beside the title (e.g. a status / step / count
84
+ * badge). Caller supplies the content; the Dialog supplies the pill chrome.
85
+ */
86
+ badge?: ReactNode
87
+ /** Width preset — `md` (default) for forms; `sm` for tight confirms; `lg`/`xl` for wide stepper content. */
88
+ size?: DialogSize
89
+ children: ReactNode | ((opts: { close: () => void }) => ReactNode)
90
+ /**
91
+ * Controlled open state. Provide together with `onOpenChange` to drive the
92
+ * dialog from caller state (the modal renders only when `isOpen`). Omit both
93
+ * to let an enclosing `DialogTrigger` own the open/close state instead.
94
+ */
95
+ isOpen?: boolean
96
+ /** Uncontrolled initial open state (ignored when `isOpen` is provided). */
97
+ defaultOpen?: boolean
98
+ /** Notified on open/close — fires with `false` on Esc / click-outside / close(). */
99
+ onOpenChange?: (isOpen: boolean) => void
100
+ /** Allow dismissing via Esc and click-outside (default true). */
101
+ isDismissable?: boolean
102
+ /** Accessible label for the close (✕) control. Defaults to "Close". */
103
+ closeLabel?: string
104
+ }
105
+
106
+ export const Dialog = ({
107
+ title,
108
+ description,
109
+ badge,
110
+ // gs DialogContent defaults to `md` (586px form width).
111
+ size = 'md',
112
+ children,
113
+ isOpen,
114
+ defaultOpen,
115
+ onOpenChange,
116
+ isDismissable = true,
117
+ closeLabel = 'Close',
118
+ ...props
119
+ }: DialogProps) => {
120
+ // `full` is a fixed-height flex canvas: the body fills and scrolls internally so
121
+ // the header (+ a footer the content pins) stay put. Other sizes are content-height.
122
+ const isFlex = size === 'full'
123
+ return (
124
+ // `isOpen`/`defaultOpen`/`onOpenChange` go to the RAC ModalOverlay: when
125
+ // provided the dialog is controlled (renders independently of a DialogTrigger);
126
+ // when omitted the overlay reads its state from an enclosing DialogTrigger.
127
+ // Either way the RACDialog render-prop `close` resolves against the active
128
+ // overlay state, so `close()` works in both modes.
129
+ <Overlay
130
+ isOpen={isOpen}
131
+ defaultOpen={defaultOpen}
132
+ onOpenChange={onOpenChange}
133
+ isDismissable={isDismissable}
134
+ >
135
+ <StyledModal className={SIZE_CLASS[size]}>
136
+ <RACDialog
137
+ {...props}
138
+ className={isFlex ? 'flex min-h-0 flex-1 flex-col outline-none' : 'outline-none'}
139
+ >
140
+ {(renderProps) => (
141
+ <>
142
+ <div className="mb-4 flex shrink-0 items-start justify-between gap-4">
143
+ {title || description ? (
144
+ <div className="min-w-0">
145
+ {title ? (
146
+ <div className="flex items-center gap-2">
147
+ <Heading slot="title" className={`${TITLE_CLASS[size]} text-fg`}>
148
+ {title}
149
+ </Heading>
150
+ {badge ? (
151
+ <span className="inline-flex shrink-0 items-center rounded-full bg-surface-muted px-2 py-0.5 text-xs font-medium text-fg-muted">
152
+ {badge}
153
+ </span>
154
+ ) : null}
155
+ </div>
156
+ ) : null}
157
+ {description ? (
158
+ // gs `.description` = base 16px, text-secondary (=fg).
159
+ <p className="mt-1 text-body text-fg">{description}</p>
160
+ ) : null}
161
+ </div>
162
+ ) : (
163
+ <span aria-hidden="true" />
164
+ )}
165
+ <button
166
+ type="button"
167
+ onClick={renderProps.close}
168
+ aria-label={closeLabel}
169
+ // gs close: 32px square (`h-8 w-8`), medium radius (`rounded-md`),
170
+ // tertiary icon color (`text-fg-subtle`) → primary (`text-fg`) +
171
+ // subtle hover bg on hover. Smooth color transition.
172
+ className="-mr-1 -mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none transition-colors hover:bg-surface-muted hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
173
+ >
174
+ <svg
175
+ width="18"
176
+ height="18"
177
+ viewBox="0 0 24 24"
178
+ fill="none"
179
+ stroke="currentColor"
180
+ strokeWidth="2"
181
+ strokeLinecap="round"
182
+ aria-hidden="true"
183
+ >
184
+ <path d="M6 6l12 12M18 6 6 18" />
185
+ </svg>
186
+ </button>
187
+ </div>
188
+ {isFlex ? (
189
+ <div className="flex min-h-0 flex-1 flex-col">
190
+ {typeof children === 'function' ? children(renderProps) : children}
191
+ </div>
192
+ ) : typeof children === 'function' ? (
193
+ children(renderProps)
194
+ ) : (
195
+ children
196
+ )}
197
+ </>
198
+ )}
199
+ </RACDialog>
200
+ </StyledModal>
201
+ </Overlay>
202
+ )
203
+ }
@@ -0,0 +1,36 @@
1
+ import {
2
+ Disclosure as RACDisclosure,
3
+ type DisclosureProps as RACDisclosureProps,
4
+ DisclosurePanel as RACDisclosurePanel,
5
+ type DisclosurePanelProps as RACDisclosurePanelProps,
6
+ } from 'react-aria-components'
7
+ import { uic } from '../utils/uic'
8
+
9
+ /**
10
+ * Disclosure — collapsible section built on React Aria Components `Disclosure`.
11
+ *
12
+ * RAC supplies the WAI-ARIA disclosure pattern: `aria-expanded` /
13
+ * `aria-controls` wiring between the trigger and the panel, keyboard
14
+ * activation and hidden-until-expanded panel semantics. Compose with the
15
+ * `@app/ui` `Button` as the trigger (pass `slot="trigger"`):
16
+ *
17
+ * <Disclosure>
18
+ * <Button slot="trigger" variant="ghost">Details</Button>
19
+ * <DisclosurePanel>…</DisclosurePanel>
20
+ * </Disclosure>
21
+ *
22
+ * Deliberately unstyled — consumers shape the row (border, background,
23
+ * padding) via `className`; `uic` merges it with `tailwind-merge`.
24
+ */
25
+ export const Disclosure = uic(RACDisclosure, {
26
+ displayName: 'Disclosure',
27
+ }) as (props: RACDisclosureProps & { 'data-testid'?: string }) => ReturnType<typeof RACDisclosure>
28
+
29
+ export const DisclosurePanel = uic(RACDisclosurePanel, {
30
+ displayName: 'DisclosurePanel',
31
+ }) as (
32
+ props: RACDisclosurePanelProps & { 'data-testid'?: string },
33
+ ) => ReturnType<typeof RACDisclosurePanel>
34
+
35
+ export type DisclosureProps = RACDisclosureProps
36
+ export type DisclosurePanelProps = RACDisclosurePanelProps
@@ -0,0 +1,143 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ Header,
4
+ Menu as RACMenu,
5
+ MenuItem as RACMenuItem,
6
+ type MenuItemProps as RACMenuItemProps,
7
+ type MenuProps as RACMenuProps,
8
+ MenuSection as RACMenuSection,
9
+ type MenuSectionProps as RACMenuSectionProps,
10
+ MenuTrigger as RACMenuTrigger,
11
+ Popover as RACPopover,
12
+ type PopoverProps as RACPopoverProps,
13
+ Separator as RACSeparator,
14
+ } from 'react-aria-components'
15
+ import { uic } from '../utils/uic'
16
+
17
+ /**
18
+ * DropdownMenu — trigger-anchored action menu (port of gs-platform's LIGHT
19
+ * `DropdownMenu`).
20
+ *
21
+ * Distinct from {@link ContextMenu} (the dark, pointer-positioned right-click
22
+ * `ContextActionPanel`): this is a BUTTON → menu, anchored to its trigger. gs
23
+ * builds it on Radix `DropdownMenu` + an SCSS module; we re-implement on React
24
+ * Aria Components `MenuTrigger` + `Popover` + `Menu`, which provides the WAI-ARIA
25
+ * menu pattern for free — roving focus, arrow / Home / End navigation, type-ahead,
26
+ * Escape-to-close, outside-press dismissal, focus restoration and viewport-aware
27
+ * collision handling (the popover flips / shifts to stay on-screen).
28
+ *
29
+ * Compose it like the RAC primitive — a trigger element next to the menu:
30
+ *
31
+ * ```tsx
32
+ * <DropdownMenuTrigger>
33
+ * <Button>Actions</Button>
34
+ * <DropdownMenu aria-label="Actions" onAction={(key) => …}>
35
+ * <DropdownMenuItem id="rename">Rename</DropdownMenuItem>
36
+ * <DropdownMenuSeparator />
37
+ * <DropdownMenuItem id="delete" destructive>Delete</DropdownMenuItem>
38
+ * </DropdownMenu>
39
+ * </DropdownMenuTrigger>
40
+ * ```
41
+ *
42
+ * gs token map (this is the LIGHT menu, so the gs literals map cleanly to our
43
+ * tokens): content bg white → `surface` · 1px `#eceae1` border → `border` · 8px
44
+ * radius → `rounded-lg` · `shadow-lg` · item 12/16px padding → `py-3 px-4` · 6px
45
+ * item radius → `rounded-md` · item hover `#f7f6f2` → `surface-card` · item text
46
+ * `#0d0d0d` → `fg` · section label `#aba89c` 13px → `text-fg-subtle text-compact`
47
+ * · separator `#eceae1` → `border`. The destructive item uses `text-danger`
48
+ * (`#dc2626`) — a light-surface red that reads correctly here (unlike the dark
49
+ * ContextMenu, which needs the lighter `#ffb5b5`).
50
+ *
51
+ * Presentational only (hard rule #1) — no app imports.
52
+ */
53
+
54
+ /** Re-export of RAC `MenuTrigger`; owns the open/close state for the pair. */
55
+ export const DropdownMenuTrigger = RACMenuTrigger
56
+
57
+ // gs `.item`: 12/16px padding, 6px radius, base text on white; hover/focus wash
58
+ // `#f7f6f2` (→ surface-card). We light up both `data-[focused]` (keyboard) and
59
+ // `data-[hovered]` (pointer) so keyboard focus stays clearly visible. Item text
60
+ // size follows the house light Select (`text-sm`) rather than gs's 16px base so
61
+ // the two light dropdowns stay consistent.
62
+ export const DropdownMenuItem = uic(RACMenuItem, {
63
+ displayName: 'DropdownMenuItem',
64
+ baseClass:
65
+ 'flex cursor-pointer select-none items-center gap-2.5 rounded-md px-4 py-3 ' +
66
+ 'text-sm text-fg outline-none ' +
67
+ 'data-[focused]:bg-surface-card data-[hovered]:bg-surface-card ' +
68
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
69
+ variants: {
70
+ // gs has no destructive item style on the light menu; we add the house danger
71
+ // token (red text), which reads correctly on the white / cream surface.
72
+ destructive: { true: 'text-danger data-[focused]:text-danger data-[hovered]:text-danger' },
73
+ },
74
+ }) as (props: RACMenuItemProps & { destructive?: boolean }) => ReactNode
75
+
76
+ // gs `.separator`: 1px rule, 8px vertical margin (`my-2`), `#eceae1` (→ border).
77
+ export const DropdownMenuSeparator = uic(RACSeparator, {
78
+ displayName: 'DropdownMenuSeparator',
79
+ baseClass: 'my-2 h-px border-0 bg-border',
80
+ })
81
+
82
+ export interface DropdownMenuSectionProps<T extends object> extends Omit<RACMenuSectionProps<T>, 'children'> {
83
+ /**
84
+ * Section label (gs `.label`). Rendered as a non-interactive `Header` — 13px
85
+ * medium, `fg-subtle` — above the section's items.
86
+ */
87
+ label?: ReactNode
88
+ /** Static section items (each a {@link DropdownMenuItem}). */
89
+ children: ReactNode
90
+ }
91
+
92
+ /** A labelled group of items. Optional `label` renders the gs section header. */
93
+ export function DropdownMenuSection<T extends object>({
94
+ label,
95
+ children,
96
+ ...props
97
+ }: DropdownMenuSectionProps<T>) {
98
+ return (
99
+ <RACMenuSection {...props}>
100
+ {label ? (
101
+ <Header className="px-4 py-3 text-compact font-medium text-fg-subtle">{label}</Header>
102
+ ) : null}
103
+ {children}
104
+ </RACMenuSection>
105
+ )
106
+ }
107
+
108
+ export type DropdownMenuProps<T extends object> = RACMenuProps<T> & {
109
+ /** Popover placement relative to the trigger (default `bottom start`). */
110
+ placement?: RACPopoverProps['placement']
111
+ /** Extra classes merged onto the popover container. */
112
+ popoverClassName?: string
113
+ }
114
+
115
+ /**
116
+ * The popover + menu body. Anchors to the enclosing {@link DropdownMenuTrigger}.
117
+ * Pass `onAction` for a single keyed handler, or per-item `onAction` on each
118
+ * {@link DropdownMenuItem}. `disabledKeys` / `aria-label` flow through to the
119
+ * underlying RAC `Menu`.
120
+ */
121
+ export function DropdownMenu<T extends object>({
122
+ placement = 'bottom start',
123
+ popoverClassName,
124
+ className,
125
+ ...props
126
+ }: DropdownMenuProps<T>) {
127
+ return (
128
+ // gs `.content`: min-width 220px, 8px padding (`p-2`), 8px radius, 1px
129
+ // `#eceae1` border, white fill, `shadow-lg`.
130
+ <RACPopover
131
+ placement={placement}
132
+ className={
133
+ 'min-w-[220px] rounded-lg border border-border bg-surface p-2 shadow-lg outline-none' +
134
+ (popoverClassName ? ` ${popoverClassName}` : '')
135
+ }
136
+ >
137
+ <RACMenu
138
+ {...props}
139
+ className={typeof className === 'string' && className ? `outline-none ${className}` : 'outline-none'}
140
+ />
141
+ </RACPopover>
142
+ )
143
+ }
@@ -0,0 +1,70 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ FieldError,
4
+ Input as RACInput,
5
+ Label,
6
+ Text,
7
+ TextField,
8
+ type TextFieldProps,
9
+ } from 'react-aria-components'
10
+ import { uic } from '../utils/uic'
11
+
12
+ /**
13
+ * Input — labelled single-line text field.
14
+ *
15
+ * Ported 1:1 from gs-platform's `Input` (filled field, brand-green focus
16
+ * border). Built on React Aria Components `TextField` (associates label /
17
+ * description / error automatically via `aria-describedby` + `aria-invalid`).
18
+ * The inner `<input>` styling comes from `uic` and matches our `Textarea`
19
+ * (same filled-field spec) so the two controls stay visually consistent.
20
+ */
21
+ const StyledInput = uic(RACInput, {
22
+ displayName: 'InputControl',
23
+ // gs token map: bg #f7f6f2 → surface-card · border #eceae1 → border · hover
24
+ // #aba89c → fg-subtle · focus #75e7b8 → brand-green · error → danger.
25
+ // Identical to textarea.tsx's filled-field skin; `fieldSize` adds the
26
+ // single-line height (gs sizes the field via padding only).
27
+ baseClass:
28
+ 'w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg ' +
29
+ 'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
30
+ 'data-[hovered]:border-fg-subtle ' +
31
+ 'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
32
+ 'data-[invalid]:border-danger data-[invalid]:ring-danger ' +
33
+ 'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
34
+ variants: {
35
+ // `fieldSize` (not `size`) to avoid colliding with the native <input size>
36
+ // attribute, which RAC's Input inherits (a numeric prop).
37
+ fieldSize: {
38
+ sm: 'h-8',
39
+ md: 'h-10',
40
+ lg: 'h-12',
41
+ },
42
+ },
43
+ defaultVariants: {
44
+ fieldSize: 'md',
45
+ },
46
+ })
47
+
48
+ export type InputProps = TextFieldProps & {
49
+ /** Visible field label (required for accessibility). */
50
+ label: ReactNode
51
+ /** Helper text rendered under the field. */
52
+ description?: ReactNode
53
+ /** Error message; pass a string for a static error or rely on validation. */
54
+ errorMessage?: string
55
+ placeholder?: string
56
+ size?: 'sm' | 'md' | 'lg'
57
+ }
58
+
59
+ export const Input = ({ label, description, errorMessage, placeholder, size, ...props }: InputProps) => (
60
+ <TextField {...props} className="flex w-full flex-col gap-2">
61
+ <Label className="text-heading5 font-medium text-fg">{label}</Label>
62
+ <StyledInput placeholder={placeholder} fieldSize={size} />
63
+ {description ? (
64
+ <Text slot="description" className="text-xs text-fg-muted">
65
+ {description}
66
+ </Text>
67
+ ) : null}
68
+ <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
69
+ </TextField>
70
+ )
@@ -0,0 +1,72 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ FieldError,
4
+ Label,
5
+ Radio as RACRadio,
6
+ RadioGroup as RACRadioGroup,
7
+ type RadioGroupProps as RACRadioGroupProps,
8
+ type RadioProps as RACRadioProps,
9
+ Text,
10
+ } from 'react-aria-components'
11
+
12
+ /**
13
+ * RadioGroup + Radio — labelled single-choice control.
14
+ *
15
+ * Ported 1:1 from gs-platform's `Radio` (20px round, 2px border, selected =
16
+ * ring + 10px filled `fg` dot, 200ms transition). Built on React Aria
17
+ * Components `RadioGroup` + `Radio` — roving tabindex, arrow-key navigation and
18
+ * ARIA grouping come for free; the group label / description / error are
19
+ * associated automatically. Callers compose `<Radio>` children inside
20
+ * `<RadioGroup>`. Styling = Tailwind + `@app/tokens`.
21
+ */
22
+ export type RadioGroupProps = RACRadioGroupProps & {
23
+ /** Visible group label. */
24
+ label?: ReactNode
25
+ /** Helper text rendered under the group. */
26
+ description?: ReactNode
27
+ /** Error message; pass a string for a static error or rely on validation. */
28
+ errorMessage?: string
29
+ children: ReactNode
30
+ }
31
+
32
+ export const RadioGroup = ({ label, description, errorMessage, children, ...props }: RadioGroupProps) => (
33
+ <RACRadioGroup {...props} className="flex flex-col gap-2">
34
+ {label ? <Label className="text-sm font-medium text-fg">{label}</Label> : null}
35
+ <div className="flex flex-col gap-3">{children}</div>
36
+ {description ? (
37
+ <Text slot="description" className="text-xs text-fg-muted">
38
+ {description}
39
+ </Text>
40
+ ) : null}
41
+ <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
42
+ </RACRadioGroup>
43
+ )
44
+
45
+ export type RadioProps = Omit<RACRadioProps, 'children'> & {
46
+ /** Visible label rendered next to the dot. */
47
+ children?: ReactNode
48
+ }
49
+
50
+ export const Radio = ({ children, ...props }: RadioProps) => (
51
+ <RACRadio
52
+ {...props}
53
+ className="group flex cursor-pointer select-none items-center gap-3 text-sm text-fg data-[disabled]:cursor-not-allowed"
54
+ >
55
+ {/* gs token map: border #eceae1 → border · hover #aba89c → fg-subtle ·
56
+ selected dot/border #0d0d0d → fg · error → danger. */}
57
+ <span
58
+ className={
59
+ 'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-border bg-surface ' +
60
+ 'transition-all duration-200 ' +
61
+ 'group-data-[hovered]:border-fg-subtle ' +
62
+ 'group-data-[selected]:border-fg ' +
63
+ 'group-data-[focus-visible]:ring-2 group-data-[focus-visible]:ring-ring group-data-[focus-visible]:ring-offset-2 ' +
64
+ 'group-data-[invalid]:border-danger ' +
65
+ 'group-data-[disabled]:opacity-50'
66
+ }
67
+ >
68
+ <span className="h-2.5 w-2.5 rounded-full bg-fg opacity-0 transition-opacity duration-200 group-data-[selected]:opacity-100" />
69
+ </span>
70
+ {children}
71
+ </RACRadio>
72
+ )
@@ -0,0 +1,103 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Button } from './button'
3
+
4
+ /**
5
+ * SectionTabs — horizontal section/filter tab row (port of gs-platform
6
+ * `SectionTabs`, also the reusable form of `apps/web`'s `FilterBar`).
7
+ *
8
+ * gs-platform renders left-aligned text tabs (active = subtle pill) with an
9
+ * optional leading "reset" glyph button. Disabled sections are dimmed; here we
10
+ * additionally surface a small "Soon" badge on disabled tabs so unbuilt sections
11
+ * read as upcoming rather than broken.
12
+ *
13
+ * Controlled: the consumer owns `active` and handles `onChange`. Each tab is a
14
+ * React Aria `Button`, so keyboard activation, focus rings and disabled handling
15
+ * come for free. The selected tab carries `aria-pressed` for SR state.
16
+ *
17
+ * Presentational only (hard rule #1): all labels via props, no API/i18n.
18
+ */
19
+
20
+ export type SectionTab = {
21
+ key: string
22
+ label: ReactNode
23
+ /** Disabled tabs render dimmed with a "Soon" badge and cannot be selected. */
24
+ disabled?: boolean
25
+ }
26
+
27
+ export type SectionTabsProps = {
28
+ tabs: SectionTab[]
29
+ active: string
30
+ onChange: (key: string) => void
31
+ /** Label shown on disabled tabs. Defaults to "Soon" (override per-locale). */
32
+ soonLabel?: ReactNode
33
+ /** When set, renders a leading reset control. */
34
+ onReset?: () => void
35
+ /** Accessible label for the reset control (e.g. translated "Reset filters"). */
36
+ resetLabel?: string
37
+ /** Glyph for the reset control; defaults to a small grid icon. */
38
+ resetIcon?: ReactNode
39
+ className?: string
40
+ }
41
+
42
+ const GridGlyph = () => (
43
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
44
+ <rect x="3" y="3" width="7.5" height="7.5" rx="1.5" />
45
+ <rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5" />
46
+ <rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5" />
47
+ <rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5" />
48
+ </svg>
49
+ )
50
+
51
+ const tabBase =
52
+ 'inline-flex h-7 items-center gap-2 rounded-sm px-3 text-compact text-fg-muted outline-none ' +
53
+ 'transition-colors duration-[120ms] ease-[ease] data-[hovered]:bg-surface-muted data-[hovered]:text-fg ' +
54
+ 'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
55
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:bg-transparent data-[disabled]:text-fg-muted'
56
+
57
+ const tabActive = 'bg-surface-muted font-medium text-fg'
58
+
59
+ export function SectionTabs({
60
+ tabs,
61
+ active,
62
+ onChange,
63
+ soonLabel = 'Soon',
64
+ onReset,
65
+ resetLabel = 'Reset',
66
+ resetIcon,
67
+ className,
68
+ }: SectionTabsProps) {
69
+ return (
70
+ <div className={['flex flex-wrap items-center gap-1', className].filter(Boolean).join(' ')} role="group">
71
+ {onReset ? (
72
+ <Button
73
+ variant="ghost"
74
+ aria-label={resetLabel}
75
+ onPress={onReset}
76
+ className="h-7 w-7 rounded-sm p-0 text-fg-muted data-[hovered]:text-fg"
77
+ >
78
+ {resetIcon ?? <GridGlyph />}
79
+ </Button>
80
+ ) : null}
81
+ {tabs.map((tab) => {
82
+ const selected = active === tab.key
83
+ return (
84
+ <Button
85
+ key={tab.key}
86
+ variant="ghost"
87
+ isDisabled={tab.disabled}
88
+ aria-pressed={selected}
89
+ onPress={() => onChange(tab.key)}
90
+ className={[tabBase, selected && !tab.disabled ? tabActive : ''].filter(Boolean).join(' ')}
91
+ >
92
+ <span>{tab.label}</span>
93
+ {tab.disabled ? (
94
+ <span className="inline-flex items-center rounded-full bg-surface-muted px-1.5 py-0.5 text-micro font-medium leading-none text-fg-muted">
95
+ {soonLabel}
96
+ </span>
97
+ ) : null}
98
+ </Button>
99
+ )
100
+ })}
101
+ </div>
102
+ )
103
+ }