@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,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
|
+
}
|