@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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Button as RACButton } from 'react-aria-components'
|
|
2
|
+
import { type ReactElement, type ReactNode, cloneElement, isValidElement } from 'react'
|
|
3
|
+
import { uic } from '../utils/uic'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* StatsCard — KPI card (port of gs-platform `GSStatsCard` / `StatsCard`).
|
|
7
|
+
*
|
|
8
|
+
* gs-platform renders title (heading-5) · large value (label-1) · footer
|
|
9
|
+
* (small body) inside a spacious cream `Tile` with panel radius. The `dark`
|
|
10
|
+
* variant mirrors gs's dark "Cloud" tile (inverted surface, light text).
|
|
11
|
+
*
|
|
12
|
+
* Presentational only (hard rule #1): all copy via props, no API/i18n.
|
|
13
|
+
*
|
|
14
|
+
* When `onPress` is set the whole card becomes a React Aria `Button` — it gets
|
|
15
|
+
* keyboard activation, `data-[focus-visible]` ring and `data-[pressed]` for
|
|
16
|
+
* free. Otherwise it renders as a plain non-interactive `div`.
|
|
17
|
+
*/
|
|
18
|
+
export type StatsCardProps = {
|
|
19
|
+
/** Small uppercase-ish label above the value. */
|
|
20
|
+
title: ReactNode
|
|
21
|
+
/** The large headline number / value. */
|
|
22
|
+
value: ReactNode
|
|
23
|
+
/** Optional supporting footer line (e.g. "+3 this week"). */
|
|
24
|
+
footer?: ReactNode
|
|
25
|
+
/** When provided the card becomes a clickable React Aria Button. */
|
|
26
|
+
onPress?: () => void
|
|
27
|
+
/**
|
|
28
|
+
* Render the card AS the provided child element (e.g. a router `<Link>`) instead
|
|
29
|
+
* of a button — so the whole tile is a real anchor (cmd/middle-click, open in
|
|
30
|
+
* new tab, link semantics). The card styling + hover/focus are merged onto the
|
|
31
|
+
* child and the title/value/footer are injected as its children. Takes
|
|
32
|
+
* precedence over `onPress`.
|
|
33
|
+
*/
|
|
34
|
+
asChild?: boolean
|
|
35
|
+
/** The element to render as the card when `asChild` is set (a single element). */
|
|
36
|
+
children?: ReactNode
|
|
37
|
+
/** Dark/inverted surface (gs's dark "Cloud" tile). */
|
|
38
|
+
dark?: boolean
|
|
39
|
+
/** Accessible label for the clickable card (defaults to nothing — title is read). */
|
|
40
|
+
'aria-label'?: string
|
|
41
|
+
className?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// gs Tile: `--radius-lg` (8px) corners, 16px padding, min-height 208px (size
|
|
45
|
+
// "compact"), and it FILLS its grid cell (h-full) so a tall dashboard tile is a
|
|
46
|
+
// tall card. The three bands (title top / value centered / footer bottom) are laid
|
|
47
|
+
// out by StatsCardBody. The "skin" (surface + border) is split out so the dark
|
|
48
|
+
// variant can override it cleanly.
|
|
49
|
+
const baseSurface = 'flex h-full min-h-[208px] flex-col gap-2 rounded-lg p-4 text-left'
|
|
50
|
+
const lightSkin = 'border border-border bg-surface-card'
|
|
51
|
+
const darkSkin = 'border border-transparent bg-surface-inverted'
|
|
52
|
+
|
|
53
|
+
function StatsCardBody({
|
|
54
|
+
title,
|
|
55
|
+
value,
|
|
56
|
+
footer,
|
|
57
|
+
dark,
|
|
58
|
+
}: Pick<StatsCardProps, 'title' | 'value' | 'footer' | 'dark'>) {
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<span
|
|
62
|
+
className={`text-body font-medium leading-5 ${dark ? 'text-white' : 'text-fg'}`}
|
|
63
|
+
>
|
|
64
|
+
{title}
|
|
65
|
+
</span>
|
|
66
|
+
{/*
|
|
67
|
+
* gs Tile `contentAlign="center"`: the value sits in the CENTRE band of the
|
|
68
|
+
* three-band tile (title top / value centred / footer bottom). The `flex-1`
|
|
69
|
+
* band grows to fill the 208px tile and vertically centres the value, pushing
|
|
70
|
+
* any footer to the bottom (no `mt-auto` needed). Value ramp = gs label-1
|
|
71
|
+
* (1.875rem / 500 / 2rem line). Letter-spacing tracks gs 1:1: light tiles use
|
|
72
|
+
* -0.56px via `tracking-wide`; the dark "Cloud" count tile uses gs's em-based
|
|
73
|
+
* `-0.02em` (`AssetsTile.module.scss .count`).
|
|
74
|
+
*/}
|
|
75
|
+
<span
|
|
76
|
+
className={`flex flex-1 items-center text-[1.875rem] font-medium leading-[2rem] ${dark ? 'tracking-[-0.02em] text-white' : 'tracking-wide text-fg'}`}
|
|
77
|
+
>
|
|
78
|
+
{value}
|
|
79
|
+
</span>
|
|
80
|
+
{footer ? (
|
|
81
|
+
<span className={`pt-4 text-compact leading-4 ${dark ? 'text-white/50' : 'text-fg-muted'}`}>
|
|
82
|
+
{footer}
|
|
83
|
+
</span>
|
|
84
|
+
) : null}
|
|
85
|
+
</>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const PressableCard = uic(RACButton, {
|
|
90
|
+
displayName: 'StatsCardButton',
|
|
91
|
+
baseClass:
|
|
92
|
+
`${baseSurface} ${lightSkin} ` +
|
|
93
|
+
'w-full outline-none transition-transform duration-150 ' +
|
|
94
|
+
'data-[hovered]:scale-[1.02] ' +
|
|
95
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
|
|
96
|
+
'data-[pressed]:scale-100',
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Anchor interaction skin: same subtle scale-up as the pressable card (gs Tile
|
|
100
|
+
// `whileHover` scale 1.02, no shadow), but driven by CSS `:hover`/`:focus-visible`
|
|
101
|
+
// (real <a>) rather than RAC `data-` attributes.
|
|
102
|
+
const anchorInteractive =
|
|
103
|
+
'no-underline outline-none transition-transform duration-150 ' +
|
|
104
|
+
'hover:scale-[1.02] ' +
|
|
105
|
+
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
|
|
106
|
+
|
|
107
|
+
export function StatsCard({
|
|
108
|
+
title,
|
|
109
|
+
value,
|
|
110
|
+
footer,
|
|
111
|
+
onPress,
|
|
112
|
+
asChild,
|
|
113
|
+
children,
|
|
114
|
+
dark,
|
|
115
|
+
className,
|
|
116
|
+
...rest
|
|
117
|
+
}: StatsCardProps) {
|
|
118
|
+
// asChild wins: render the supplied element (typically a router <Link>) as the
|
|
119
|
+
// card. The body is injected as its children so callers pass a self-closing link.
|
|
120
|
+
if (asChild && isValidElement(children)) {
|
|
121
|
+
const child = children as ReactElement<{ className?: string }>
|
|
122
|
+
const cardClass = [baseSurface, dark ? darkSkin : lightSkin, anchorInteractive, child.props.className, className]
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.join(' ')
|
|
125
|
+
return cloneElement(child, { className: cardClass } as { className: string }, <StatsCardBody title={title} value={value} footer={footer} dark={dark} />)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (onPress) {
|
|
129
|
+
// uic merges via tailwind-merge, so `darkSkin` cleanly overrides the
|
|
130
|
+
// light surface/border in the base class.
|
|
131
|
+
return (
|
|
132
|
+
<PressableCard
|
|
133
|
+
onPress={onPress}
|
|
134
|
+
className={[dark ? darkSkin : '', className].filter(Boolean).join(' ')}
|
|
135
|
+
aria-label={rest['aria-label']}
|
|
136
|
+
>
|
|
137
|
+
<StatsCardBody title={title} value={value} footer={footer} dark={dark} />
|
|
138
|
+
</PressableCard>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
<div className={[baseSurface, dark ? darkSkin : lightSkin, className].filter(Boolean).join(' ')}>
|
|
143
|
+
<StatsCardBody title={title} value={value} footer={footer} dark={dark} />
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @app/ui — task approval modal (issue 13).
|
|
2
|
+
//
|
|
3
|
+
// PRESENTATIONAL approve-confirmation dialog built on the @app/ui <Dialog>
|
|
4
|
+
// (React Aria Modal — focus trap, Esc, aria-modal). The note is OPTIONAL on
|
|
5
|
+
// approve; confirm is always enabled (a press with an empty note is valid).
|
|
6
|
+
//
|
|
7
|
+
// This component is APP-AGNOSTIC (hard rule #1: @app/ui never imports apps/web
|
|
8
|
+
// or calls API hooks). Every string is supplied via the `labels` prop and the
|
|
9
|
+
// confirm action is delegated through `onConfirm(note?)` — the owning surface
|
|
10
|
+
// in apps/web wires the i18n strings + the REST mutation + invalidation.
|
|
11
|
+
//
|
|
12
|
+
// a11y: the textarea is label-associated via htmlFor/id; the server error is
|
|
13
|
+
// role="alert"; confirm shows a pending state and disables while in flight.
|
|
14
|
+
|
|
15
|
+
import { useId, useRef } from 'react'
|
|
16
|
+
import { Button } from './button'
|
|
17
|
+
import { Dialog } from './dialog'
|
|
18
|
+
|
|
19
|
+
/** Strings the approval modal renders — supplied by the app (i18n), not hardcoded. */
|
|
20
|
+
export interface TaskApprovalModalLabels {
|
|
21
|
+
/** Dialog heading. */
|
|
22
|
+
title: string
|
|
23
|
+
/** Explanatory body copy under the title. */
|
|
24
|
+
body: string
|
|
25
|
+
/** Label for the optional note textarea. */
|
|
26
|
+
noteLabel: string
|
|
27
|
+
/** Placeholder for the note textarea. */
|
|
28
|
+
notePlaceholder: string
|
|
29
|
+
/** Cancel button. */
|
|
30
|
+
cancel: string
|
|
31
|
+
/** Confirm (approve) button — idle state. */
|
|
32
|
+
confirm: string
|
|
33
|
+
/** Confirm button — pending state. */
|
|
34
|
+
submitting: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TaskApprovalModalProps {
|
|
38
|
+
/** Controlled open state. */
|
|
39
|
+
isOpen: boolean
|
|
40
|
+
/** Notified on open/close (false on Esc / click-outside / cancel). */
|
|
41
|
+
onOpenChange: (isOpen: boolean) => void
|
|
42
|
+
/** Run the approve command with the (trimmed, optional) note. */
|
|
43
|
+
onConfirm: (note?: string) => void
|
|
44
|
+
/** Whether the confirm mutation is in flight (disables + shows pending). */
|
|
45
|
+
isPending?: boolean
|
|
46
|
+
/** Server / mutation error message to surface inline (role="alert"). */
|
|
47
|
+
error?: string
|
|
48
|
+
/** App-supplied i18n strings. */
|
|
49
|
+
labels: TaskApprovalModalLabels
|
|
50
|
+
/** Optional test id forwarded to the dialog. */
|
|
51
|
+
'data-testid'?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Approve-confirmation modal with an optional note. */
|
|
55
|
+
export function TaskApprovalModal({
|
|
56
|
+
isOpen,
|
|
57
|
+
onOpenChange,
|
|
58
|
+
onConfirm,
|
|
59
|
+
isPending = false,
|
|
60
|
+
error,
|
|
61
|
+
labels,
|
|
62
|
+
'data-testid': testId,
|
|
63
|
+
}: TaskApprovalModalProps): React.ReactNode {
|
|
64
|
+
const noteId = useId()
|
|
65
|
+
// UNCONTROLLED textarea (read at submit via ref): the note has no dependent UI
|
|
66
|
+
// on approve, so controlled state would only add a re-render per keystroke.
|
|
67
|
+
const noteRef = useRef<HTMLTextAreaElement | null>(null)
|
|
68
|
+
|
|
69
|
+
const handleConfirm = (): void => {
|
|
70
|
+
const trimmed = (noteRef.current?.value ?? '').trim()
|
|
71
|
+
onConfirm(trimmed.length > 0 ? trimmed : undefined)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Dialog
|
|
76
|
+
title={labels.title}
|
|
77
|
+
data-testid={testId ?? 'task-approval-modal'}
|
|
78
|
+
isOpen={isOpen}
|
|
79
|
+
onOpenChange={onOpenChange}
|
|
80
|
+
>
|
|
81
|
+
<div className="flex flex-col gap-3">
|
|
82
|
+
<p className="text-sm text-fg-muted">{labels.body}</p>
|
|
83
|
+
<label htmlFor={noteId} className="text-sm font-medium text-fg">
|
|
84
|
+
{labels.noteLabel}
|
|
85
|
+
</label>
|
|
86
|
+
<textarea
|
|
87
|
+
id={noteId}
|
|
88
|
+
data-testid="task-approval-note"
|
|
89
|
+
ref={noteRef}
|
|
90
|
+
maxLength={10_000}
|
|
91
|
+
rows={3}
|
|
92
|
+
placeholder={labels.notePlaceholder}
|
|
93
|
+
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
94
|
+
/>
|
|
95
|
+
{error ? (
|
|
96
|
+
<p data-testid="task-approval-error" role="alert" className="text-sm text-danger">
|
|
97
|
+
{error}
|
|
98
|
+
</p>
|
|
99
|
+
) : null}
|
|
100
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
101
|
+
<Button
|
|
102
|
+
variant="secondary"
|
|
103
|
+
size="sm"
|
|
104
|
+
isDisabled={isPending}
|
|
105
|
+
onPress={() => onOpenChange(false)}
|
|
106
|
+
>
|
|
107
|
+
{labels.cancel}
|
|
108
|
+
</Button>
|
|
109
|
+
<Button
|
|
110
|
+
data-testid="task-approval-confirm"
|
|
111
|
+
variant="primary"
|
|
112
|
+
size="sm"
|
|
113
|
+
isDisabled={isPending}
|
|
114
|
+
isPending={isPending}
|
|
115
|
+
onPress={handleConfirm}
|
|
116
|
+
>
|
|
117
|
+
{isPending ? labels.submitting : labels.confirm}
|
|
118
|
+
</Button>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</Dialog>
|
|
122
|
+
)
|
|
123
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// @podoba/react — the universal component library.
|
|
2
2
|
//
|
|
3
3
|
// React Aria Components + Tailwind, composed with `uic`. Seeded from graphic-standard's
|
|
4
|
-
// @app/ui — atomic primitives + layout
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// @app/ui — atomic primitives + layout, PLUS the label-driven product patterns
|
|
5
|
+
// (delivery/approval modals, brand header, stats/dashboard). Podoba is the single
|
|
6
|
+
// source of truth for UI. Only GS-DOMAIN-coupled UI (the @app/schema-driven component
|
|
7
|
+
// renderer) stays in GS and consumes this. See ../../EXTRACTION.md.
|
|
7
8
|
|
|
8
9
|
// --- factory ---
|
|
9
10
|
export { uic, uiconfig, type ConfigVariants, type NoInfer } from "./utils/uic";
|
|
@@ -13,6 +14,8 @@ export * from "./components/button";
|
|
|
13
14
|
export * from "./components/input";
|
|
14
15
|
export * from "./components/textarea";
|
|
15
16
|
export * from "./components/rich-text-editor";
|
|
17
|
+
export * from "./components/focus-field";
|
|
18
|
+
export * from "./components/focus-context";
|
|
16
19
|
export * from "./components/checkbox";
|
|
17
20
|
export * from "./components/radio";
|
|
18
21
|
export * from "./components/switch";
|
|
@@ -45,3 +48,14 @@ export * from "./layout/page-container";
|
|
|
45
48
|
export * from "./layout/app-shell";
|
|
46
49
|
export * from "./layout/topbar";
|
|
47
50
|
export * from "./layout/persistent-page-shell";
|
|
51
|
+
|
|
52
|
+
// --- product patterns (label-driven; no domain coupling) ---
|
|
53
|
+
export * from "./components/brand-page-header";
|
|
54
|
+
export * from "./components/stats-card";
|
|
55
|
+
export * from "./components/dashboard-grid";
|
|
56
|
+
export * from "./components/task-approval-modal";
|
|
57
|
+
export * from "./components/request-changes-modal";
|
|
58
|
+
export * from "./components/delivery/download-modal";
|
|
59
|
+
export * from "./components/delivery/send-to-print-modal";
|
|
60
|
+
export * from "./components/delivery/publish-modal";
|
|
61
|
+
export * from "./components/delivery/delivery-status-module";
|