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