@podoba/react 0.0.6 → 0.0.8
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 +3 -3
- package/src/components/brand-page-header.tsx +189 -0
- package/src/components/combobox.tsx +78 -28
- package/src/components/dashboard-grid.tsx +50 -0
- package/src/components/date-picker.tsx +79 -65
- package/src/components/delivery/delivery-status-module.tsx +113 -0
- package/src/components/delivery/download-modal.tsx +204 -0
- package/src/components/delivery/publish-modal.tsx +170 -0
- package/src/components/delivery/send-to-print-modal.tsx +216 -0
- package/src/components/file-upload.tsx +4 -1
- package/src/components/focus-context.ts +11 -0
- package/src/components/focus-field.tsx +213 -0
- package/src/components/multiselect.tsx +55 -37
- package/src/components/request-changes-modal.tsx +134 -0
- package/src/components/rich-text-editor.tsx +5 -1
- package/src/components/select.tsx +53 -38
- package/src/components/slider.tsx +17 -19
- package/src/components/stats-card.tsx +146 -0
- package/src/components/task-approval-modal.tsx +123 -0
- package/src/index.ts +17 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"typecheck": "tsc --build"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@podoba/tokens": "^0.0.
|
|
29
|
-
"@podoba/tailwind": "^0.0.
|
|
28
|
+
"@podoba/tokens": "^0.0.8",
|
|
29
|
+
"@podoba/tailwind": "^0.0.8",
|
|
30
30
|
"react-aria-components": "1.18.0",
|
|
31
31
|
"class-variance-authority": "0.7.1",
|
|
32
32
|
"clsx": "2.1.1",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { type ReactNode, useState } from 'react'
|
|
2
|
+
import { Button } from './button'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* BrandPageHeader — the brand-workspace page header (port of gs-platform
|
|
6
|
+
* `GSPageHeader` + `ExpandableCTA`).
|
|
7
|
+
*
|
|
8
|
+
* gs-platform's header is a two-column grid: a left "welcome" section
|
|
9
|
+
* (optional breadcrumbs + a large greeting line) and a right section holding an
|
|
10
|
+
* `ExpandableCTA` — a collapsed teal pill that expands into an inline
|
|
11
|
+
* create-hub panel. We port that interaction to React Aria + Tailwind + the
|
|
12
|
+
* teal accent token (`brand-secondary`), dropping gs-platform's mobile
|
|
13
|
+
* fixed-sheet behaviour for a simpler inline disclosure.
|
|
14
|
+
*
|
|
15
|
+
* The expandable CTA is a controlled disclosure: the collapsed teal pill is a
|
|
16
|
+
* React Aria `Button` (keyboard + focus ring + press handling) wired to a
|
|
17
|
+
* `region` with `aria-expanded`/`aria-controls`, so it follows the WAI-ARIA
|
|
18
|
+
* disclosure pattern without pulling in extra markup.
|
|
19
|
+
*
|
|
20
|
+
* Presentational only (hard rule #1): every string (greeting, breadcrumb
|
|
21
|
+
* labels, CTA label) arrives via props — no API, no i18n.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type BrandPageHeaderCrumb = {
|
|
25
|
+
label: ReactNode
|
|
26
|
+
/** Optional click handler — when set the crumb renders as a button. */
|
|
27
|
+
onPress?: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type BrandPageHeaderProps = {
|
|
31
|
+
/** The large greeting / page-title slot (e.g. "Good morning Jonas 👋"). */
|
|
32
|
+
greeting: ReactNode
|
|
33
|
+
/**
|
|
34
|
+
* gs-style "title to go back": a muted, clickable PARENT link rendered as the
|
|
35
|
+
* line ABOVE the title (so the title reads "Parent ⏎ Current"). Pass a real
|
|
36
|
+
* router `<Link>` so it's an anchor (cmd/middle-click, deep-link-safe), exactly
|
|
37
|
+
* like gs's `parentLink`. The header styles it muted with a hover→fg affordance.
|
|
38
|
+
*/
|
|
39
|
+
parentLink?: ReactNode
|
|
40
|
+
/** Optional breadcrumb trail rendered above the greeting. */
|
|
41
|
+
breadcrumbs?: BrandPageHeaderCrumb[]
|
|
42
|
+
/**
|
|
43
|
+
* Arbitrary right-column CTA node (e.g. the gs hero `CtaPill` banner). When
|
|
44
|
+
* set it REPLACES the collapsed-pill ExpandableCTA — use this for the
|
|
45
|
+
* gs-faithful "Let's create something" hero. `ctaLabel`/`createHub` are ignored.
|
|
46
|
+
*/
|
|
47
|
+
cta?: ReactNode
|
|
48
|
+
/** Label on the collapsed teal "Create" pill. Required to render the CTA. */
|
|
49
|
+
ctaLabel?: ReactNode
|
|
50
|
+
/** Inline content revealed when the CTA expands (the create hub). */
|
|
51
|
+
createHub?: ReactNode
|
|
52
|
+
/** Controlled expansion (optional — uncontrolled by default). */
|
|
53
|
+
expanded?: boolean
|
|
54
|
+
onExpandedChange?: (expanded: boolean) => void
|
|
55
|
+
/** Accessible label for the close control when expanded. Defaults to "Close". */
|
|
56
|
+
closeLabel?: string
|
|
57
|
+
/** Sticky header on scroll. */
|
|
58
|
+
sticky?: boolean
|
|
59
|
+
className?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const PANEL_ID = 'brand-page-header-create-hub'
|
|
63
|
+
|
|
64
|
+
export function BrandPageHeader({
|
|
65
|
+
greeting,
|
|
66
|
+
parentLink,
|
|
67
|
+
breadcrumbs,
|
|
68
|
+
cta,
|
|
69
|
+
ctaLabel,
|
|
70
|
+
createHub,
|
|
71
|
+
expanded: expandedProp,
|
|
72
|
+
onExpandedChange,
|
|
73
|
+
closeLabel = 'Close',
|
|
74
|
+
sticky = false,
|
|
75
|
+
className,
|
|
76
|
+
}: BrandPageHeaderProps) {
|
|
77
|
+
const [internalExpanded, setInternalExpanded] = useState(false)
|
|
78
|
+
const isControlled = expandedProp !== undefined
|
|
79
|
+
const expanded = isControlled ? expandedProp : internalExpanded
|
|
80
|
+
const hasExpandable = Boolean(createHub)
|
|
81
|
+
|
|
82
|
+
const setExpanded = (next: boolean) => {
|
|
83
|
+
if (!isControlled) setInternalExpanded(next)
|
|
84
|
+
onExpandedChange?.(next)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
className={[
|
|
90
|
+
'mb-6 w-full',
|
|
91
|
+
sticky ? 'sticky top-0 z-30 bg-surface' : '',
|
|
92
|
+
className,
|
|
93
|
+
]
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join(' ')}
|
|
96
|
+
>
|
|
97
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
|
98
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
99
|
+
{breadcrumbs && breadcrumbs.length > 0 ? (
|
|
100
|
+
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-compact text-fg-muted">
|
|
101
|
+
{breadcrumbs.map((crumb, i) => (
|
|
102
|
+
<span key={i} className="inline-flex items-center gap-1">
|
|
103
|
+
{i > 0 ? <span aria-hidden="true">/</span> : null}
|
|
104
|
+
{crumb.onPress ? (
|
|
105
|
+
<Button
|
|
106
|
+
variant="ghost"
|
|
107
|
+
onPress={crumb.onPress}
|
|
108
|
+
className="h-auto rounded-sm p-0 text-compact font-normal text-fg-muted data-[hovered]:bg-transparent data-[hovered]:text-fg data-[hovered]:underline"
|
|
109
|
+
>
|
|
110
|
+
{crumb.label}
|
|
111
|
+
</Button>
|
|
112
|
+
) : (
|
|
113
|
+
<span>{crumb.label}</span>
|
|
114
|
+
)}
|
|
115
|
+
</span>
|
|
116
|
+
))}
|
|
117
|
+
</nav>
|
|
118
|
+
) : null}
|
|
119
|
+
<h1 className="text-display font-medium leading-[1.12] tracking-[-0.56px] text-fg">
|
|
120
|
+
{parentLink ? (
|
|
121
|
+
// gs "title to go back": muted clickable parent line above the title.
|
|
122
|
+
// `[&_a]` styles the nested router <Link> (anchor) without @app/ui
|
|
123
|
+
// importing the router.
|
|
124
|
+
<>
|
|
125
|
+
<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">
|
|
126
|
+
{parentLink}
|
|
127
|
+
</span>
|
|
128
|
+
<br />
|
|
129
|
+
</>
|
|
130
|
+
) : null}
|
|
131
|
+
{greeting}
|
|
132
|
+
</h1>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{cta ? (
|
|
136
|
+
<div className="w-full shrink-0 sm:w-auto sm:min-w-[360px]">{cta}</div>
|
|
137
|
+
) : ctaLabel ? (
|
|
138
|
+
<div className="shrink-0">
|
|
139
|
+
{hasExpandable ? (
|
|
140
|
+
<Button
|
|
141
|
+
onPress={() => setExpanded(!expanded)}
|
|
142
|
+
aria-expanded={expanded}
|
|
143
|
+
aria-controls={PANEL_ID}
|
|
144
|
+
className="h-10 rounded-full bg-brand-secondary px-5 text-sm font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80"
|
|
145
|
+
>
|
|
146
|
+
{ctaLabel}
|
|
147
|
+
</Button>
|
|
148
|
+
) : (
|
|
149
|
+
<Button className="h-10 rounded-full bg-brand-secondary px-5 text-sm font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80">
|
|
150
|
+
{ctaLabel}
|
|
151
|
+
</Button>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
) : null}
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{hasExpandable ? (
|
|
158
|
+
<div
|
|
159
|
+
id={PANEL_ID}
|
|
160
|
+
role="region"
|
|
161
|
+
aria-label={typeof ctaLabel === 'string' ? ctaLabel : undefined}
|
|
162
|
+
hidden={!expanded}
|
|
163
|
+
className="mt-4"
|
|
164
|
+
>
|
|
165
|
+
{expanded ? (
|
|
166
|
+
<div className="relative rounded-lg border border-border bg-surface-card p-4 animate-expand-cta motion-reduce:animate-none">
|
|
167
|
+
<Button
|
|
168
|
+
variant="ghost"
|
|
169
|
+
aria-label={closeLabel}
|
|
170
|
+
onPress={() => setExpanded(false)}
|
|
171
|
+
className="absolute right-2 top-2 h-7 w-7 rounded-full p-0 text-fg-muted data-[hovered]:bg-surface-muted data-[hovered]:text-fg"
|
|
172
|
+
>
|
|
173
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
174
|
+
<path
|
|
175
|
+
d="M6 6l12 12M18 6L6 18"
|
|
176
|
+
stroke="currentColor"
|
|
177
|
+
strokeWidth="2"
|
|
178
|
+
strokeLinecap="round"
|
|
179
|
+
/>
|
|
180
|
+
</svg>
|
|
181
|
+
</Button>
|
|
182
|
+
{createHub}
|
|
183
|
+
</div>
|
|
184
|
+
) : null}
|
|
185
|
+
</div>
|
|
186
|
+
) : null}
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import {
|
|
3
|
+
Autocomplete,
|
|
3
4
|
Button as RACButton,
|
|
4
5
|
ComboBox as RACComboBox,
|
|
5
6
|
type ComboBoxProps as RACComboBoxProps,
|
|
@@ -10,9 +11,12 @@ import {
|
|
|
10
11
|
ListBoxItem,
|
|
11
12
|
type ListBoxItemProps,
|
|
12
13
|
Popover,
|
|
14
|
+
SearchField,
|
|
13
15
|
Text,
|
|
16
|
+
useFilter,
|
|
14
17
|
} from 'react-aria-components'
|
|
15
18
|
import { uic } from '../utils/uic'
|
|
19
|
+
import { useInFocusOverlay } from './focus-context'
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* ComboBox — a filterable single-select: a text input that narrows a listbox as
|
|
@@ -65,32 +69,78 @@ export const ComboBox = <T extends object>({
|
|
|
65
69
|
errorMessage,
|
|
66
70
|
placeholder,
|
|
67
71
|
children,
|
|
72
|
+
selectedKey,
|
|
73
|
+
onSelectionChange,
|
|
68
74
|
...props
|
|
69
|
-
}: ComboBoxProps<T>) =>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
)
|
|
75
|
+
}: ComboBoxProps<T>) => {
|
|
76
|
+
const inFocus = useInFocusOverlay()
|
|
77
|
+
const { contains } = useFilter({ sensitivity: 'base' })
|
|
78
|
+
const desc = description ? (
|
|
79
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
80
|
+
{description}
|
|
81
|
+
</Text>
|
|
82
|
+
) : null
|
|
83
|
+
|
|
84
|
+
// Focus overlay: a bare search input + an inline filtered list (RAC ComboBox
|
|
85
|
+
// only fills its listbox while open, so compose Autocomplete + ListBox here).
|
|
86
|
+
if (inFocus) {
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex flex-col gap-2">
|
|
89
|
+
<span className="text-heading5 font-medium text-fg">{label}</span>
|
|
90
|
+
<Autocomplete filter={contains}>
|
|
91
|
+
<SearchField aria-label={typeof label === 'string' ? label : 'Search'}>
|
|
92
|
+
<RACInput
|
|
93
|
+
placeholder={placeholder}
|
|
94
|
+
className="w-full border-0 bg-transparent p-0 text-3xl font-medium text-fg outline-none placeholder:text-fg-subtle"
|
|
95
|
+
/>
|
|
96
|
+
</SearchField>
|
|
97
|
+
<ListBox
|
|
98
|
+
selectionMode="single"
|
|
99
|
+
selectedKeys={selectedKey != null ? new Set([selectedKey]) : new Set()}
|
|
100
|
+
onSelectionChange={(keys) => {
|
|
101
|
+
const k = keys === 'all' ? undefined : [...keys][0]
|
|
102
|
+
onSelectionChange?.(k ?? null)
|
|
103
|
+
}}
|
|
104
|
+
renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No results</div>}
|
|
105
|
+
className="mt-2 flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
|
|
106
|
+
>
|
|
107
|
+
{children}
|
|
108
|
+
</ListBox>
|
|
109
|
+
</Autocomplete>
|
|
110
|
+
{desc}
|
|
111
|
+
{errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null}
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
// `menuTrigger="focus"` opens the list on focus/click (not only on typing), so
|
|
118
|
+
// the options are always one interaction away. Consumers can override via props.
|
|
119
|
+
<RACComboBox
|
|
120
|
+
menuTrigger="focus"
|
|
121
|
+
selectedKey={selectedKey}
|
|
122
|
+
onSelectionChange={onSelectionChange}
|
|
123
|
+
{...props}
|
|
124
|
+
className="group flex flex-col gap-2"
|
|
125
|
+
>
|
|
126
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
127
|
+
<div className="relative">
|
|
128
|
+
<ComboBoxInput placeholder={placeholder} />
|
|
129
|
+
{/* RAC uses this Button to toggle the listbox open/closed. */}
|
|
130
|
+
<RACButton className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring">
|
|
131
|
+
<Chevron />
|
|
132
|
+
</RACButton>
|
|
133
|
+
</div>
|
|
134
|
+
{desc}
|
|
135
|
+
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
136
|
+
<Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
|
|
137
|
+
<ListBox
|
|
138
|
+
className="flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
|
|
139
|
+
renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No results</div>}
|
|
140
|
+
>
|
|
141
|
+
{children}
|
|
142
|
+
</ListBox>
|
|
143
|
+
</Popover>
|
|
144
|
+
</RACComboBox>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DashboardGrid + DashboardTile — responsive 12-column dashboard grid
|
|
5
|
+
* (port of gs-platform `DashboardGrid`).
|
|
6
|
+
*
|
|
7
|
+
* gs-platform's grid is a 12-col CSS grid where each item declares a `width`
|
|
8
|
+
* (3/4/6/12) and tiles collapse to full width on mobile. We reproduce that with
|
|
9
|
+
* Tailwind responsive column spans — no SCSS, no external grid lib.
|
|
10
|
+
*
|
|
11
|
+
* **Drag-to-reorder is DEFERRED** for this issue: gs-platform implements it with
|
|
12
|
+
* `@dnd-kit/core` + `@dnd-kit/sortable`, which are not dependencies of this repo
|
|
13
|
+
* and adding them is out of scope (no dependency bumps). A static responsive grid
|
|
14
|
+
* is acceptable per the issue spec; the `DashboardTile` API is shaped so a future
|
|
15
|
+
* sortable wrapper can slot in without changing call sites.
|
|
16
|
+
*
|
|
17
|
+
* Presentational only (hard rule #1).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type DashboardTileSpan = 3 | 4 | 6 | 12
|
|
21
|
+
|
|
22
|
+
const SPAN_CLASS: Record<DashboardTileSpan, string> = {
|
|
23
|
+
// Mobile: always full width. ≥sm: take the requested fraction of 12 cols.
|
|
24
|
+
3: 'col-span-12 sm:col-span-6 lg:col-span-3',
|
|
25
|
+
4: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
26
|
+
6: 'col-span-12 sm:col-span-6',
|
|
27
|
+
12: 'col-span-12',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type DashboardGridProps = {
|
|
31
|
+
children: ReactNode
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function DashboardGrid({ children, className }: DashboardGridProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className={['grid grid-cols-12 gap-4', className].filter(Boolean).join(' ')}>{children}</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DashboardTileProps = {
|
|
42
|
+
/** Column span out of 12 (collapses to full width below `lg`). Default 3. */
|
|
43
|
+
span?: DashboardTileSpan
|
|
44
|
+
children: ReactNode
|
|
45
|
+
className?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function DashboardTile({ span = 3, children, className }: DashboardTileProps) {
|
|
49
|
+
return <div className={[SPAN_CLASS[span], className].filter(Boolean).join(' ')}>{children}</div>
|
|
50
|
+
}
|
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
Popover,
|
|
21
21
|
Text,
|
|
22
22
|
} from 'react-aria-components'
|
|
23
|
+
import { clsx } from 'clsx'
|
|
23
24
|
import { dateInputClass, segmentClass } from './date-field'
|
|
25
|
+
import { useInFocusOverlay } from './focus-context'
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* DatePicker — a `DateField` with a calendar popover. Built on React Aria
|
|
@@ -38,74 +40,86 @@ const Chevron = ({ dir }: { dir: 'left' | 'right' }) => (
|
|
|
38
40
|
</svg>
|
|
39
41
|
)
|
|
40
42
|
|
|
43
|
+
const calendarCellClass =
|
|
44
|
+
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-sm text-fg outline-none transition-colors ' +
|
|
45
|
+
'data-[outside-month]:text-fg-subtle data-[hovered]:bg-surface-muted ' +
|
|
46
|
+
'data-[selected]:bg-fg data-[selected]:text-fg-inverted data-[selected]:font-medium ' +
|
|
47
|
+
'data-[unavailable]:text-fg-subtle data-[unavailable]:line-through ' +
|
|
48
|
+
'data-[disabled]:opacity-40 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
|
|
49
|
+
|
|
50
|
+
function CalendarBody() {
|
|
51
|
+
return (
|
|
52
|
+
<Calendar className="w-[17.5rem]">
|
|
53
|
+
<header className="flex items-center justify-between pb-3">
|
|
54
|
+
<RACButton slot="previous" className={navButton}>
|
|
55
|
+
<Chevron dir="left" />
|
|
56
|
+
</RACButton>
|
|
57
|
+
<Heading className="text-sm font-medium text-fg" />
|
|
58
|
+
<RACButton slot="next" className={navButton}>
|
|
59
|
+
<Chevron dir="right" />
|
|
60
|
+
</RACButton>
|
|
61
|
+
</header>
|
|
62
|
+
<CalendarGrid className="w-full border-collapse">
|
|
63
|
+
<CalendarGridHeader>
|
|
64
|
+
{(day) => (
|
|
65
|
+
<CalendarHeaderCell className="pb-1 text-micro font-medium uppercase tracking-wide text-fg-subtle">
|
|
66
|
+
{day}
|
|
67
|
+
</CalendarHeaderCell>
|
|
68
|
+
)}
|
|
69
|
+
</CalendarGridHeader>
|
|
70
|
+
<CalendarGridBody>{(date) => <CalendarCell date={date} className={calendarCellClass} />}</CalendarGridBody>
|
|
71
|
+
</CalendarGrid>
|
|
72
|
+
</Calendar>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
41
76
|
export type DatePickerProps<T extends DateValue> = RACDatePickerProps<T> & {
|
|
42
77
|
label: ReactNode
|
|
43
78
|
description?: ReactNode
|
|
44
79
|
errorMessage?: string
|
|
45
80
|
}
|
|
46
81
|
|
|
47
|
-
export const DatePicker = <T extends DateValue>({ label, description, errorMessage, ...props }: DatePickerProps<T>) =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
className=
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{(date) => (
|
|
92
|
-
<CalendarCell
|
|
93
|
-
date={date}
|
|
94
|
-
className={
|
|
95
|
-
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-sm text-fg outline-none transition-colors ' +
|
|
96
|
-
'data-[outside-month]:text-fg-subtle ' +
|
|
97
|
-
'data-[hovered]:bg-surface-muted ' +
|
|
98
|
-
'data-[selected]:bg-fg data-[selected]:text-fg-inverted data-[selected]:font-medium ' +
|
|
99
|
-
'data-[unavailable]:text-fg-subtle data-[unavailable]:line-through ' +
|
|
100
|
-
'data-[disabled]:opacity-40 ' +
|
|
101
|
-
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
|
|
102
|
-
}
|
|
103
|
-
/>
|
|
104
|
-
)}
|
|
105
|
-
</CalendarGridBody>
|
|
106
|
-
</CalendarGrid>
|
|
107
|
-
</Calendar>
|
|
108
|
-
</Dialog>
|
|
109
|
-
</Popover>
|
|
110
|
-
</RACDatePicker>
|
|
111
|
-
)
|
|
82
|
+
export const DatePicker = <T extends DateValue>({ label, description, errorMessage, ...props }: DatePickerProps<T>) => {
|
|
83
|
+
// In a focus overlay, show the calendar inline (open) instead of a popover.
|
|
84
|
+
const inFocus = useInFocusOverlay()
|
|
85
|
+
return (
|
|
86
|
+
<RACDatePicker {...props} className="group flex flex-col gap-2">
|
|
87
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
88
|
+
{/* In focus mode the field is bare + large and the calendar is borderless,
|
|
89
|
+
so field + calendar read as one seamless editor rather than boxes. */}
|
|
90
|
+
<Group className={inFocus ? 'flex w-full items-center' : `${dateInputClass} pr-2`}>
|
|
91
|
+
<DateInput className={clsx('flex flex-1 items-center gap-0.5', inFocus && 'text-3xl font-medium')}>
|
|
92
|
+
{(segment) => <DateSegment segment={segment} className={segmentClass} />}
|
|
93
|
+
</DateInput>
|
|
94
|
+
{inFocus ? null : (
|
|
95
|
+
<RACButton
|
|
96
|
+
aria-label="Open calendar"
|
|
97
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-fg-muted outline-none transition-colors hover:bg-surface-muted hover:text-fg data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring"
|
|
98
|
+
>
|
|
99
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
100
|
+
<rect x="3" y="4" width="18" height="18" rx="2" />
|
|
101
|
+
<path d="M16 2v4M8 2v4M3 10h18" />
|
|
102
|
+
</svg>
|
|
103
|
+
</RACButton>
|
|
104
|
+
)}
|
|
105
|
+
</Group>
|
|
106
|
+
{description ? (
|
|
107
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
108
|
+
{description}
|
|
109
|
+
</Text>
|
|
110
|
+
) : null}
|
|
111
|
+
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
112
|
+
{inFocus ? (
|
|
113
|
+
<div className="mt-2 -ml-2">
|
|
114
|
+
<CalendarBody />
|
|
115
|
+
</div>
|
|
116
|
+
) : (
|
|
117
|
+
<Popover className="rounded-lg border border-border bg-surface p-4 shadow-lg outline-none">
|
|
118
|
+
<Dialog className="outline-none">
|
|
119
|
+
<CalendarBody />
|
|
120
|
+
</Dialog>
|
|
121
|
+
</Popover>
|
|
122
|
+
)}
|
|
123
|
+
</RACDatePicker>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @app/ui — delivery status module (issue 17).
|
|
2
|
+
//
|
|
3
|
+
// PRESENTATIONAL "black status module" — the signature delivery-UI surface ported
|
|
4
|
+
// from the gs-platform `FinalOutputsHandoffPanel` (`.statusModule`): a dark
|
|
5
|
+
// inverted surface (white-on-#242423) carrying a status header row, muted status
|
|
6
|
+
// text, an optional progress track with a GREEN progress bar, and optional
|
|
7
|
+
// trailing "ready" pills. Used by BOTH the final-outputs handoff panel and the
|
|
8
|
+
// live delivery-status cards so the two read as one visual language.
|
|
9
|
+
//
|
|
10
|
+
// APP-AGNOSTIC (hard rule #1): no apps/web imports, no API hooks, no i18n. Every
|
|
11
|
+
// string is passed in; the owning surface in apps/web supplies the i18n copy and
|
|
12
|
+
// wires behaviour. Pure markup + Tailwind classes bound to @app/tokens CSS vars.
|
|
13
|
+
//
|
|
14
|
+
// ── Styling (hard rule #3) ────────────────────────────────────────────────────
|
|
15
|
+
// Tailwind + @app/tokens only — NO SCSS, NO raw hex.
|
|
16
|
+
// gs-platform `--color-text-primary` (#000 module surface) → our `surface-inverted`
|
|
17
|
+
// gs-platform `--color-background` (white text) → our `fg-inverted`
|
|
18
|
+
// gs-platform `--color-brand-green` (#22c55e progress) → our `success`
|
|
19
|
+
// gs-platform `--radius-full` (track / pills) → `rounded-full`
|
|
20
|
+
// gs-platform `--radius-panel-sm` (module) → `rounded-md`
|
|
21
|
+
//
|
|
22
|
+
// ── a11y ──────────────────────────────────────────────────────────────────────
|
|
23
|
+
// White (#fff) on the dark surface (#242423) ≈ 14.9:1 — passes WCAG AA. The muted
|
|
24
|
+
// status text uses `text-white/70` (white at 70% over the dark surface) ≈ 8.6:1 —
|
|
25
|
+
// still AA. (We use the stock `white`/opacity utilities for the muted tints rather
|
|
26
|
+
// than `fg-inverted/NN`: Tailwind cannot inject an alpha channel into an arbitrary
|
|
27
|
+
// `var(--color-…)`, so an opacity modifier on `fg-inverted` would be a no-op; the
|
|
28
|
+
// `fg-inverted` token IS white, so `text-white` is the same colour, alpha-capable.)
|
|
29
|
+
// The green bar (#22c55e on #242423) is a NON-TEXT graphical indicator (~5.6:1,
|
|
30
|
+
// above the 3:1 non-text minimum). The progress track is `aria-hidden`; when a
|
|
31
|
+
// numeric value matters, expose it in the header (text), not the bar.
|
|
32
|
+
|
|
33
|
+
import type { ReactNode } from 'react'
|
|
34
|
+
import { uic } from '../../utils/uic'
|
|
35
|
+
|
|
36
|
+
/** The dark status-module shell (inverted surface + white text). */
|
|
37
|
+
export const DeliveryStatusModuleRoot = uic('div', {
|
|
38
|
+
displayName: 'DeliveryStatusModule',
|
|
39
|
+
baseClass:
|
|
40
|
+
'grid gap-3 rounded-md bg-surface-inverted p-4 text-fg-inverted',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export interface DeliveryStatusModuleProps {
|
|
44
|
+
/** Status header — left-aligned headline (e.g. "Files ready"). */
|
|
45
|
+
heading: ReactNode
|
|
46
|
+
/** Optional right-aligned header annotation (e.g. "50 %", "3 ready"). */
|
|
47
|
+
headingMeta?: ReactNode
|
|
48
|
+
/** Muted status sentence under the header. */
|
|
49
|
+
statusText?: ReactNode
|
|
50
|
+
/**
|
|
51
|
+
* Optional progress fraction in [0, 1]. When provided, renders the green
|
|
52
|
+
* progress track. Clamped; the track itself is decorative (`aria-hidden`).
|
|
53
|
+
*/
|
|
54
|
+
progress?: number
|
|
55
|
+
/** Optional trailing pills / actions row (e.g. "ready" chips). */
|
|
56
|
+
children?: ReactNode
|
|
57
|
+
/** Forwarded test id. */
|
|
58
|
+
'data-testid'?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Black status module with an optional green progress bar. The visual heart of the
|
|
63
|
+
* delivery UI — stack it under a white output card or use it as a job-status card.
|
|
64
|
+
*/
|
|
65
|
+
export function DeliveryStatusModule({
|
|
66
|
+
heading,
|
|
67
|
+
headingMeta,
|
|
68
|
+
statusText,
|
|
69
|
+
progress,
|
|
70
|
+
children,
|
|
71
|
+
'data-testid': testId,
|
|
72
|
+
}: DeliveryStatusModuleProps): React.ReactNode {
|
|
73
|
+
const hasProgress = progress != null
|
|
74
|
+
const pct = hasProgress ? Math.max(0, Math.min(1, progress)) * 100 : 0
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<DeliveryStatusModuleRoot data-testid={testId}>
|
|
78
|
+
<div className="flex items-center justify-between gap-4 text-sm font-medium">
|
|
79
|
+
<span>{heading}</span>
|
|
80
|
+
{headingMeta != null ? <span>{headingMeta}</span> : null}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{statusText != null ? (
|
|
84
|
+
<p className="m-0 text-xs text-white/70">{statusText}</p>
|
|
85
|
+
) : null}
|
|
86
|
+
|
|
87
|
+
{hasProgress ? (
|
|
88
|
+
<div
|
|
89
|
+
className="h-2 overflow-hidden rounded-full bg-white/25"
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
>
|
|
92
|
+
<span
|
|
93
|
+
className="block h-full rounded-full bg-success transition-[width]"
|
|
94
|
+
style={{ width: `${pct}%` }}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
) : null}
|
|
98
|
+
|
|
99
|
+
{children != null ? (
|
|
100
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-white/80">
|
|
101
|
+
{children}
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
</DeliveryStatusModuleRoot>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A hairline "ready" pill for the trailing actions row (inverted palette). */
|
|
109
|
+
export const DeliveryStatusPill = uic('span', {
|
|
110
|
+
displayName: 'DeliveryStatusPill',
|
|
111
|
+
baseClass:
|
|
112
|
+
'inline-flex min-h-5 items-center rounded-full border border-white/30 px-3',
|
|
113
|
+
})
|