@podoba/react 0.0.7 → 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/stats-card.tsx +146 -0
- package/src/components/task-approval-modal.tsx +123 -0
- package/src/index.ts +17 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import { createPortal } from 'react-dom'
|
|
13
|
+
import { clsx } from 'clsx'
|
|
14
|
+
import { FocusOverlayContext } from './focus-context'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* FocusFields + FocusField — immersive focus-mode field editing.
|
|
18
|
+
*
|
|
19
|
+
* Ported from gs-platform's FormWithPreview overlay pattern. Wrap a form in
|
|
20
|
+
* `<FocusFields>`; each `<FocusField>` renders as a compact card (icon · label ·
|
|
21
|
+
* value preview). Clicking one enters focus mode: the rest of the form blurs and
|
|
22
|
+
* dims, and the field's editor (`children`) takes over the form area at a large
|
|
23
|
+
* size. Esc or a click on the backdrop closes it.
|
|
24
|
+
*
|
|
25
|
+
* Generic by design — `children` is any editor (an input, textarea, select…). The
|
|
26
|
+
* card owns the label, so pass a *label-less* control as children. Text controls
|
|
27
|
+
* are auto-enlarged and stripped to a bare headline look in the overlay.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
type FocusCtx = {
|
|
31
|
+
activeId: string | null
|
|
32
|
+
activate: (id: string) => void
|
|
33
|
+
close: () => void
|
|
34
|
+
host: HTMLElement | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const FocusFieldsContext = createContext<FocusCtx | null>(null)
|
|
38
|
+
|
|
39
|
+
function useFocusFields(): FocusCtx {
|
|
40
|
+
const ctx = useContext(FocusFieldsContext)
|
|
41
|
+
if (!ctx) throw new Error('<FocusField> must be rendered inside <FocusFields>')
|
|
42
|
+
return ctx
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function FocusFields({ children, className }: { children: ReactNode; className?: string }) {
|
|
46
|
+
const [activeId, setActiveId] = useState<string | null>(null)
|
|
47
|
+
const [host, setHost] = useState<HTMLDivElement | null>(null)
|
|
48
|
+
const close = useCallback(() => setActiveId(null), [])
|
|
49
|
+
const activate = useCallback((id: string) => setActiveId(id), [])
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!activeId) return
|
|
53
|
+
const onKey = (e: KeyboardEvent) => {
|
|
54
|
+
if (e.key === 'Escape') close()
|
|
55
|
+
}
|
|
56
|
+
document.addEventListener('keydown', onKey)
|
|
57
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
58
|
+
}, [activeId, close])
|
|
59
|
+
|
|
60
|
+
const ctx = useMemo<FocusCtx>(() => ({ activeId, activate, close, host }), [activeId, activate, close, host])
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<FocusFieldsContext.Provider value={ctx}>
|
|
64
|
+
<div className={clsx('relative', className)}>
|
|
65
|
+
<div
|
|
66
|
+
className={clsx(
|
|
67
|
+
'transition-[filter,opacity] duration-200',
|
|
68
|
+
activeId && 'pointer-events-none select-none opacity-40 blur-[3px]',
|
|
69
|
+
)}
|
|
70
|
+
aria-hidden={activeId ? true : undefined}
|
|
71
|
+
>
|
|
72
|
+
{children}
|
|
73
|
+
</div>
|
|
74
|
+
{activeId ? (
|
|
75
|
+
// Backdrop over the form; the portalled editor sits at the top and
|
|
76
|
+
// stops propagation so only clicks outside it close focus mode.
|
|
77
|
+
<div className="absolute inset-0 z-20 overflow-auto" onClick={close}>
|
|
78
|
+
<div ref={setHost} onClick={(e) => e.stopPropagation()} />
|
|
79
|
+
</div>
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
</FocusFieldsContext.Provider>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// In the overlay, enlarge and de-chrome text controls into a bare headline editor.
|
|
87
|
+
const OVERLAY_EDITOR =
|
|
88
|
+
'[&_input]:w-full [&_input]:border-0 [&_input]:bg-transparent [&_input]:p-0 [&_input]:text-3xl [&_input]:font-medium [&_input]:text-fg [&_input]:outline-none [&_input]:shadow-none [&_input]:ring-0 [&_input]:placeholder:text-fg-subtle ' +
|
|
89
|
+
'[&_textarea]:min-h-40 [&_textarea]:w-full [&_textarea]:resize-none [&_textarea]:border-0 [&_textarea]:bg-transparent [&_textarea]:p-0 [&_textarea]:text-3xl [&_textarea]:font-medium [&_textarea]:text-fg [&_textarea]:outline-none [&_textarea]:placeholder:text-fg-subtle'
|
|
90
|
+
|
|
91
|
+
function IconBadge({ icon }: { icon: ReactNode }) {
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-surface-muted text-sm font-medium text-fg">
|
|
94
|
+
{icon}
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type FocusFieldProps = {
|
|
100
|
+
/** Field label (shown on the card and above the overlay editor). */
|
|
101
|
+
label: ReactNode
|
|
102
|
+
/** Optional leading icon (e.g. a "T" for text). */
|
|
103
|
+
icon?: ReactNode
|
|
104
|
+
/** Current value shown on the collapsed card. */
|
|
105
|
+
preview?: ReactNode
|
|
106
|
+
/** Shown on the card when there's no value. */
|
|
107
|
+
placeholder?: ReactNode
|
|
108
|
+
/** The editor, shown in the overlay when focused. */
|
|
109
|
+
children: ReactNode
|
|
110
|
+
/**
|
|
111
|
+
* How the editor is presented in the overlay:
|
|
112
|
+
* - `"text"` (default): treat `children` as a bare text control and enlarge it
|
|
113
|
+
* to a headline; FocusField supplies the label.
|
|
114
|
+
* - `"control"`: render `children` (a self-labelled field component) as-is, with
|
|
115
|
+
* no enlargement and no FocusField label (the control provides its own).
|
|
116
|
+
*/
|
|
117
|
+
editor?: 'text' | 'control'
|
|
118
|
+
className?: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function FocusField({
|
|
122
|
+
label,
|
|
123
|
+
icon,
|
|
124
|
+
preview,
|
|
125
|
+
placeholder,
|
|
126
|
+
children,
|
|
127
|
+
editor = 'text',
|
|
128
|
+
className,
|
|
129
|
+
}: FocusFieldProps) {
|
|
130
|
+
const ctx = useFocusFields()
|
|
131
|
+
const id = useId()
|
|
132
|
+
const active = ctx.activeId === id
|
|
133
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
134
|
+
|
|
135
|
+
// Move focus into the editor once it's mounted (the portal host appears a
|
|
136
|
+
// render after activation, so wait for ctx.host before focusing).
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!active || !ctx.host) return
|
|
139
|
+
const el = contentRef.current?.querySelector<HTMLElement>('input, textarea, [contenteditable="true"]')
|
|
140
|
+
el?.focus()
|
|
141
|
+
}, [active, ctx.host])
|
|
142
|
+
|
|
143
|
+
const hasValue = preview !== undefined && preview !== null && preview !== ''
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<>
|
|
147
|
+
<div
|
|
148
|
+
role="button"
|
|
149
|
+
tabIndex={0}
|
|
150
|
+
aria-label={typeof label === 'string' ? label : undefined}
|
|
151
|
+
onClick={() => ctx.activate(id)}
|
|
152
|
+
onKeyDown={(e) => {
|
|
153
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
154
|
+
e.preventDefault()
|
|
155
|
+
ctx.activate(id)
|
|
156
|
+
}
|
|
157
|
+
}}
|
|
158
|
+
className={clsx(
|
|
159
|
+
// Field-group divider: a bottom border on each row, none on the last.
|
|
160
|
+
'flex w-full cursor-text items-start gap-3 border-b border-border p-3 text-left outline-none transition-colors last:border-b-0',
|
|
161
|
+
'hover:bg-surface-card focus-visible:ring-2 focus-visible:ring-ring',
|
|
162
|
+
className,
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
{icon ? <IconBadge icon={icon} /> : null}
|
|
166
|
+
<div className="min-w-0 flex-1">
|
|
167
|
+
<div className="text-sm text-fg-muted">{label}</div>
|
|
168
|
+
<div className="mt-1 truncate text-base text-fg">
|
|
169
|
+
{hasValue ? preview : <span className="text-fg-subtle">{placeholder}</span>}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
{active && ctx.host
|
|
174
|
+
? createPortal(
|
|
175
|
+
// No card/shadow: a full-bleed white area at the top that gradient-fades
|
|
176
|
+
// to transparent, so it melts into the blurred form below.
|
|
177
|
+
<div
|
|
178
|
+
ref={contentRef}
|
|
179
|
+
className="relative min-h-[60vh] w-full bg-gradient-to-b from-surface from-0% via-surface via-65% to-transparent px-3 pt-3"
|
|
180
|
+
>
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={ctx.close}
|
|
184
|
+
aria-label="Close"
|
|
185
|
+
className="absolute right-3 top-3 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none transition-colors hover:bg-surface-muted hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
|
|
186
|
+
>
|
|
187
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
|
188
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
189
|
+
</svg>
|
|
190
|
+
</button>
|
|
191
|
+
{/* Tell field components they're in a focus overlay so they can
|
|
192
|
+
render an enhanced view (e.g. an inline open calendar). */}
|
|
193
|
+
<FocusOverlayContext.Provider value={true}>
|
|
194
|
+
{editor === 'control' ? (
|
|
195
|
+
// Self-labelled field component — render it as-is (enhanced by context).
|
|
196
|
+
<div className="max-w-md pr-10">{children}</div>
|
|
197
|
+
) : (
|
|
198
|
+
<div className="flex items-start gap-3">
|
|
199
|
+
{icon ? <IconBadge icon={icon} /> : null}
|
|
200
|
+
<div className="min-w-0 flex-1 pr-10">
|
|
201
|
+
<div className="text-sm text-fg-muted">{label}</div>
|
|
202
|
+
<div className={OVERLAY_EDITOR}>{children}</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
</FocusOverlayContext.Provider>
|
|
207
|
+
</div>,
|
|
208
|
+
ctx.host,
|
|
209
|
+
)
|
|
210
|
+
: null}
|
|
211
|
+
</>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'react-aria-components'
|
|
14
14
|
import { clsx } from 'clsx'
|
|
15
15
|
import { uic } from '../utils/uic'
|
|
16
|
+
import { useInFocusOverlay } from './focus-context'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* MultiSelect — a dropdown that selects several options at once.
|
|
@@ -96,6 +97,7 @@ export const MultiSelect = ({
|
|
|
96
97
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
97
98
|
const [open, setOpen] = useState(false)
|
|
98
99
|
const { contains } = useFilter({ sensitivity: 'base' })
|
|
100
|
+
const inFocus = useInFocusOverlay()
|
|
99
101
|
const [internal, setInternal] = useState<Set<string>>(() => new Set(defaultSelectedKeys ?? []))
|
|
100
102
|
const selected = selectedKeys ?? internal
|
|
101
103
|
|
|
@@ -126,48 +128,64 @@ export const MultiSelect = ({
|
|
|
126
128
|
</ListBox>
|
|
127
129
|
)
|
|
128
130
|
|
|
131
|
+
const listContent = searchable ? (
|
|
132
|
+
<Autocomplete filter={contains}>
|
|
133
|
+
<SearchField aria-label="Filter options" autoFocus className="border-b border-border p-1">
|
|
134
|
+
<RACInput
|
|
135
|
+
placeholder="Search…"
|
|
136
|
+
className="w-full rounded-md bg-surface px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted"
|
|
137
|
+
/>
|
|
138
|
+
</SearchField>
|
|
139
|
+
{list}
|
|
140
|
+
</Autocomplete>
|
|
141
|
+
) : (
|
|
142
|
+
list
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const desc = description ? <span className="text-xs text-fg-muted">{description}</span> : null
|
|
146
|
+
const err = isInvalid && errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null
|
|
147
|
+
|
|
129
148
|
return (
|
|
130
149
|
<div className={clsx('flex flex-col gap-2', className)}>
|
|
131
150
|
<span id={labelId} className="text-heading5 font-medium text-fg">
|
|
132
151
|
{label}
|
|
133
152
|
</span>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
</Popover>
|
|
153
|
+
{inFocus ? (
|
|
154
|
+
// Seamless: no trigger/popover — the checklist sits inline on the panel.
|
|
155
|
+
<>
|
|
156
|
+
{listContent}
|
|
157
|
+
{desc}
|
|
158
|
+
{err}
|
|
159
|
+
</>
|
|
160
|
+
) : (
|
|
161
|
+
<>
|
|
162
|
+
<RACButton
|
|
163
|
+
ref={triggerRef}
|
|
164
|
+
aria-labelledby={labelId}
|
|
165
|
+
isDisabled={isDisabled}
|
|
166
|
+
onPress={() => setOpen(true)}
|
|
167
|
+
className={clsx(
|
|
168
|
+
'flex h-12 w-full items-center justify-between gap-2.5 rounded-lg border bg-surface px-4 text-sm text-fg outline-none transition-colors',
|
|
169
|
+
'data-[hovered]:border-fg-subtle data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
|
|
170
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
|
|
171
|
+
isInvalid ? 'border-danger ring-2 ring-danger' : 'border-border',
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
<span className={clsx('truncate', chosen.length === 0 && 'text-fg-muted')}>{summary}</span>
|
|
175
|
+
<Chevron />
|
|
176
|
+
</RACButton>
|
|
177
|
+
{desc}
|
|
178
|
+
{err}
|
|
179
|
+
<Popover
|
|
180
|
+
triggerRef={triggerRef}
|
|
181
|
+
isOpen={open}
|
|
182
|
+
onOpenChange={setOpen}
|
|
183
|
+
className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg"
|
|
184
|
+
>
|
|
185
|
+
{listContent}
|
|
186
|
+
</Popover>
|
|
187
|
+
</>
|
|
188
|
+
)}
|
|
171
189
|
</div>
|
|
172
190
|
)
|
|
173
191
|
}
|
|
@@ -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
|
+
}
|