@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,115 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ Button as RACButton,
4
+ FieldError,
5
+ Label,
6
+ ListBox,
7
+ ListBoxItem,
8
+ type ListBoxItemProps,
9
+ Popover,
10
+ Select as RACSelect,
11
+ type SelectProps as RACSelectProps,
12
+ SelectValue,
13
+ Text,
14
+ } from 'react-aria-components'
15
+ import { uic } from '../utils/uic'
16
+
17
+ /**
18
+ * Select — accessible dropdown built on React Aria Components `Select`.
19
+ *
20
+ * RAC handles the listbox ARIA pattern, keyboard navigation, typeahead and
21
+ * focus management. Styling via `uic`. Pass options as `SelectItem` children.
22
+ *
23
+ * Re-skinned 1:1 to gs-platform's designer spec (Figma GraphicStandard 1.5,
24
+ * node 2115-4271 — `Select.module.scss`): a tall (min-h 58px) borderless filled
25
+ * trigger on the cream `surface-card`, matching our `Input` / `Textarea` fill so
26
+ * the form controls stay consistent. gs token map: bg #f7f6f2 → surface-card ·
27
+ * hover #eceae1 → surface-muted · text #0d0d0d → fg · placeholder → fg-muted ·
28
+ * 8px radius → rounded-lg · 6px item radius → rounded-md · error → danger.
29
+ */
30
+ const SelectTrigger = uic(RACButton, {
31
+ displayName: 'SelectTrigger',
32
+ // gs trigger: min-h 58px, 20px padding (p-5), 10px text↔chevron gap (gap-2.5),
33
+ // 8px radius, NO border (filled fill only). We keep a focus-visible ring for
34
+ // WCAG (a borderless trigger that still shows a visible focus indicator is the
35
+ // correct a11y adaptation of gs's borderless look). Error → 2px danger ring
36
+ // (gs uses a box-shadow ring since the trigger has no border to colour); the
37
+ // ring is driven off the RACSelect root's `data-invalid` via `group-`.
38
+ baseClass:
39
+ 'flex min-h-[58px] w-full items-center justify-between gap-2.5 rounded-lg bg-surface-card p-5 ' +
40
+ 'text-sm text-fg outline-none transition-colors ' +
41
+ 'data-[hovered]:bg-surface-muted ' +
42
+ 'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
43
+ 'group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
44
+ 'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
45
+ })
46
+
47
+ export const SelectItem = uic(ListBoxItem, {
48
+ displayName: 'SelectItem',
49
+ // gs item: 0/20px padding (px-5, no vertical pad), 6px radius, selected =
50
+ // medium weight. We add a `surface-muted` focus background (gs highlights with
51
+ // weight only) so keyboard focus stays clearly visible on the cream content.
52
+ baseClass:
53
+ 'flex cursor-pointer select-none items-center rounded-md px-5 text-sm text-fg outline-none ' +
54
+ 'data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
55
+ 'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
56
+ }) as (props: ListBoxItemProps) => ReactNode
57
+
58
+ export type SelectProps<T extends object> = RACSelectProps<T> & {
59
+ /** Visible label (required for accessibility). */
60
+ label: ReactNode
61
+ description?: ReactNode
62
+ errorMessage?: string
63
+ /**
64
+ * Placeholder shown while nothing is selected. REQUIRED — `@app/ui` ships no
65
+ * i18n, so the consumer passes a translated string (otherwise RAC would fall
66
+ * back to its own hardcoded English "Select an item").
67
+ */
68
+ placeholder: string
69
+ children: ReactNode
70
+ }
71
+
72
+ export const Select = <T extends object>({
73
+ label,
74
+ description,
75
+ errorMessage,
76
+ placeholder,
77
+ children,
78
+ ...props
79
+ }: SelectProps<T>) => (
80
+ <RACSelect {...props} placeholder={placeholder} className="group flex flex-col gap-2">
81
+ <Label className="text-heading5 font-medium text-fg">{label}</Label>
82
+ <SelectTrigger>
83
+ <SelectValue className="data-[placeholder]:text-fg-muted" />
84
+ {/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
85
+ <svg
86
+ width="9.5"
87
+ height="9.5"
88
+ viewBox="0 0 12 12"
89
+ fill="none"
90
+ aria-hidden="true"
91
+ className="pointer-events-none shrink-0 text-fg"
92
+ >
93
+ <path
94
+ d="M3 4.5L6 7.5L9 4.5"
95
+ stroke="currentColor"
96
+ strokeWidth="1.5"
97
+ strokeLinecap="round"
98
+ strokeLinejoin="round"
99
+ />
100
+ </svg>
101
+ </SelectTrigger>
102
+ {description ? (
103
+ <Text slot="description" className="text-xs text-fg-muted">
104
+ {description}
105
+ </Text>
106
+ ) : null}
107
+ <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
108
+ {/* gs content: cream fill, 8px radius, shadow-lg, NO border; viewport pads
109
+ 20px vertical / 0 horizontal (items own their 20px side padding) with a
110
+ 12px gap between items. */}
111
+ <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
112
+ <ListBox className="flex flex-col gap-3 py-5 outline-none">{children}</ListBox>
113
+ </Popover>
114
+ </RACSelect>
115
+ )
@@ -0,0 +1,36 @@
1
+ import { Separator as RACSeparator, type SeparatorProps as RACSeparatorProps } from 'react-aria-components'
2
+
3
+ /**
4
+ * Separator — thin 1px rule (port of gs-platform's `Separator`).
5
+ *
6
+ * Built on React Aria Components `Separator`, which renders the element with
7
+ * `role="separator"` and the correct `aria-orientation` for assistive tech.
8
+ *
9
+ * gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule
10
+ * #3): the gs `1px` rule on `var(--color-border)` (#eceae1) maps to our
11
+ * `border` token. Horizontal = full-width 1px-tall line; vertical = full-height
12
+ * 1px-wide line. Driven with `bg-border` so the rule paints regardless of the
13
+ * element's own border-box.
14
+ */
15
+
16
+ export type SeparatorProps = RACSeparatorProps & {
17
+ /** Rule direction. Defaults to horizontal. */
18
+ orientation?: 'horizontal' | 'vertical'
19
+ className?: string
20
+ }
21
+
22
+ export function Separator({ orientation = 'horizontal', className, ...props }: SeparatorProps) {
23
+ return (
24
+ <RACSeparator
25
+ {...props}
26
+ orientation={orientation}
27
+ className={[
28
+ 'shrink-0 bg-border',
29
+ orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full',
30
+ className,
31
+ ]
32
+ .filter(Boolean)
33
+ .join(' ')}
34
+ />
35
+ )
36
+ }
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Switch as RACSwitch, type SwitchProps as RACSwitchProps } from 'react-aria-components'
3
+
4
+ /**
5
+ * Switch — labelled on/off toggle.
6
+ *
7
+ * Ported 1:1 from gs-platform's `Switch` (44×24 track, off = `surface-muted`,
8
+ * on = filled `fg`, 20px white thumb translating 2px→22px, 200ms transition).
9
+ * Built on React Aria Components `Switch` (renders the `<label>` + hidden native
10
+ * checkbox with `role="switch"`, full keyboard + ARIA). The track / thumb are
11
+ * styled `<span>`s driven by `group-data-[…]` hooks. Styling = Tailwind +
12
+ * `@app/tokens`.
13
+ */
14
+ export type SwitchProps = Omit<RACSwitchProps, 'children'> & {
15
+ /** Visible label rendered next to the track. */
16
+ children?: ReactNode
17
+ }
18
+
19
+ export const Switch = ({ children, ...props }: SwitchProps) => (
20
+ <RACSwitch
21
+ {...props}
22
+ className="group flex cursor-pointer select-none items-center gap-3 text-sm text-fg data-[disabled]:cursor-not-allowed"
23
+ >
24
+ {/* gs token map: off track #eceae1 → surface-muted · on track #0d0d0d → fg ·
25
+ thumb #ffffff → surface. gs hovers the track at opacity .9 (not a border). */}
26
+ <span
27
+ className={
28
+ 'flex h-6 w-11 shrink-0 items-center rounded-full bg-surface-muted px-0.5 ' +
29
+ 'transition-colors duration-200 ' +
30
+ 'group-data-[selected]:bg-fg ' +
31
+ 'group-data-[hovered]:opacity-90 ' +
32
+ 'group-data-[focus-visible]:ring-2 group-data-[focus-visible]:ring-ring group-data-[focus-visible]:ring-offset-2 ' +
33
+ 'group-data-[disabled]:opacity-50'
34
+ }
35
+ >
36
+ <span className="h-5 w-5 translate-x-0 rounded-full bg-surface transition-transform duration-200 group-data-[selected]:translate-x-5" />
37
+ </span>
38
+ {children}
39
+ </RACSwitch>
40
+ )
@@ -0,0 +1,40 @@
1
+ import {
2
+ Tab as RACTab,
3
+ type TabProps,
4
+ TabList as RACTabList,
5
+ type TabListProps,
6
+ TabPanel as RACTabPanel,
7
+ type TabPanelProps,
8
+ Tabs as RACTabs,
9
+ } from 'react-aria-components'
10
+ import { uic } from '../utils/uic'
11
+
12
+ /**
13
+ * Tabs — built on React Aria Components `Tabs`.
14
+ *
15
+ * RAC supplies the WAI-ARIA tabs pattern: roving tabindex, arrow-key
16
+ * navigation, `aria-selected` / `aria-controls` wiring and panel association.
17
+ * Styling via `uic`.
18
+ */
19
+ export const Tabs = RACTabs
20
+
21
+ export const TabList = uic(RACTabList, {
22
+ displayName: 'TabList',
23
+ baseClass: 'flex gap-2 border-b border-border',
24
+ }) as <T extends object>(props: TabListProps<T>) => ReturnType<typeof RACTabList>
25
+
26
+ export const Tab = uic(RACTab, {
27
+ displayName: 'Tab',
28
+ baseClass:
29
+ 'cursor-pointer select-none border-b-2 border-transparent px-4 py-3 text-sm font-medium text-fg-muted ' +
30
+ 'outline-none transition-colors ' +
31
+ 'data-[hovered]:text-fg ' +
32
+ 'data-[selected]:border-brand-primary data-[selected]:text-fg ' +
33
+ 'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
34
+ 'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
35
+ }) as (props: TabProps) => ReturnType<typeof RACTab>
36
+
37
+ export const TabPanel = uic(RACTabPanel, {
38
+ displayName: 'TabPanel',
39
+ baseClass: 'py-4 text-sm text-fg outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
40
+ }) as (props: TabPanelProps) => ReturnType<typeof RACTabPanel>
@@ -0,0 +1,68 @@
1
+ import { uic } from '../utils/uic'
2
+
3
+ /**
4
+ * Text + Heading — the typography primitives for the management SPA.
5
+ *
6
+ * They express the `@app/tokens` type ramp as variant props so screens stop
7
+ * hand-writing `text-[NNpx]` literals (the scale, not arbitrary pixels). Two ramps:
8
+ *
9
+ * • <Text> — the SIZE-ONLY UI ramp (micro · caption · label · compact · callout ·
10
+ * body · subtitle · title · headline · display). Like the tokens, these set
11
+ * font-size only; pair with `leading-*` via `className` where a fixed leading
12
+ * matters. `weight` + `tone` cover the common cases.
13
+ * • <Heading> — the semantic heading ramp (`level` 1–5 → text-heading1…5), which
14
+ * DO carry a matching line-height. Renders an <h2> by default; pass `asChild`
15
+ * to project the styles onto the correct heading element for the document
16
+ * outline (e.g. `<Heading asChild level="1"><h1>…</h1></Heading>`).
17
+ *
18
+ * Both are `uic` factories, so they accept `className` (merged, conflict-resolved),
19
+ * `asChild`, refs, and all native props. Presentational only (hard rule #1): no API,
20
+ * no i18n.
21
+ */
22
+
23
+ export const Text = uic('span', {
24
+ displayName: 'Text',
25
+ baseClass: 'text-fg',
26
+ variants: {
27
+ size: {
28
+ micro: 'text-micro',
29
+ caption: 'text-caption',
30
+ label: 'text-label',
31
+ compact: 'text-compact',
32
+ callout: 'text-callout',
33
+ body: 'text-body',
34
+ subtitle: 'text-subtitle',
35
+ title: 'text-title',
36
+ headline: 'text-headline',
37
+ display: 'text-display',
38
+ },
39
+ weight: {
40
+ regular: 'font-normal',
41
+ medium: 'font-medium',
42
+ semibold: 'font-semibold',
43
+ bold: 'font-bold',
44
+ },
45
+ tone: {
46
+ default: 'text-fg',
47
+ muted: 'text-fg-muted',
48
+ subtle: 'text-fg-subtle',
49
+ inverted: 'text-fg-inverted',
50
+ },
51
+ },
52
+ defaultVariants: { size: 'body', weight: 'regular', tone: 'default' },
53
+ })
54
+
55
+ export const Heading = uic('h2', {
56
+ displayName: 'Heading',
57
+ baseClass: 'font-medium tracking-tight text-fg',
58
+ variants: {
59
+ level: {
60
+ '1': 'text-heading1',
61
+ '2': 'text-heading2',
62
+ '3': 'text-heading3',
63
+ '4': 'text-heading4',
64
+ '5': 'text-heading5',
65
+ },
66
+ },
67
+ defaultVariants: { level: '2' },
68
+ })
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ FieldError,
4
+ Label,
5
+ Text,
6
+ TextArea as RACTextArea,
7
+ TextField,
8
+ type TextFieldProps,
9
+ } from 'react-aria-components'
10
+ import { uic } from '../utils/uic'
11
+
12
+ /**
13
+ * Textarea — labelled multi-line text field.
14
+ *
15
+ * Ported 1:1 from gs-platform's `Textarea` (filled field, 120px min-height,
16
+ * vertical resize, brand-green focus border). Built on React Aria Components
17
+ * `TextField` + `TextArea` so the label / description / error are associated
18
+ * automatically via `aria-describedby` + `aria-invalid`. Styling comes from
19
+ * `uic` (Tailwind + `@app/tokens` CSS vars). API mirrors our `Input`.
20
+ */
21
+ const StyledTextArea = uic(RACTextArea, {
22
+ displayName: 'TextAreaControl',
23
+ // gs token map: bg #f7f6f2 → surface-card · border #eceae1 → border · hover
24
+ // #aba89c → fg-subtle · focus #75e7b8 → brand-green · error → danger.
25
+ // min-h-[120px] is a control dimension (not a design token) — gs uses a literal
26
+ // 120px here too; there is no spacing token for it.
27
+ baseClass:
28
+ 'min-h-[120px] w-full resize-y rounded-lg border border-border bg-surface-card px-4 py-3 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
+ })
35
+
36
+ export type TextareaProps = TextFieldProps & {
37
+ /** Visible field label (required for accessibility). */
38
+ label: ReactNode
39
+ /** Helper text rendered under the field. */
40
+ description?: ReactNode
41
+ /** Error message; pass a string for a static error or rely on validation. */
42
+ errorMessage?: string
43
+ placeholder?: string
44
+ /** Initial visible row count (the field still grows / resizes vertically). */
45
+ rows?: number
46
+ }
47
+
48
+ export const Textarea = ({ label, description, errorMessage, placeholder, rows, ...props }: TextareaProps) => (
49
+ <TextField {...props} className="flex w-full flex-col gap-2">
50
+ <Label className="text-sm font-medium text-fg">{label}</Label>
51
+ <StyledTextArea placeholder={placeholder} rows={rows} />
52
+ {description ? (
53
+ <Text slot="description" className="text-xs text-fg-muted">
54
+ {description}
55
+ </Text>
56
+ ) : null}
57
+ <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
58
+ </TextField>
59
+ )
@@ -0,0 +1,177 @@
1
+ import type { ReactElement } from 'react'
2
+ import {
3
+ Button as RACButton,
4
+ Text,
5
+ UNSTABLE_Toast as RACToast,
6
+ UNSTABLE_ToastContent as RACToastContent,
7
+ UNSTABLE_ToastQueue as RACToastQueue,
8
+ UNSTABLE_ToastRegion as RACToastRegion,
9
+ type ToastOptions,
10
+ } from 'react-aria-components'
11
+
12
+ /**
13
+ * Toast — transient notification, ported 1:1 from gs-platform's `Toast` designer
14
+ * component (`packages/ui/src/components/ui/Toast.{tsx,module.scss}`).
15
+ *
16
+ * RAC version note: `react-aria-components@1.18` ships the (still UNSTABLE) Toast
17
+ * API — `UNSTABLE_ToastQueue` + `UNSTABLE_ToastRegion`/`Toast`/`ToastContent`. We
18
+ * build on it (rather than rolling a custom queue) so we inherit RAC's a11y for
19
+ * free: a single off-screen `aria-live` announcer, keyboard focus management
20
+ * (F6 / arrow nav into the region), hover/focus auto-pause of the dismiss timer,
21
+ * and a focusable close control (`<Button slot="close">`).
22
+ *
23
+ * gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule #3):
24
+ * - `--color-background-secondary` (#f7f6f2) → `bg-surface-card`
25
+ * - `1px solid --color-border` (#eceae1) → `border border-border`
26
+ * - `--radius-lg` (8px) → `rounded-lg`
27
+ * - `--shadow-lg` → `shadow-lg`
28
+ * - `--spacing-4` (16px) padding → `p-md`
29
+ * - `--spacing-2` (8px) gap between toasts → `gap-sm`
30
+ * - title heading-5 / weight-medium / text-primary → `text-heading5 font-medium text-fg`
31
+ * - desc base / text-secondary → `text-body text-fg-muted`
32
+ * - close 24px / radius-md / text-tertiary→primary → `h-6 w-6 rounded-md text-fg-subtle hover:text-fg`
33
+ * - viewport `--z-index-tooltip` (1070), width 390px → `z-[1070] w-[390px]`
34
+ * Entrance: gs `slideIn` 300ms cubic-bezier(.16,1,.3,1) translateX(100%)→0 — kept
35
+ * via an injected `@keyframes gsToastSlideIn` (one-shot on mount; RAC toasts have
36
+ * no `data-entering` hook, so we animate on render rather than via a state attr).
37
+ *
38
+ * Variant is our additive extension (gs's base toast is neutral): `success` /
39
+ * `error` tint a leading status dot; `default` shows none. The card stays neutral.
40
+ */
41
+
42
+ export type ToastVariant = 'default' | 'success' | 'error'
43
+
44
+ /** The payload carried by each queued toast. */
45
+ export interface ToastContent {
46
+ /** Primary line — required. Announced to screen readers via RAC's live region. */
47
+ title: string
48
+ /** Optional secondary line, rendered muted under the title. */
49
+ description?: string
50
+ /** Visual status accent (leading dot). Default = neutral. */
51
+ variant?: ToastVariant
52
+ }
53
+
54
+ // Module-level queue: the single source of truth. Mount exactly one <ToastRegion>
55
+ // (typically at the app root); call `toast.add(...)` from anywhere. `maxVisibleToasts`
56
+ // caps the stack — overflow is queued and shown as visible slots free up.
57
+ export const toastQueue = new RACToastQueue<ToastContent>({ maxVisibleToasts: 5 })
58
+
59
+ const DEFAULT_TIMEOUT = 5000
60
+
61
+ /**
62
+ * Imperative toast API. `add` returns the toast key (pass to `toastQueue.close`).
63
+ * `success` / `error` are thin convenience wrappers that preset the variant.
64
+ */
65
+ export const toast = {
66
+ add(content: ToastContent, options?: ToastOptions): string {
67
+ return toastQueue.add(content, { timeout: DEFAULT_TIMEOUT, ...options })
68
+ },
69
+ success(title: string, description?: string, options?: ToastOptions): string {
70
+ return toastQueue.add(
71
+ { title, description, variant: 'success' },
72
+ { timeout: DEFAULT_TIMEOUT, ...options },
73
+ )
74
+ },
75
+ error(title: string, description?: string, options?: ToastOptions): string {
76
+ return toastQueue.add(
77
+ { title, description, variant: 'error' },
78
+ { timeout: DEFAULT_TIMEOUT, ...options },
79
+ )
80
+ },
81
+ }
82
+
83
+ /** Hook form for components that prefer a hook over the module singleton. */
84
+ export const useToast = () => toast
85
+
86
+ const DOT_CLASS: Record<ToastVariant, string | null> = {
87
+ default: null,
88
+ success: 'bg-success',
89
+ error: 'bg-danger',
90
+ }
91
+
92
+ const CloseIcon = () => (
93
+ <svg
94
+ width="14"
95
+ height="14"
96
+ viewBox="0 0 24 24"
97
+ fill="none"
98
+ stroke="currentColor"
99
+ strokeWidth="2"
100
+ strokeLinecap="round"
101
+ aria-hidden="true"
102
+ >
103
+ <path d="M6 6l12 12M18 6 6 18" />
104
+ </svg>
105
+ )
106
+
107
+ export interface ToastRegionProps {
108
+ /** Accessible label for the toast landmark region. Defaults to "Notifications". */
109
+ 'aria-label'?: string
110
+ /** Accessible label for each toast's close control. Defaults to "Close". */
111
+ closeLabel?: string
112
+ }
113
+
114
+ /**
115
+ * ToastRegion — the fixed bottom-right viewport that renders the live queue.
116
+ * Mount once near the app root. Subscribes to `toastQueue` and renders each toast.
117
+ */
118
+ export const ToastRegion = ({
119
+ 'aria-label': ariaLabel = 'Notifications',
120
+ closeLabel = 'Close',
121
+ }: ToastRegionProps): ReactElement => (
122
+ <>
123
+ {/* Injected once: the gs `slideIn` keyframe. Scoped name avoids collisions; a
124
+ duplicate identical @keyframes (if two regions mount) is harmless. */}
125
+ <style>{KEYFRAMES}</style>
126
+ <RACToastRegion
127
+ queue={toastQueue}
128
+ aria-label={ariaLabel}
129
+ className="fixed bottom-0 right-0 z-[1070] m-0 flex w-[390px] max-w-[100vw] list-none flex-col gap-sm p-md outline-none"
130
+ >
131
+ {({ toast: t }) => {
132
+ const variant = t.content.variant ?? 'default'
133
+ const dot = DOT_CLASS[variant]
134
+ return (
135
+ <RACToast
136
+ toast={t}
137
+ style={{
138
+ animation: 'gsToastSlideIn 300ms cubic-bezier(0.16, 1, 0.3, 1)',
139
+ willChange: 'transform, opacity',
140
+ }}
141
+ className="relative flex items-start gap-sm rounded-lg border border-border bg-surface-card p-md shadow-lg outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring"
142
+ >
143
+ {dot ? (
144
+ <span
145
+ aria-hidden="true"
146
+ className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${dot}`}
147
+ />
148
+ ) : null}
149
+ <RACToastContent className="flex min-w-0 flex-1 flex-col gap-1">
150
+ <Text slot="title" className="text-heading5 font-medium text-fg">
151
+ {t.content.title}
152
+ </Text>
153
+ {t.content.description ? (
154
+ <Text slot="description" className="text-body text-fg-muted">
155
+ {t.content.description}
156
+ </Text>
157
+ ) : null}
158
+ </RACToastContent>
159
+ <RACButton
160
+ slot="close"
161
+ aria-label={closeLabel}
162
+ className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none transition-colors hover:bg-surface-muted hover:text-fg data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring"
163
+ >
164
+ <CloseIcon />
165
+ </RACButton>
166
+ </RACToast>
167
+ )
168
+ }}
169
+ </RACToastRegion>
170
+ </>
171
+ )
172
+
173
+ // gs `slideIn`: translateX(100% + viewport padding) → 0, opacity in.
174
+ const KEYFRAMES = `@keyframes gsToastSlideIn {
175
+ from { transform: translateX(calc(100% + 16px)); opacity: 0; }
176
+ to { transform: translateX(0); opacity: 1; }
177
+ }`
@@ -0,0 +1,66 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ OverlayArrow,
4
+ Tooltip as RACTooltip,
5
+ type TooltipProps as RACTooltipProps,
6
+ TooltipTrigger as RACTooltipTrigger,
7
+ } from 'react-aria-components'
8
+
9
+ /**
10
+ * Tooltip — dark hover/focus hint, ported 1:1 from gs-platform's `Tooltip` designer
11
+ * component (`packages/ui/src/components/ui/Tooltip.{tsx,module.scss}`).
12
+ *
13
+ * Built on React Aria Components `TooltipTrigger` + `Tooltip` (which gs faked with
14
+ * Radix): RAC gives us hover-intent + focus + touch handling, an `OverlayArrow`
15
+ * primitive, and correct `aria-describedby` wiring on the trigger for free. Wrap
16
+ * any focusable element (e.g. our `Button`) in `TooltipTrigger`, then render a
17
+ * `Tooltip`.
18
+ *
19
+ * gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule #3):
20
+ * - `--color-brand-black` (#0d0d0d) bg + arrow fill → `bg-fg` / `fill-fg`
21
+ * - `--color-white` text → `text-fg-inverted`
22
+ * - `--font-size-small-body` (13px) → `text-compact`
23
+ * - `--spacing-3` / `--spacing-4` (12px / 16px) pad → `py-3 px-4`
24
+ * - `--radius-md` (6px) → `rounded-md`
25
+ * - `--shadow-lg` → `shadow-lg`
26
+ * - `--z-index-tooltip` (1070) → `z-[1070]`
27
+ * Motion: gs `slideUpAndFade` 200ms cubic-bezier(.16,1,.3,1) translateY(2px)→0,
28
+ * gated on RAC's `data-[entering]` (and reversed on `data-[exiting]`) via an
29
+ * injected `@keyframes` pair.
30
+ */
31
+
32
+ export const TooltipTrigger = RACTooltipTrigger
33
+
34
+ export interface TooltipProps extends Omit<RACTooltipProps, 'children'> {
35
+ /** Tooltip body — text or rich content. */
36
+ children: ReactNode
37
+ /** Hide the small pointer arrow (shown by default). */
38
+ hideArrow?: boolean
39
+ }
40
+
41
+ export const Tooltip = ({ children, hideArrow = false, ...props }: TooltipProps) => (
42
+ <RACTooltip
43
+ // gs default offset (`sideOffset={5}`); RAC adds arrow size automatically.
44
+ offset={6}
45
+ {...props}
46
+ className="z-[1070] select-none rounded-md bg-fg px-4 py-3 text-compact text-fg-inverted shadow-lg outline-none data-[entering]:[animation:gsTooltipSlideUpFade_200ms_cubic-bezier(0.16,1,0.3,1)] data-[exiting]:[animation:gsTooltipSlideUpFade_150ms_cubic-bezier(0.16,1,0.3,1)_reverse]"
47
+ >
48
+ {/* Injected keyframes (gs `slideUpAndFade`). No SCSS; scoped name. */}
49
+ <style>{KEYFRAMES}</style>
50
+ {hideArrow ? null : (
51
+ <OverlayArrow>
52
+ <svg width="10" height="5" viewBox="0 0 10 5" className="fill-fg" aria-hidden="true">
53
+ <path d="M0 0 L5 5 L10 0 Z" />
54
+ </svg>
55
+ </OverlayArrow>
56
+ )}
57
+ {children}
58
+ </RACTooltip>
59
+ )
60
+
61
+ // gs `slideUpAndFade`: translateY(2px) + opacity 0 → settled. RAC flips the
62
+ // rendered arrow per placement, so the keyframe only needs the fade/rise.
63
+ const KEYFRAMES = `@keyframes gsTooltipSlideUpFade {
64
+ from { opacity: 0; transform: translateY(2px); }
65
+ to { opacity: 1; transform: translateY(0); }
66
+ }`