@podoba/react 0.0.28 → 0.0.30
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/package.json +6 -4
- package/src/components/brand-page-header.tsx +24 -17
- package/src/components/button.tsx +5 -2
- package/src/components/cta-pill.tsx +9 -8
- package/src/components/dialog.tsx +24 -22
- package/src/components/input.tsx +17 -17
- package/src/components/section-tabs.tsx +34 -12
- package/src/components/select.tsx +15 -11
- package/src/components/text.tsx +19 -0
- package/src/components/tile.tsx +63 -25
- package/src/utils/uic.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.30",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
|
|
6
6
|
"repository": {
|
|
@@ -16,17 +16,19 @@
|
|
|
16
16
|
".": "./src/index.ts"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
-
"src"
|
|
19
|
+
"src",
|
|
20
|
+
"!src/**/*.test.*"
|
|
20
21
|
],
|
|
21
22
|
"publishConfig": {
|
|
22
23
|
"access": "public"
|
|
23
24
|
},
|
|
24
25
|
"scripts": {
|
|
26
|
+
"test": "bun test src",
|
|
25
27
|
"typecheck": "tsc --build"
|
|
26
28
|
},
|
|
27
29
|
"dependencies": {
|
|
28
|
-
"@podoba/tokens": "^0.0.
|
|
29
|
-
"@podoba/tailwind": "^0.0.
|
|
30
|
+
"@podoba/tokens": "^0.0.30",
|
|
31
|
+
"@podoba/tailwind": "^0.0.30",
|
|
30
32
|
"react-aria-components": "1.18.0",
|
|
31
33
|
"class-variance-authority": "0.7.1",
|
|
32
34
|
"clsx": "2.1.1",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReactNode, useState } from 'react'
|
|
2
2
|
import { Button } from './button'
|
|
3
|
+
import { DisplayHeading } from './text'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* BrandPageHeader — the brand-workspace page header (port of gs-platform
|
|
@@ -30,6 +31,8 @@ export type BrandPageHeaderCrumb = {
|
|
|
30
31
|
export type BrandPageHeaderProps = {
|
|
31
32
|
/** The large greeting / page-title slot (e.g. "Good morning Jonas 👋"). */
|
|
32
33
|
greeting: ReactNode
|
|
34
|
+
/** Semantic heading level. Use 2 or 3 when the header is nested below a page title. */
|
|
35
|
+
headingLevel?: 1 | 2 | 3
|
|
33
36
|
/**
|
|
34
37
|
* gs-style "title to go back": a muted, clickable PARENT link rendered as the
|
|
35
38
|
* line ABOVE the title (so the title reads "Parent ⏎ Current"). Pass a real
|
|
@@ -63,6 +66,7 @@ const PANEL_ID = 'brand-page-header-create-hub'
|
|
|
63
66
|
|
|
64
67
|
export function BrandPageHeader({
|
|
65
68
|
greeting,
|
|
69
|
+
headingLevel = 1,
|
|
66
70
|
parentLink,
|
|
67
71
|
breadcrumbs,
|
|
68
72
|
cta,
|
|
@@ -78,6 +82,7 @@ export function BrandPageHeader({
|
|
|
78
82
|
const isControlled = expandedProp !== undefined
|
|
79
83
|
const expanded = isControlled ? expandedProp : internalExpanded
|
|
80
84
|
const hasExpandable = Boolean(createHub)
|
|
85
|
+
const HeadingTag = `h${headingLevel}` as const
|
|
81
86
|
|
|
82
87
|
const setExpanded = (next: boolean) => {
|
|
83
88
|
if (!isControlled) setInternalExpanded(next)
|
|
@@ -94,8 +99,8 @@ export function BrandPageHeader({
|
|
|
94
99
|
.filter(Boolean)
|
|
95
100
|
.join(' ')}
|
|
96
101
|
>
|
|
97
|
-
<div className="flex flex-col gap-2 sm:
|
|
98
|
-
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
102
|
+
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-3 sm:items-stretch sm:gap-4">
|
|
103
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1 sm:col-span-2">
|
|
99
104
|
{breadcrumbs && breadcrumbs.length > 0 ? (
|
|
100
105
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-compact text-fg-muted">
|
|
101
106
|
{breadcrumbs.map((crumb, i) => (
|
|
@@ -116,25 +121,27 @@ export function BrandPageHeader({
|
|
|
116
121
|
))}
|
|
117
122
|
</nav>
|
|
118
123
|
) : null}
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
124
|
+
<DisplayHeading asChild className="tracking-tight">
|
|
125
|
+
<HeadingTag>
|
|
126
|
+
{parentLink ? (
|
|
127
|
+
// gs "title to go back": muted clickable parent line above the title.
|
|
128
|
+
// `[&_a]` styles the nested router <Link> (anchor) without @app/ui
|
|
129
|
+
// importing the router.
|
|
130
|
+
<>
|
|
131
|
+
<span className="text-fg-muted transition-colors [&_a:hover]:text-fg [&_a]:text-fg-muted [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline">
|
|
132
|
+
{parentLink}
|
|
133
|
+
</span>
|
|
134
|
+
<br />
|
|
135
|
+
</>
|
|
136
|
+
) : null}
|
|
137
|
+
{greeting}
|
|
138
|
+
</HeadingTag>
|
|
139
|
+
</DisplayHeading>
|
|
133
140
|
</div>
|
|
134
141
|
|
|
135
142
|
{cta ? (
|
|
136
143
|
// Hero CTA spans 4 of 12 columns (one third) — the greeting takes the rest.
|
|
137
|
-
<div className="
|
|
144
|
+
<div className="h-full min-w-0">{cta}</div>
|
|
138
145
|
) : ctaLabel ? (
|
|
139
146
|
<div className="shrink-0">
|
|
140
147
|
{hasExpandable ? (
|
|
@@ -23,7 +23,7 @@ export const Button = uic(RACButton, {
|
|
|
23
23
|
// hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow → active back
|
|
24
24
|
// to neutral-400 (surface-inverted, no shadow).
|
|
25
25
|
primary:
|
|
26
|
-
'bg-
|
|
26
|
+
'bg-brand-primary text-fg-inverted hover:bg-neutral-600 hover:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:bg-brand-primary data-[pressed]:shadow-none',
|
|
27
27
|
// secondary: #f7f6f2 (surface-card) + 1px #eceae1 (border); hover bg #eceae1
|
|
28
28
|
// (surface-muted) + border #aba89c (fg-subtle); active bg #eceae1.
|
|
29
29
|
secondary:
|
|
@@ -39,6 +39,9 @@ export const Button = uic(RACButton, {
|
|
|
39
39
|
sm: 'py-2 px-4',
|
|
40
40
|
md: 'py-3 px-6',
|
|
41
41
|
lg: 'py-4 px-8',
|
|
42
|
+
// Task/workflow CTA: exact source 44px height, 24px inline padding,
|
|
43
|
+
// 17/20 medium text.
|
|
44
|
+
prominent: 'h-11 px-6 py-0 text-panel-heading font-medium',
|
|
42
45
|
},
|
|
43
46
|
},
|
|
44
47
|
defaultVariants: {
|
|
@@ -49,5 +52,5 @@ export const Button = uic(RACButton, {
|
|
|
49
52
|
|
|
50
53
|
export type ButtonProps = RACButtonProps & {
|
|
51
54
|
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive'
|
|
52
|
-
size?: 'sm' | 'md' | 'lg'
|
|
55
|
+
size?: 'sm' | 'md' | 'lg' | 'prominent'
|
|
53
56
|
}
|
|
@@ -8,15 +8,16 @@ import type { ReactNode } from 'react'
|
|
|
8
8
|
* translate each per-locale while keeping the middle-word highlight. Presentational
|
|
9
9
|
* only (hard rule #1): every string arrives via props — no i18n, no domain data.
|
|
10
10
|
*
|
|
11
|
-
* a11y NOTE: the background is the light brand-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* a11y NOTE: the background is the FIXED light brand-secondary (#6eddb1) — it does
|
|
12
|
+
* not flip with the theme, so every sentence fragment sits on `fg-on-brand` (stable
|
|
13
|
+
* dark ink, ~11:1 in both themes). The gs design's white emphasis word failed WCAG AA
|
|
14
|
+
* (~1.66:1) and turned the whole sentence white-on-green in a dark shell; the
|
|
15
|
+
* emphasis is now carried by weight alone.
|
|
15
16
|
*/
|
|
16
17
|
export interface CtaPillProps {
|
|
17
18
|
/** Regular lead-in before the emphasised word (e.g. "Let's"). */
|
|
18
19
|
lead: ReactNode
|
|
19
|
-
/** The emphasised bold
|
|
20
|
+
/** The emphasised bold middle word (e.g. "create"). */
|
|
20
21
|
emphasis: ReactNode
|
|
21
22
|
/** Regular trailing text after the emphasis (e.g. "something"). */
|
|
22
23
|
tail: ReactNode
|
|
@@ -26,9 +27,9 @@ export interface CtaPillProps {
|
|
|
26
27
|
|
|
27
28
|
export function CtaPill({ lead, emphasis, tail, children }: CtaPillProps) {
|
|
28
29
|
return (
|
|
29
|
-
<div className="flex w-full items-center justify-between gap-
|
|
30
|
-
<p className="min-w-0 text-heading4 font-medium leading-
|
|
31
|
-
{lead} <span className="font-semibold
|
|
30
|
+
<div className="flex h-full min-h-16 w-full items-center justify-between gap-nav-x rounded-lg bg-brand-green py-2.5 pr-3 pl-4.5">
|
|
31
|
+
<p className="min-w-0 text-heading4 font-medium leading-5 tracking-tight text-fg-on-brand">
|
|
32
|
+
{lead} <span className="font-semibold">{emphasis}</span> {tail}
|
|
32
33
|
</p>
|
|
33
34
|
<div className="shrink-0">{children}</div>
|
|
34
35
|
</div>
|
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
DialogTrigger as RACDialogTrigger,
|
|
5
5
|
type DialogProps as RACDialogProps,
|
|
6
6
|
Heading,
|
|
7
|
-
Modal,
|
|
8
|
-
ModalOverlay,
|
|
7
|
+
Modal as RACModal,
|
|
8
|
+
ModalOverlay as RACModalOverlay,
|
|
9
9
|
} from 'react-aria-components'
|
|
10
10
|
import { uic } from '../utils/uic'
|
|
11
11
|
|
|
@@ -18,25 +18,31 @@ import { uic } from '../utils/uic'
|
|
|
18
18
|
*/
|
|
19
19
|
export const DialogTrigger = RACDialogTrigger
|
|
20
20
|
|
|
21
|
-
// gs modal backdrop
|
|
22
|
-
|
|
23
|
-
const Overlay = uic(ModalOverlay, {
|
|
21
|
+
// Current gs modal backdrop: warm surface-card scrim at 66% under a 10px blur.
|
|
22
|
+
export const ModalOverlay = uic(RACModalOverlay, {
|
|
24
23
|
displayName: 'DialogOverlay',
|
|
25
24
|
baseClass:
|
|
26
25
|
'fixed inset-0 z-50 flex items-center justify-center p-4 ' +
|
|
27
|
-
'bg-
|
|
28
|
-
'data-[entering]:animate-in data-[exiting]:animate-out',
|
|
26
|
+
'bg-modal-backdrop backdrop-blur-modal-backdrop ' +
|
|
27
|
+
'data-[entering]:animate-modal-overlay-in data-[exiting]:animate-modal-overlay-out',
|
|
29
28
|
})
|
|
30
29
|
|
|
31
|
-
// gs content card: white bg, 20px padding
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
const StyledModal = uic(Modal, {
|
|
30
|
+
// gs content card: white bg, 20px padding, 8px radius and the shared two-layer
|
|
31
|
+
// modal elevation.
|
|
32
|
+
export const ModalSurface = uic(RACModal, {
|
|
35
33
|
displayName: 'DialogModal',
|
|
36
34
|
baseClass:
|
|
37
|
-
'rounded-lg bg-surface p-5 outline-none shadow-
|
|
35
|
+
'rounded-lg bg-surface p-5 outline-none shadow-modal-surface ' +
|
|
36
|
+
'data-[entering]:animate-modal-surface-in data-[exiting]:animate-modal-surface-out',
|
|
38
37
|
})
|
|
39
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Unstyled accessible dialog body for edge-to-edge / split modal compositions.
|
|
41
|
+
* Pair only with the shared {@link ModalOverlay} and {@link ModalSurface}; ordinary
|
|
42
|
+
* form and confirmation dialogs should use the composed {@link Dialog}.
|
|
43
|
+
*/
|
|
44
|
+
export const ModalDialog = RACDialog
|
|
45
|
+
|
|
40
46
|
/**
|
|
41
47
|
* Modal width presets. `md` (default) is the form/confirm dialog (gs DialogContent
|
|
42
48
|
* default, 586px); `sm` is a tighter confirm; `lg`/`xl` host wider, taller content
|
|
@@ -126,18 +132,14 @@ export const Dialog = ({
|
|
|
126
132
|
// when omitted the overlay reads its state from an enclosing DialogTrigger.
|
|
127
133
|
// Either way the RACDialog render-prop `close` resolves against the active
|
|
128
134
|
// overlay state, so `close()` works in both modes.
|
|
129
|
-
<
|
|
130
|
-
// Full-screen is an immersive takeover: swap the light 2px scrim for a
|
|
131
|
-
// heavier backdrop blur so the page behind reads as clearly blurred around
|
|
132
|
-
// the near-fullscreen canvas. (tailwind-merge dedupes the base blur/scrim.)
|
|
133
|
-
className={isFlex ? "bg-black/25 backdrop-blur-lg" : undefined}
|
|
135
|
+
<ModalOverlay
|
|
134
136
|
isOpen={isOpen}
|
|
135
137
|
defaultOpen={defaultOpen}
|
|
136
138
|
onOpenChange={onOpenChange}
|
|
137
139
|
isDismissable={isDismissable}
|
|
138
140
|
>
|
|
139
|
-
<
|
|
140
|
-
<
|
|
141
|
+
<ModalSurface className={SIZE_CLASS[size]}>
|
|
142
|
+
<ModalDialog
|
|
141
143
|
{...props}
|
|
142
144
|
className={isFlex ? 'flex min-h-0 flex-1 flex-col outline-none' : 'outline-none'}
|
|
143
145
|
>
|
|
@@ -200,8 +202,8 @@ export const Dialog = ({
|
|
|
200
202
|
)}
|
|
201
203
|
</>
|
|
202
204
|
)}
|
|
203
|
-
</
|
|
204
|
-
</
|
|
205
|
-
</
|
|
205
|
+
</ModalDialog>
|
|
206
|
+
</ModalSurface>
|
|
207
|
+
</ModalOverlay>
|
|
206
208
|
)
|
|
207
209
|
}
|
package/src/components/input.tsx
CHANGED
|
@@ -20,20 +20,15 @@ import { uic } from '../utils/uic'
|
|
|
20
20
|
*/
|
|
21
21
|
const StyledInput = uic(RACInput, {
|
|
22
22
|
displayName: 'InputControl',
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// a filled field inside a card vanished and read as disabled. Inverted: active
|
|
26
|
-
// = white (surface), disabled = the muted cream. border #eceae1 → border ·
|
|
27
|
-
// hover #aba89c → fg-subtle · focus #75e7b8 → brand-green · error → danger.
|
|
28
|
-
// Identical to textarea.tsx's filled-field skin; `fieldSize` adds the
|
|
29
|
-
// single-line height (gs sizes the field via padding only).
|
|
23
|
+
// gs source: borderless cream fill, 8px radius, 14/18 text and 16px inline
|
|
24
|
+
// padding. Focus keeps an explicit ring for keyboard accessibility.
|
|
30
25
|
baseClass:
|
|
31
|
-
'w-full rounded-lg border
|
|
26
|
+
'w-full rounded-lg border-0 bg-surface-card px-4 text-small text-fg ' +
|
|
32
27
|
'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
|
|
33
|
-
'data-[hovered]:
|
|
34
|
-
'data-[focused]:
|
|
35
|
-
'data-[invalid]:
|
|
36
|
-
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-
|
|
28
|
+
'data-[hovered]:bg-surface-muted ' +
|
|
29
|
+
'data-[focused]:ring-2 data-[focused]:ring-ring ' +
|
|
30
|
+
'data-[invalid]:ring-2 data-[invalid]:ring-danger ' +
|
|
31
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
|
|
37
32
|
variants: {
|
|
38
33
|
// `fieldSize` (not `size`) to avoid colliding with the native <input size>
|
|
39
34
|
// attribute, which RAC's Input inherits (a numeric prop).
|
|
@@ -41,6 +36,7 @@ const StyledInput = uic(RACInput, {
|
|
|
41
36
|
sm: 'h-8',
|
|
42
37
|
md: 'h-10',
|
|
43
38
|
lg: 'h-12',
|
|
39
|
+
tall: 'h-control-tall',
|
|
44
40
|
},
|
|
45
41
|
},
|
|
46
42
|
defaultVariants: {
|
|
@@ -56,13 +52,17 @@ export type InputProps = TextFieldProps & {
|
|
|
56
52
|
/** Error message; pass a string for a static error or rely on validation. */
|
|
57
53
|
errorMessage?: string
|
|
58
54
|
placeholder?: string
|
|
59
|
-
size?: 'sm' | 'md' | 'lg'
|
|
55
|
+
size?: 'sm' | 'md' | 'lg' | 'tall'
|
|
56
|
+
/** Optional class for the TextField root. */
|
|
57
|
+
rootClassName?: string
|
|
58
|
+
/** Optional class for the inner native input (for product-specific composition). */
|
|
59
|
+
inputClassName?: string
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export const Input = ({ label, description, errorMessage, placeholder, size, ...props }: InputProps) => (
|
|
63
|
-
<TextField {...props} className=
|
|
64
|
-
<Label className="text-
|
|
65
|
-
<StyledInput placeholder={placeholder} fieldSize={size} />
|
|
62
|
+
export const Input = ({ label, description, errorMessage, placeholder, size, rootClassName, inputClassName, ...props }: InputProps) => (
|
|
63
|
+
<TextField {...props} className={`flex w-full flex-col gap-3 ${rootClassName ?? ''}`}>
|
|
64
|
+
<Label className="text-panel-heading font-medium text-fg">{label}</Label>
|
|
65
|
+
<StyledInput className={inputClassName} placeholder={placeholder} fieldSize={size} />
|
|
66
66
|
{description ? (
|
|
67
67
|
<Text slot="description" className="text-label text-fg-muted">
|
|
68
68
|
{description}
|
|
@@ -36,6 +36,10 @@ export type SectionTabsProps = {
|
|
|
36
36
|
resetLabel?: string
|
|
37
37
|
/** Glyph for the reset control; defaults to a small grid icon. */
|
|
38
38
|
resetIcon?: ReactNode
|
|
39
|
+
/** Visible reset content. When set, renders a source-style text tab (e.g. "Summary"). */
|
|
40
|
+
resetContent?: ReactNode
|
|
41
|
+
/** Whether the reset/overview destination is the currently selected section. */
|
|
42
|
+
isResetSelected?: boolean
|
|
39
43
|
className?: string
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -49,12 +53,12 @@ const GridGlyph = () => (
|
|
|
49
53
|
)
|
|
50
54
|
|
|
51
55
|
const tabBase =
|
|
52
|
-
'inline-flex h-7 items-center gap-2 rounded-sm px-
|
|
53
|
-
'transition-colors duration-
|
|
56
|
+
'inline-flex h-7 shrink-0 items-center gap-2 rounded-sm px-nav-x py-1.5 text-compact font-normal text-fg-muted outline-none ' +
|
|
57
|
+
'transition-colors duration-150 ease-in-out data-[hovered]:bg-surface-muted data-[hovered]:text-fg ' +
|
|
54
58
|
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
|
|
55
59
|
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:bg-transparent data-[disabled]:text-fg-muted'
|
|
56
60
|
|
|
57
|
-
const tabActive = 'bg-surface-muted
|
|
61
|
+
const tabActive = 'bg-surface-muted text-fg'
|
|
58
62
|
|
|
59
63
|
export function SectionTabs({
|
|
60
64
|
tabs,
|
|
@@ -64,19 +68,37 @@ export function SectionTabs({
|
|
|
64
68
|
onReset,
|
|
65
69
|
resetLabel = 'Reset',
|
|
66
70
|
resetIcon,
|
|
71
|
+
resetContent,
|
|
72
|
+
isResetSelected = false,
|
|
67
73
|
className,
|
|
68
74
|
}: SectionTabsProps) {
|
|
69
75
|
return (
|
|
70
|
-
<div
|
|
76
|
+
<div
|
|
77
|
+
className={[
|
|
78
|
+
'flex min-h-9 w-full flex-nowrap items-start gap-2 overflow-x-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:w-2/3 lg:flex-wrap lg:overflow-visible lg:pb-0',
|
|
79
|
+
className,
|
|
80
|
+
]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(' ')}
|
|
83
|
+
role="group"
|
|
84
|
+
>
|
|
71
85
|
{onReset ? (
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
<div className="sticky left-0 z-10 shrink-0 bg-surface pr-1 lg:static lg:bg-transparent lg:pr-0">
|
|
87
|
+
<Button
|
|
88
|
+
variant="ghost"
|
|
89
|
+
aria-label={resetLabel}
|
|
90
|
+
aria-pressed={isResetSelected}
|
|
91
|
+
onPress={onReset}
|
|
92
|
+
className={[
|
|
93
|
+
resetContent ? tabBase : 'h-7 w-7 rounded-sm p-0 text-fg-muted data-[hovered]:text-fg',
|
|
94
|
+
isResetSelected ? tabActive : '',
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(' ')}
|
|
98
|
+
>
|
|
99
|
+
{resetContent ?? resetIcon ?? <GridGlyph />}
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
80
102
|
) : null}
|
|
81
103
|
{tabs.map((tab) => {
|
|
82
104
|
const selected = active === tab.key
|
|
@@ -30,17 +30,15 @@ import { useInFocusOverlay } from './focus-context'
|
|
|
30
30
|
*/
|
|
31
31
|
const SelectTrigger = uic(RACButton, {
|
|
32
32
|
displayName: 'SelectTrigger',
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// a Card (also surface-card) and read as disabled; hover darkens the border.
|
|
36
|
-
// Error → 2px danger ring driven off the RACSelect root's `data-invalid`.
|
|
33
|
+
// Exact gs source control: 58px tall, 20px inline padding, borderless cream
|
|
34
|
+
// fill and 8px radius.
|
|
37
35
|
baseClass:
|
|
38
|
-
'flex h-
|
|
36
|
+
'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border-0 bg-surface-card px-5 ' +
|
|
39
37
|
'text-small text-fg outline-none transition-colors ' +
|
|
40
|
-
'data-[hovered]:
|
|
38
|
+
'data-[hovered]:bg-surface-muted ' +
|
|
41
39
|
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
|
|
42
|
-
'group-data-[invalid]:
|
|
43
|
-
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-
|
|
40
|
+
'group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
|
|
41
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
|
|
44
42
|
})
|
|
45
43
|
|
|
46
44
|
export const SelectItem = uic(ListBoxItem, {
|
|
@@ -66,6 +64,10 @@ export type SelectProps<T extends object> = RACSelectProps<T> & {
|
|
|
66
64
|
*/
|
|
67
65
|
placeholder: string
|
|
68
66
|
children: ReactNode
|
|
67
|
+
/** Optional class for the Select root. */
|
|
68
|
+
rootClassName?: string
|
|
69
|
+
/** Optional class for the trigger (for product-specific composition). */
|
|
70
|
+
triggerClassName?: string
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export const Select = <T extends object>({
|
|
@@ -74,6 +76,8 @@ export const Select = <T extends object>({
|
|
|
74
76
|
errorMessage,
|
|
75
77
|
placeholder,
|
|
76
78
|
children,
|
|
79
|
+
rootClassName,
|
|
80
|
+
triggerClassName,
|
|
77
81
|
...props
|
|
78
82
|
}: SelectProps<T>) => {
|
|
79
83
|
// In a focus overlay, show the options inline (seamless) instead of a popover.
|
|
@@ -91,8 +95,8 @@ export const Select = <T extends object>({
|
|
|
91
95
|
const err = <FieldError className="text-label text-danger">{errorMessage}</FieldError>
|
|
92
96
|
|
|
93
97
|
return (
|
|
94
|
-
<RACSelect {...props} placeholder={placeholder} className=
|
|
95
|
-
<Label className="text-
|
|
98
|
+
<RACSelect {...props} placeholder={placeholder} className={`group flex flex-col gap-3 ${rootClassName ?? ''}`}>
|
|
99
|
+
<Label className="text-panel-heading font-medium text-fg">{label}</Label>
|
|
96
100
|
{inFocus ? (
|
|
97
101
|
<>
|
|
98
102
|
{listbox}
|
|
@@ -101,7 +105,7 @@ export const Select = <T extends object>({
|
|
|
101
105
|
</>
|
|
102
106
|
) : (
|
|
103
107
|
<>
|
|
104
|
-
<SelectTrigger>
|
|
108
|
+
<SelectTrigger className={triggerClassName}>
|
|
105
109
|
<SelectValue className="data-[placeholder]:text-fg-muted" />
|
|
106
110
|
{/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
|
|
107
111
|
<svg
|
package/src/components/text.tsx
CHANGED
|
@@ -20,6 +20,25 @@ import { uic } from '../utils/uic'
|
|
|
20
20
|
* no i18n.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The large product heading from the GS reference UI (`label-1` in its original
|
|
25
|
+
* token vocabulary): 30px / 32px / medium with -2px tracking.
|
|
26
|
+
*
|
|
27
|
+
* Kept separate from the 28px semantic Heading ramp because it is a reusable
|
|
28
|
+
* product-display treatment, not a document-outline level. Use `asChild` to project
|
|
29
|
+
* it onto the correct h1–h6 element.
|
|
30
|
+
*/
|
|
31
|
+
export const DisplayHeading = uic('h2', {
|
|
32
|
+
displayName: 'DisplayHeading',
|
|
33
|
+
baseClass: 'text-display-large font-medium text-fg',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** Compact 17px / 20px section title used in workflow and dialog panels. */
|
|
37
|
+
export const PanelHeading = uic('h3', {
|
|
38
|
+
displayName: 'PanelHeading',
|
|
39
|
+
baseClass: 'text-panel-heading font-medium text-fg',
|
|
40
|
+
})
|
|
41
|
+
|
|
23
42
|
export const Text = uic('span', {
|
|
24
43
|
displayName: 'Text',
|
|
25
44
|
baseClass: 'text-fg',
|
package/src/components/tile.tsx
CHANGED
|
@@ -31,10 +31,14 @@ export type TileAlign = 'top' | 'center'
|
|
|
31
31
|
const THEME: Record<TileTheme, string> = {
|
|
32
32
|
// Light tiles carry a visible hairline; dark/colored use a transparent border so
|
|
33
33
|
// every tile keeps the same box size (no 1px shift between themes).
|
|
34
|
+
// Every surface pairs with ITS semantic foreground (never raw white / bare fg):
|
|
35
|
+
// `surface-inverted` flips with the theme so it needs the flipping `fg-inverted`;
|
|
36
|
+
// teal/yellow are FIXED light surfaces so they need the stable `fg-on-brand` ink
|
|
37
|
+
// (theme-flipping `fg` went white-on-pastel in a dark shell).
|
|
34
38
|
light: 'border border-border bg-surface-card text-fg',
|
|
35
|
-
dark: 'border border-transparent bg-surface-inverted text-
|
|
36
|
-
teal: 'border border-transparent bg-brand-secondary text-fg',
|
|
37
|
-
yellow: 'border border-transparent bg-accent-yellow text-fg',
|
|
39
|
+
dark: 'border border-transparent bg-surface-inverted text-fg-inverted',
|
|
40
|
+
teal: 'border border-transparent bg-brand-secondary text-fg-on-brand',
|
|
41
|
+
yellow: 'border border-transparent bg-accent-yellow text-fg-on-brand',
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
// The shared inner for EVERY tile (gs `DashboardContentCard`): 16px padding
|
|
@@ -95,10 +99,23 @@ export type TileProps = {
|
|
|
95
99
|
export const tileStatClass = 'text-display font-medium leading-[30px] tracking-wide'
|
|
96
100
|
|
|
97
101
|
function titleTone(theme: TileTheme, subtle: boolean): string {
|
|
98
|
-
if (theme === 'dark') return subtle ? 'text-
|
|
102
|
+
if (theme === 'dark') return subtle ? 'text-fg-inverted/60' : 'text-fg-inverted'
|
|
103
|
+
if (theme === 'teal' || theme === 'yellow') return subtle ? 'text-fg-on-brand/70' : 'text-fg-on-brand'
|
|
99
104
|
return subtle ? 'text-fg-muted' : 'text-fg'
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Muted supporting text / footer tone per surface. `fg-muted` is tuned for white —
|
|
109
|
+
* on the colored surfaces it drops below AA, so those blend their own paired ink
|
|
110
|
+
* instead (/60 · /70 keep ≥4.5:1 in both themes — verified by the contrast test
|
|
111
|
+
* in @podoba/tokens).
|
|
112
|
+
*/
|
|
113
|
+
function mutedTone(theme: TileTheme): string {
|
|
114
|
+
if (theme === 'dark') return 'text-fg-inverted/60'
|
|
115
|
+
if (theme === 'teal' || theme === 'yellow') return 'text-fg-on-brand/70'
|
|
116
|
+
return 'text-fg-muted'
|
|
117
|
+
}
|
|
118
|
+
|
|
102
119
|
/** head / content / tail bands — the normal (non-media) layout. */
|
|
103
120
|
function TileBands({
|
|
104
121
|
theme,
|
|
@@ -123,7 +140,7 @@ function TileBands({
|
|
|
123
140
|
) : null
|
|
124
141
|
const textNode =
|
|
125
142
|
text != null ? (
|
|
126
|
-
<p className={`text-small leading-4 ${theme
|
|
143
|
+
<p className={`text-small leading-4 ${mutedTone(theme)} ${title != null ? 'mt-3' : ''}`}>
|
|
127
144
|
{text}
|
|
128
145
|
</p>
|
|
129
146
|
) : null
|
|
@@ -153,8 +170,9 @@ function TileBands({
|
|
|
153
170
|
}
|
|
154
171
|
|
|
155
172
|
// Footer defaults to 14px muted (gs supporting text); self-styled footer content
|
|
156
|
-
// (links, buttons) overrides both size and colour.
|
|
157
|
-
|
|
173
|
+
// (links, buttons) overrides both size and colour. Shares `mutedTone` — the old
|
|
174
|
+
// white/50 (dark) and fg/70 (teal/yellow) both broke AA in a dark shell.
|
|
175
|
+
const footerTone = mutedTone(theme)
|
|
158
176
|
|
|
159
177
|
return (
|
|
160
178
|
<>
|
|
@@ -197,7 +215,7 @@ function MediaHero({
|
|
|
197
215
|
|
|
198
216
|
return (
|
|
199
217
|
<div
|
|
200
|
-
className={['relative isolate h-full min-h-[200px] w-full overflow-hidden rounded-lg bg-surface-inverted', className]
|
|
218
|
+
className={['@container relative isolate h-full min-h-[200px] w-full overflow-hidden rounded-lg bg-surface-inverted', className]
|
|
201
219
|
.filter(Boolean)
|
|
202
220
|
.join(' ')}
|
|
203
221
|
>
|
|
@@ -239,20 +257,36 @@ function MediaHero({
|
|
|
239
257
|
/>
|
|
240
258
|
) : null}
|
|
241
259
|
|
|
242
|
-
{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
</
|
|
260
|
+
{/* Keep every text overlay in one flex flow. The old independent
|
|
261
|
+
top eyebrow and bottom title bands could overlap when a long title
|
|
262
|
+
wrapped on a narrow card. Compact mobile insets/type preserve the
|
|
263
|
+
200px floor; the spacious composition returns when the card itself,
|
|
264
|
+
rather than merely the viewport, is wide enough. */}
|
|
265
|
+
<div
|
|
266
|
+
data-slot="media-overlay"
|
|
267
|
+
className="pointer-events-none absolute inset-4 z-10 flex min-h-0 flex-col @sm:inset-6"
|
|
268
|
+
>
|
|
269
|
+
{eyebrow ? (
|
|
270
|
+
<span data-slot="media-eyebrow" className="shrink-0 text-compact font-medium text-white">
|
|
271
|
+
{eyebrow}
|
|
272
|
+
</span>
|
|
255
273
|
) : null}
|
|
274
|
+
|
|
275
|
+
{/* Content is click-through so pressing the title/badge falls to the
|
|
276
|
+
press overlay; only `footer` re-enables its own pointer events. */}
|
|
277
|
+
<div data-slot="media-content" className="mt-auto flex min-h-0 flex-col gap-2 @sm:gap-4">
|
|
278
|
+
{title ? (
|
|
279
|
+
<h2 className="max-w-md text-title font-medium leading-tight text-white @sm:text-display">
|
|
280
|
+
{title}
|
|
281
|
+
</h2>
|
|
282
|
+
) : null}
|
|
283
|
+
{footer != null || badge != null ? (
|
|
284
|
+
<div className="flex items-center justify-between gap-3 @sm:gap-4">
|
|
285
|
+
<span className="pointer-events-auto">{footer}</span>
|
|
286
|
+
<span>{badge}</span>
|
|
287
|
+
</div>
|
|
288
|
+
) : null}
|
|
289
|
+
</div>
|
|
256
290
|
</div>
|
|
257
291
|
</>
|
|
258
292
|
)}
|
|
@@ -350,10 +384,14 @@ export type BadgeColor = 'green' | 'yellow' | 'grey' | 'dark'
|
|
|
350
384
|
const BADGE: Record<BadgeColor, string> = {
|
|
351
385
|
// gs `GSTag` green = the brand green (not the pale accent-mint). One saturated
|
|
352
386
|
// brand green for every green tag (New, Active, Published), matching gs 1:1.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
387
|
+
// Pairing rule (same as Tile THEME): fixed light surfaces take the stable
|
|
388
|
+
// `fg-on-brand` ink, the flipping inverted surface takes `fg-inverted`, and grey
|
|
389
|
+
// takes full `fg` — `fg-muted` on `surface-muted` was ~3.7:1, under the 4.5:1
|
|
390
|
+
// AA floor for caption-size text.
|
|
391
|
+
green: 'bg-brand-green text-fg-on-brand',
|
|
392
|
+
yellow: 'bg-accent-yellow text-fg-on-brand',
|
|
393
|
+
grey: 'bg-surface-muted text-fg',
|
|
394
|
+
dark: 'bg-surface-inverted text-fg-inverted',
|
|
357
395
|
}
|
|
358
396
|
|
|
359
397
|
export function Badge({ label, color = 'grey' }: { label: ReactNode; color?: BadgeColor }) {
|
package/src/utils/uic.ts
CHANGED
|
@@ -58,12 +58,15 @@ const twMerge = extendTailwindMerge({
|
|
|
58
58
|
'caption',
|
|
59
59
|
'label',
|
|
60
60
|
'compact',
|
|
61
|
+
'small',
|
|
61
62
|
'callout',
|
|
62
63
|
'body',
|
|
63
64
|
'subtitle',
|
|
64
65
|
'title',
|
|
65
66
|
'headline',
|
|
66
67
|
'display',
|
|
68
|
+
'display-large',
|
|
69
|
+
'panel-heading',
|
|
67
70
|
// Heading ramp (size + line-height).
|
|
68
71
|
'heading1',
|
|
69
72
|
'heading2',
|
|
@@ -75,7 +78,7 @@ const twMerge = extendTailwindMerge({
|
|
|
75
78
|
],
|
|
76
79
|
},
|
|
77
80
|
theme: {
|
|
78
|
-
spacing: ['nav-x'],
|
|
81
|
+
spacing: ['nav-x', 'control-tall'],
|
|
79
82
|
// Custom card/panel radius key (rounded-panel) → dedupes against other
|
|
80
83
|
// rounded-* utilities. xl/2xl are stock keys tailwind-merge already knows.
|
|
81
84
|
radius: ['panel'],
|