@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.6",
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.6",
29
- "@podoba/tailwind": "^0.0.6",
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
- // `menuTrigger="focus"` opens the list on focus/click (not only on typing), so
71
- // the options are always one interaction away. Consumers can override via props.
72
- <RACComboBox menuTrigger="focus" {...props} className="group flex flex-col gap-2">
73
- <Label className="text-heading5 font-medium text-fg">{label}</Label>
74
- <div className="relative">
75
- <ComboBoxInput placeholder={placeholder} />
76
- {/* RAC uses this Button to toggle the listbox open/closed. */}
77
- <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">
78
- <Chevron />
79
- </RACButton>
80
- </div>
81
- {description ? (
82
- <Text slot="description" className="text-xs text-fg-muted">
83
- {description}
84
- </Text>
85
- ) : null}
86
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
87
- <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
88
- <ListBox
89
- className="flex max-h-64 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
90
- renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No results</div>}
91
- >
92
- {children}
93
- </ListBox>
94
- </Popover>
95
- </RACComboBox>
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
- <RACDatePicker {...props} className="group flex flex-col gap-2">
49
- <Label className="text-heading5 font-medium text-fg">{label}</Label>
50
- <Group className={`${dateInputClass} pr-2`}>
51
- <DateInput className="flex flex-1 items-center gap-0.5">
52
- {(segment) => <DateSegment segment={segment} className={segmentClass} />}
53
- </DateInput>
54
- <RACButton
55
- aria-label="Open calendar"
56
- 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"
57
- >
58
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
59
- <rect x="3" y="4" width="18" height="18" rx="2" />
60
- <path d="M16 2v4M8 2v4M3 10h18" />
61
- </svg>
62
- </RACButton>
63
- </Group>
64
- {description ? (
65
- <Text slot="description" className="text-xs text-fg-muted">
66
- {description}
67
- </Text>
68
- ) : null}
69
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
70
- <Popover className="rounded-lg border border-border bg-surface p-4 shadow-lg outline-none">
71
- <Dialog className="outline-none">
72
- <Calendar className="w-[17.5rem]">
73
- <header className="flex items-center justify-between pb-3">
74
- <RACButton slot="previous" className={navButton}>
75
- <Chevron dir="left" />
76
- </RACButton>
77
- <Heading className="text-sm font-medium text-fg" />
78
- <RACButton slot="next" className={navButton}>
79
- <Chevron dir="right" />
80
- </RACButton>
81
- </header>
82
- <CalendarGrid className="w-full border-collapse">
83
- <CalendarGridHeader>
84
- {(day) => (
85
- <CalendarHeaderCell className="pb-1 text-micro font-medium uppercase tracking-wide text-fg-subtle">
86
- {day}
87
- </CalendarHeaderCell>
88
- )}
89
- </CalendarGridHeader>
90
- <CalendarGridBody>
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
+ })