@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,216 @@
|
|
|
1
|
+
// @app/ui — send-to-print modal (issue 14).
|
|
2
|
+
//
|
|
3
|
+
// PRESENTATIONAL print-order dialog built on the @app/ui <Dialog> (React Aria
|
|
4
|
+
// Modal — focus trap, Esc, aria-modal). Collects the print spec (size,
|
|
5
|
+
// material, quantity, delivery address) and, on confirm, the owning surface in
|
|
6
|
+
// apps/web creates a `print` delivery job.
|
|
7
|
+
//
|
|
8
|
+
// VALIDATION mirrors the smart-document `print_specs` + `delivery_output`
|
|
9
|
+
// block rules (docs/GOVERNANCE/tasks-delivery.md / the issue-06 validator):
|
|
10
|
+
// * size — non-blank (trimmed),
|
|
11
|
+
// * material — non-blank (trimmed),
|
|
12
|
+
// * quantity — a finite integer strictly > 0,
|
|
13
|
+
// * address — non-blank (trimmed, the delivery_output address field).
|
|
14
|
+
// Confirm stays DISABLED until every field is valid; per-field errors are shown
|
|
15
|
+
// inline (the quantity input carries aria-invalid + a role="alert" message).
|
|
16
|
+
//
|
|
17
|
+
// APP-AGNOSTIC (hard rule #1): every string is a prop; the action is delegated
|
|
18
|
+
// via `onConfirm(spec)`; apps/web wires i18n + the REST mutation + invalidation.
|
|
19
|
+
//
|
|
20
|
+
// a11y: every field is label-associated (Input / RAC TextField); the quantity
|
|
21
|
+
// field is aria-invalid when invalid; the server error is role="alert"; confirm
|
|
22
|
+
// shows a pending state and disables while in flight / invalid.
|
|
23
|
+
|
|
24
|
+
import { useMemo, useState } from 'react'
|
|
25
|
+
import { Button } from '../button'
|
|
26
|
+
import { Dialog } from '../dialog'
|
|
27
|
+
import { Input } from '../input'
|
|
28
|
+
|
|
29
|
+
/** The validated print spec the modal yields on confirm. */
|
|
30
|
+
export interface PrintSpec {
|
|
31
|
+
/** Print size (e.g. "A2", "1000×700mm") — non-blank. */
|
|
32
|
+
size: string
|
|
33
|
+
/** Material / stock (e.g. "Matte 250g") — non-blank. */
|
|
34
|
+
material: string
|
|
35
|
+
/** Quantity — a finite integer > 0. */
|
|
36
|
+
quantity: number
|
|
37
|
+
/** Delivery address — non-blank. */
|
|
38
|
+
address: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Strings the send-to-print modal renders — supplied by the app (i18n). */
|
|
42
|
+
export interface SendToPrintModalLabels {
|
|
43
|
+
/** Dialog heading. */
|
|
44
|
+
title: string
|
|
45
|
+
/** Explanatory body copy under the title. */
|
|
46
|
+
body: string
|
|
47
|
+
/** Size field label + placeholder. */
|
|
48
|
+
sizeLabel: string
|
|
49
|
+
sizePlaceholder: string
|
|
50
|
+
/** Material field label + placeholder. */
|
|
51
|
+
materialLabel: string
|
|
52
|
+
materialPlaceholder: string
|
|
53
|
+
/** Quantity field label + placeholder. */
|
|
54
|
+
quantityLabel: string
|
|
55
|
+
quantityPlaceholder: string
|
|
56
|
+
/** Inline error shown when the quantity is not a finite integer > 0. */
|
|
57
|
+
quantityError: string
|
|
58
|
+
/** Address field label + placeholder. */
|
|
59
|
+
addressLabel: string
|
|
60
|
+
addressPlaceholder: string
|
|
61
|
+
/** Cancel button. */
|
|
62
|
+
cancel: string
|
|
63
|
+
/** Confirm (create print job) button — idle state. */
|
|
64
|
+
confirm: string
|
|
65
|
+
/** Confirm button — pending state. */
|
|
66
|
+
submitting: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SendToPrintModalProps {
|
|
70
|
+
/** Controlled open state. */
|
|
71
|
+
isOpen: boolean
|
|
72
|
+
/** Notified on open/close (false on Esc / click-outside / cancel). */
|
|
73
|
+
onOpenChange: (isOpen: boolean) => void
|
|
74
|
+
/** Create the `print` delivery job with the validated spec. */
|
|
75
|
+
onConfirm: (spec: PrintSpec) => void
|
|
76
|
+
/** Whether the confirm mutation is in flight (disables + shows pending). */
|
|
77
|
+
isPending?: boolean
|
|
78
|
+
/** Server / mutation error message to surface inline (role="alert"). */
|
|
79
|
+
error?: string
|
|
80
|
+
/** App-supplied i18n strings. */
|
|
81
|
+
labels: SendToPrintModalLabels
|
|
82
|
+
/** Optional test id forwarded to the dialog. */
|
|
83
|
+
'data-testid'?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse a raw quantity string into a finite integer > 0, or null when invalid. */
|
|
87
|
+
function parseQuantity(raw: string): number | null {
|
|
88
|
+
const trimmed = raw.trim()
|
|
89
|
+
if (trimmed.length === 0) return null
|
|
90
|
+
const value = Number(trimmed)
|
|
91
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) return null
|
|
92
|
+
return value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Send-to-print modal with validated size / material / quantity / address. */
|
|
96
|
+
export function SendToPrintModal({
|
|
97
|
+
isOpen,
|
|
98
|
+
onOpenChange,
|
|
99
|
+
onConfirm,
|
|
100
|
+
isPending = false,
|
|
101
|
+
error,
|
|
102
|
+
labels,
|
|
103
|
+
'data-testid': testId,
|
|
104
|
+
}: SendToPrintModalProps): React.ReactNode {
|
|
105
|
+
const [size, setSize] = useState('')
|
|
106
|
+
const [material, setMaterial] = useState('')
|
|
107
|
+
const [quantityRaw, setQuantityRaw] = useState('')
|
|
108
|
+
const [address, setAddress] = useState('')
|
|
109
|
+
|
|
110
|
+
const quantity = useMemo(() => parseQuantity(quantityRaw), [quantityRaw])
|
|
111
|
+
// Surface the quantity error only once the user has typed something invalid
|
|
112
|
+
// (not on the initial blank state — that would scream before any input).
|
|
113
|
+
const quantityInvalid = quantityRaw.trim().length > 0 && quantity === null
|
|
114
|
+
|
|
115
|
+
const reset = (): void => {
|
|
116
|
+
setSize('')
|
|
117
|
+
setMaterial('')
|
|
118
|
+
setQuantityRaw('')
|
|
119
|
+
setAddress('')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const canConfirm =
|
|
123
|
+
!isPending &&
|
|
124
|
+
size.trim().length > 0 &&
|
|
125
|
+
material.trim().length > 0 &&
|
|
126
|
+
quantity !== null &&
|
|
127
|
+
address.trim().length > 0
|
|
128
|
+
|
|
129
|
+
const handleOpenChange = (open: boolean): void => {
|
|
130
|
+
if (!open) reset()
|
|
131
|
+
onOpenChange(open)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const handleConfirm = (): void => {
|
|
135
|
+
if (!canConfirm || quantity === null) return
|
|
136
|
+
onConfirm({
|
|
137
|
+
size: size.trim(),
|
|
138
|
+
material: material.trim(),
|
|
139
|
+
quantity,
|
|
140
|
+
address: address.trim(),
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<Dialog
|
|
146
|
+
title={labels.title}
|
|
147
|
+
data-testid={testId ?? 'delivery-print-modal'}
|
|
148
|
+
isOpen={isOpen}
|
|
149
|
+
onOpenChange={handleOpenChange}
|
|
150
|
+
>
|
|
151
|
+
<div className="flex flex-col gap-3">
|
|
152
|
+
<p className="text-sm text-fg-muted">{labels.body}</p>
|
|
153
|
+
|
|
154
|
+
<Input
|
|
155
|
+
label={labels.sizeLabel}
|
|
156
|
+
placeholder={labels.sizePlaceholder}
|
|
157
|
+
value={size}
|
|
158
|
+
onChange={setSize}
|
|
159
|
+
data-testid="delivery-print-size"
|
|
160
|
+
/>
|
|
161
|
+
<Input
|
|
162
|
+
label={labels.materialLabel}
|
|
163
|
+
placeholder={labels.materialPlaceholder}
|
|
164
|
+
value={material}
|
|
165
|
+
onChange={setMaterial}
|
|
166
|
+
data-testid="delivery-print-material"
|
|
167
|
+
/>
|
|
168
|
+
<Input
|
|
169
|
+
label={labels.quantityLabel}
|
|
170
|
+
placeholder={labels.quantityPlaceholder}
|
|
171
|
+
value={quantityRaw}
|
|
172
|
+
onChange={setQuantityRaw}
|
|
173
|
+
type="number"
|
|
174
|
+
inputMode="numeric"
|
|
175
|
+
isInvalid={quantityInvalid}
|
|
176
|
+
errorMessage={quantityInvalid ? labels.quantityError : undefined}
|
|
177
|
+
data-testid="delivery-print-quantity"
|
|
178
|
+
/>
|
|
179
|
+
<Input
|
|
180
|
+
label={labels.addressLabel}
|
|
181
|
+
placeholder={labels.addressPlaceholder}
|
|
182
|
+
value={address}
|
|
183
|
+
onChange={setAddress}
|
|
184
|
+
data-testid="delivery-print-address"
|
|
185
|
+
/>
|
|
186
|
+
|
|
187
|
+
{error ? (
|
|
188
|
+
<p data-testid="delivery-print-error" role="alert" className="text-sm text-danger">
|
|
189
|
+
{error}
|
|
190
|
+
</p>
|
|
191
|
+
) : null}
|
|
192
|
+
|
|
193
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
194
|
+
<Button
|
|
195
|
+
variant="secondary"
|
|
196
|
+
size="sm"
|
|
197
|
+
isDisabled={isPending}
|
|
198
|
+
onPress={() => handleOpenChange(false)}
|
|
199
|
+
>
|
|
200
|
+
{labels.cancel}
|
|
201
|
+
</Button>
|
|
202
|
+
<Button
|
|
203
|
+
data-testid="delivery-print-confirm"
|
|
204
|
+
variant="primary"
|
|
205
|
+
size="sm"
|
|
206
|
+
isDisabled={!canConfirm}
|
|
207
|
+
isPending={isPending}
|
|
208
|
+
onPress={handleConfirm}
|
|
209
|
+
>
|
|
210
|
+
{isPending ? labels.submitting : labels.confirm}
|
|
211
|
+
</Button>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</Dialog>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode, useState } from 'react'
|
|
2
2
|
import { DropZone, FileTrigger, Text } from 'react-aria-components'
|
|
3
3
|
import { Button } from './button'
|
|
4
|
+
import { useInFocusOverlay } from './focus-context'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* FileUpload — a drop zone + "choose file" trigger over React Aria Components
|
|
@@ -21,6 +22,7 @@ export type FileUploadProps = {
|
|
|
21
22
|
|
|
22
23
|
export const FileUpload = ({ label, description, accept, allowsMultiple, onFiles, className }: FileUploadProps) => {
|
|
23
24
|
const [names, setNames] = useState<string[]>([])
|
|
25
|
+
const inFocus = useInFocusOverlay()
|
|
24
26
|
|
|
25
27
|
const handle = (files: File[]) => {
|
|
26
28
|
if (files.length === 0) return
|
|
@@ -38,7 +40,8 @@ export const FileUpload = ({ label, description, accept, allowsMultiple, onFiles
|
|
|
38
40
|
handle(allowsMultiple ? files : files.slice(0, 1))
|
|
39
41
|
}}
|
|
40
42
|
className={
|
|
41
|
-
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-surface
|
|
43
|
+
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-surface text-center outline-none transition-colors ' +
|
|
44
|
+
(inFocus ? 'min-h-64 p-12 ' : 'p-6 ') +
|
|
42
45
|
'data-[hovered]:border-fg-subtle ' +
|
|
43
46
|
'data-[drop-target]:border-brand-green data-[drop-target]:bg-surface-card ' +
|
|
44
47
|
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Signals that a field is being rendered inside an active FocusField overlay, so
|
|
5
|
+
* the field can present an enhanced "focus" view (e.g. DatePicker shows the
|
|
6
|
+
* calendar inline/open instead of behind a popover). Default `false`.
|
|
7
|
+
*/
|
|
8
|
+
export const FocusOverlayContext = createContext(false)
|
|
9
|
+
|
|
10
|
+
/** True when a field is rendered inside an active FocusField overlay. */
|
|
11
|
+
export const useInFocusOverlay = (): boolean => useContext(FocusOverlayContext)
|
|
@@ -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
|
}
|