@podoba/react 0.0.7 → 0.0.9
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/collapsible-card.tsx +121 -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/stats-card.tsx +146 -0
- package/src/components/task-approval-modal.tsx +123 -0
- package/src/index.ts +18 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @app/ui — request-changes modal (issue 13).
|
|
2
|
+
//
|
|
3
|
+
// PRESENTATIONAL "request changes" dialog built on the @app/ui <Dialog>
|
|
4
|
+
// (React Aria Modal — focus trap, Esc, aria-modal). The note is REQUIRED:
|
|
5
|
+
// confirm stays DISABLED until the textarea holds a non-empty (trimmed) value,
|
|
6
|
+
// mirroring the server contract (`request_changes` 422s on an empty note).
|
|
7
|
+
//
|
|
8
|
+
// APP-AGNOSTIC (hard rule #1): every string is a prop and the action is
|
|
9
|
+
// delegated via `onConfirm(note)`. The owning surface in apps/web wires the
|
|
10
|
+
// i18n strings + the REST mutation + invalidation, and surfaces the auto-created
|
|
11
|
+
// follow-up `changes` task returned by the command.
|
|
12
|
+
//
|
|
13
|
+
// a11y: the textarea is label-associated (htmlFor/id), marked `required` +
|
|
14
|
+
// `aria-invalid` while empty; the server error is role="alert"; confirm shows a
|
|
15
|
+
// pending state and is disabled while in flight or while the note is empty.
|
|
16
|
+
|
|
17
|
+
import { useId, useState } from 'react'
|
|
18
|
+
import { Button } from './button'
|
|
19
|
+
import { Dialog } from './dialog'
|
|
20
|
+
|
|
21
|
+
/** Strings the request-changes modal renders — supplied by the app (i18n). */
|
|
22
|
+
export interface RequestChangesModalLabels {
|
|
23
|
+
/** Dialog heading. */
|
|
24
|
+
title: string
|
|
25
|
+
/** Explanatory body copy under the title. */
|
|
26
|
+
body: string
|
|
27
|
+
/** Label for the required note textarea. */
|
|
28
|
+
noteLabel: string
|
|
29
|
+
/** Placeholder for the note textarea. */
|
|
30
|
+
notePlaceholder: string
|
|
31
|
+
/** Cancel button. */
|
|
32
|
+
cancel: string
|
|
33
|
+
/** Confirm (request changes) button — idle state. */
|
|
34
|
+
confirm: string
|
|
35
|
+
/** Confirm button — pending state. */
|
|
36
|
+
submitting: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RequestChangesModalProps {
|
|
40
|
+
/** Controlled open state. */
|
|
41
|
+
isOpen: boolean
|
|
42
|
+
/** Notified on open/close (false on Esc / click-outside / cancel). */
|
|
43
|
+
onOpenChange: (isOpen: boolean) => void
|
|
44
|
+
/** Run the request_changes command with the (required, trimmed) note. */
|
|
45
|
+
onConfirm: (note: string) => void
|
|
46
|
+
/** Whether the confirm mutation is in flight (disables + shows pending). */
|
|
47
|
+
isPending?: boolean
|
|
48
|
+
/** Server / mutation error message to surface inline (role="alert"). */
|
|
49
|
+
error?: string
|
|
50
|
+
/** App-supplied i18n strings. */
|
|
51
|
+
labels: RequestChangesModalLabels
|
|
52
|
+
/** Optional test id forwarded to the dialog. */
|
|
53
|
+
'data-testid'?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Request-changes modal with a REQUIRED note (confirm disabled until non-empty). */
|
|
57
|
+
export function RequestChangesModal({
|
|
58
|
+
isOpen,
|
|
59
|
+
onOpenChange,
|
|
60
|
+
onConfirm,
|
|
61
|
+
isPending = false,
|
|
62
|
+
error,
|
|
63
|
+
labels,
|
|
64
|
+
'data-testid': testId,
|
|
65
|
+
}: RequestChangesModalProps): React.ReactNode {
|
|
66
|
+
const noteId = useId()
|
|
67
|
+
// CONTROLLED textarea: the confirm button's disabled state depends on whether
|
|
68
|
+
// the note is non-empty, so the value drives dependent UI (unlike approve).
|
|
69
|
+
const [note, setNote] = useState('')
|
|
70
|
+
const isEmpty = note.trim().length === 0
|
|
71
|
+
const canConfirm = !isEmpty && !isPending
|
|
72
|
+
|
|
73
|
+
const handleConfirm = (): void => {
|
|
74
|
+
const trimmed = note.trim()
|
|
75
|
+
if (trimmed.length === 0) return
|
|
76
|
+
onConfirm(trimmed)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Dialog
|
|
81
|
+
title={labels.title}
|
|
82
|
+
data-testid={testId ?? 'request-changes-modal'}
|
|
83
|
+
isOpen={isOpen}
|
|
84
|
+
onOpenChange={(open) => {
|
|
85
|
+
if (!open) setNote('')
|
|
86
|
+
onOpenChange(open)
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<div className="flex flex-col gap-3">
|
|
90
|
+
<p className="text-sm text-fg-muted">{labels.body}</p>
|
|
91
|
+
<label htmlFor={noteId} className="text-sm font-medium text-fg">
|
|
92
|
+
{labels.noteLabel}
|
|
93
|
+
</label>
|
|
94
|
+
<textarea
|
|
95
|
+
id={noteId}
|
|
96
|
+
data-testid="request-changes-note"
|
|
97
|
+
value={note}
|
|
98
|
+
onChange={(event) => setNote(event.target.value)}
|
|
99
|
+
maxLength={10_000}
|
|
100
|
+
rows={4}
|
|
101
|
+
required
|
|
102
|
+
aria-invalid={isEmpty ? true : undefined}
|
|
103
|
+
placeholder={labels.notePlaceholder}
|
|
104
|
+
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"
|
|
105
|
+
/>
|
|
106
|
+
{error ? (
|
|
107
|
+
<p data-testid="request-changes-error" role="alert" className="text-sm text-danger">
|
|
108
|
+
{error}
|
|
109
|
+
</p>
|
|
110
|
+
) : null}
|
|
111
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
112
|
+
<Button
|
|
113
|
+
variant="secondary"
|
|
114
|
+
size="sm"
|
|
115
|
+
isDisabled={isPending}
|
|
116
|
+
onPress={() => onOpenChange(false)}
|
|
117
|
+
>
|
|
118
|
+
{labels.cancel}
|
|
119
|
+
</Button>
|
|
120
|
+
<Button
|
|
121
|
+
data-testid="request-changes-confirm"
|
|
122
|
+
variant="primary"
|
|
123
|
+
size="sm"
|
|
124
|
+
isDisabled={!canConfirm}
|
|
125
|
+
isPending={isPending}
|
|
126
|
+
onPress={handleConfirm}
|
|
127
|
+
>
|
|
128
|
+
{isPending ? labels.submitting : labels.confirm}
|
|
129
|
+
</Button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</Dialog>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ClipboardEvent as ReactClipboardEvent, type ReactNode, useEffect, useRef } from 'react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
|
+
import { useInFocusOverlay } from './focus-context'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* RichTextEditor — a dependency-free contentEditable WYSIWYG that emits an HTML
|
|
@@ -85,6 +86,9 @@ export function RichTextEditor({
|
|
|
85
86
|
}: RichTextEditorProps) {
|
|
86
87
|
const ref = useRef<HTMLDivElement>(null)
|
|
87
88
|
const last = useRef<string>('')
|
|
89
|
+
// In a focus overlay, give the body more room to write in.
|
|
90
|
+
const inFocus = useInFocusOverlay()
|
|
91
|
+
const bodyMinHeight = inFocus ? Math.max(minHeight, 360) : minHeight
|
|
88
92
|
|
|
89
93
|
// Sync only EXTERNAL value changes into the DOM — never on our own keystrokes,
|
|
90
94
|
// or the caret would jump to the start on every character.
|
|
@@ -142,7 +146,7 @@ export function RichTextEditor({
|
|
|
142
146
|
<div
|
|
143
147
|
ref={ref}
|
|
144
148
|
className="prose prose-sm max-w-none px-4 py-3.5 text-sm leading-relaxed text-fg outline-none empty:before:text-fg-subtle empty:before:content-[attr(data-placeholder)]"
|
|
145
|
-
style={{ minHeight }}
|
|
149
|
+
style={{ minHeight: bodyMinHeight }}
|
|
146
150
|
contentEditable
|
|
147
151
|
suppressContentEditableWarning
|
|
148
152
|
data-placeholder={placeholder}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
Text,
|
|
14
14
|
} from 'react-aria-components'
|
|
15
15
|
import { uic } from '../utils/uic'
|
|
16
|
+
import { useInFocusOverlay } from './focus-context'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Select — accessible dropdown built on React Aria Components `Select`.
|
|
@@ -74,41 +75,55 @@ export const Select = <T extends object>({
|
|
|
74
75
|
placeholder,
|
|
75
76
|
children,
|
|
76
77
|
...props
|
|
77
|
-
}: SelectProps<T>) =>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
78
|
+
}: SelectProps<T>) => {
|
|
79
|
+
// In a focus overlay, show the options inline (seamless) instead of a popover.
|
|
80
|
+
const inFocus = useInFocusOverlay()
|
|
81
|
+
const listbox = (
|
|
82
|
+
<ListBox className="flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none">
|
|
83
|
+
{children}
|
|
84
|
+
</ListBox>
|
|
85
|
+
)
|
|
86
|
+
const desc = description ? (
|
|
87
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
88
|
+
{description}
|
|
89
|
+
</Text>
|
|
90
|
+
) : null
|
|
91
|
+
const err = <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<RACSelect {...props} placeholder={placeholder} className="group flex flex-col gap-2">
|
|
95
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
96
|
+
{inFocus ? (
|
|
97
|
+
<>
|
|
98
|
+
{listbox}
|
|
99
|
+
{desc}
|
|
100
|
+
{err}
|
|
101
|
+
</>
|
|
102
|
+
) : (
|
|
103
|
+
<>
|
|
104
|
+
<SelectTrigger>
|
|
105
|
+
<SelectValue className="data-[placeholder]:text-fg-muted" />
|
|
106
|
+
{/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
|
|
107
|
+
<svg
|
|
108
|
+
width="9.5"
|
|
109
|
+
height="9.5"
|
|
110
|
+
viewBox="0 0 12 12"
|
|
111
|
+
fill="none"
|
|
112
|
+
aria-hidden="true"
|
|
113
|
+
className="pointer-events-none shrink-0 text-fg"
|
|
114
|
+
>
|
|
115
|
+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
116
|
+
</svg>
|
|
117
|
+
</SelectTrigger>
|
|
118
|
+
{desc}
|
|
119
|
+
{err}
|
|
120
|
+
{/* Cream fill, 8px radius, shadow-lg, NO border. 4px inset so each option's
|
|
121
|
+
highlight sits as a padded pill; small gap for an even list rhythm. */}
|
|
122
|
+
<Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
|
|
123
|
+
{listbox}
|
|
124
|
+
</Popover>
|
|
125
|
+
</>
|
|
126
|
+
)}
|
|
127
|
+
</RACSelect>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -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";
|
|
@@ -32,6 +35,7 @@ export * from "./components/context-menu";
|
|
|
32
35
|
export * from "./components/tooltip";
|
|
33
36
|
export * from "./components/toast";
|
|
34
37
|
export * from "./components/disclosure";
|
|
38
|
+
export * from "./components/collapsible-card";
|
|
35
39
|
export * from "./components/tabs";
|
|
36
40
|
export * from "./components/section-tabs";
|
|
37
41
|
export * from "./components/separator";
|
|
@@ -45,3 +49,14 @@ export * from "./layout/page-container";
|
|
|
45
49
|
export * from "./layout/app-shell";
|
|
46
50
|
export * from "./layout/topbar";
|
|
47
51
|
export * from "./layout/persistent-page-shell";
|
|
52
|
+
|
|
53
|
+
// --- product patterns (label-driven; no domain coupling) ---
|
|
54
|
+
export * from "./components/brand-page-header";
|
|
55
|
+
export * from "./components/stats-card";
|
|
56
|
+
export * from "./components/dashboard-grid";
|
|
57
|
+
export * from "./components/task-approval-modal";
|
|
58
|
+
export * from "./components/request-changes-modal";
|
|
59
|
+
export * from "./components/delivery/download-modal";
|
|
60
|
+
export * from "./components/delivery/send-to-print-modal";
|
|
61
|
+
export * from "./components/delivery/publish-modal";
|
|
62
|
+
export * from "./components/delivery/delivery-status-module";
|