@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.
@@ -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 ONLY. Product-specific components (schema
5
- // renderer, delivery/approval modals, brand headers) stay in GS and consume this.
6
- // See ../../EXTRACTION.md.
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";