@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.
@@ -0,0 +1,204 @@
1
+ // @app/ui — delivery download modal (issue 14).
2
+ //
3
+ // PRESENTATIONAL download-confirmation dialog built on the @app/ui <Dialog>
4
+ // (React Aria Modal — focus trap, Esc, aria-modal). The user picks a format
5
+ // (PNG / PDF), confirms, and a `download` delivery job is created. While the
6
+ // job's artifact is generating the modal shows a progress note; once the
7
+ // artifact is ready the modal surfaces a download link to the artifact URL.
8
+ //
9
+ // APP-AGNOSTIC (hard rule #1: @app/ui never imports apps/web or calls API
10
+ // hooks). Every string comes through `labels`; the action is delegated via
11
+ // `onConfirm(format)`; the job status / artifact URL are passed in by the
12
+ // owning surface in apps/web, which wires the i18n strings + the REST mutation
13
+ // (`useDeliveryJobMutations().create`) + invalidation.
14
+ //
15
+ // a11y: the format radiogroup is label-associated; the progress note is
16
+ // role="status"; the server error is role="alert"; confirm shows a pending
17
+ // state and disables while in flight.
18
+
19
+ import { useState } from 'react'
20
+ import {
21
+ Label,
22
+ Radio,
23
+ RadioGroup,
24
+ type Key,
25
+ } from 'react-aria-components'
26
+ import { Button } from '../button'
27
+ import { Dialog } from '../dialog'
28
+
29
+ /** The two download formats a `download` job can produce. */
30
+ export const DOWNLOAD_FORMATS = ['png', 'pdf'] as const
31
+ export type DownloadFormat = (typeof DOWNLOAD_FORMATS)[number]
32
+
33
+ /** Strings the download modal renders — supplied by the app (i18n), not hardcoded. */
34
+ export interface DownloadModalLabels {
35
+ /** Dialog heading. */
36
+ title: string
37
+ /** Explanatory body copy under the title. */
38
+ body: string
39
+ /** Label for the format radiogroup. */
40
+ formatLabel: string
41
+ /** Label for the PNG option. */
42
+ formatPng: string
43
+ /** Label for the PDF option. */
44
+ formatPdf: string
45
+ /** Cancel button. */
46
+ cancel: string
47
+ /** Confirm (create download job) button — idle state. */
48
+ confirm: string
49
+ /** Confirm button — pending state. */
50
+ submitting: string
51
+ /** Progress note while the artifact is generating. */
52
+ preparing: string
53
+ /** Download-ready action label (links to the artifact). */
54
+ download: string
55
+ /** Note shown once the artifact is ready. */
56
+ ready: string
57
+ }
58
+
59
+ export interface DownloadModalProps {
60
+ /** Controlled open state. */
61
+ isOpen: boolean
62
+ /** Notified on open/close (false on Esc / click-outside / cancel). */
63
+ onOpenChange: (isOpen: boolean) => void
64
+ /** Create the `download` delivery job for the chosen format. */
65
+ onConfirm: (format: DownloadFormat) => void
66
+ /** Whether the confirm mutation is in flight (disables + shows pending). */
67
+ isPending?: boolean
68
+ /** Server / mutation error message to surface inline (role="alert"). */
69
+ error?: string
70
+ /**
71
+ * Once the job is created and its artifact is ready, the URL to download.
72
+ * While null/undefined the modal shows the create form (or the preparing
73
+ * note when `isPreparing`).
74
+ */
75
+ artifactUrl?: string | null
76
+ /** Whether a created job's artifact is still generating (shows the progress note). */
77
+ isPreparing?: boolean
78
+ /** App-supplied i18n strings. */
79
+ labels: DownloadModalLabels
80
+ /** Optional test id forwarded to the dialog. */
81
+ 'data-testid'?: string
82
+ }
83
+
84
+ /** Download modal with a PNG/PDF format choice and a ready-artifact download link. */
85
+ export function DownloadModal({
86
+ isOpen,
87
+ onOpenChange,
88
+ onConfirm,
89
+ isPending = false,
90
+ error,
91
+ artifactUrl,
92
+ isPreparing = false,
93
+ labels,
94
+ 'data-testid': testId,
95
+ }: DownloadModalProps): React.ReactNode {
96
+ const [format, setFormat] = useState<DownloadFormat>('png')
97
+
98
+ const handleConfirm = (): void => {
99
+ onConfirm(format)
100
+ }
101
+
102
+ const hasArtifact = artifactUrl != null && artifactUrl.length > 0
103
+
104
+ return (
105
+ <Dialog
106
+ title={labels.title}
107
+ data-testid={testId ?? 'delivery-download-modal'}
108
+ isOpen={isOpen}
109
+ onOpenChange={onOpenChange}
110
+ >
111
+ <div className="flex flex-col gap-3">
112
+ <p className="text-sm text-fg-muted">{labels.body}</p>
113
+
114
+ {hasArtifact ? (
115
+ <>
116
+ <p data-testid="delivery-download-ready" role="status" className="text-sm text-fg">
117
+ {labels.ready}
118
+ </p>
119
+ <div className="mt-2 flex justify-end gap-2">
120
+ <Button variant="secondary" size="sm" onPress={() => onOpenChange(false)}>
121
+ {labels.cancel}
122
+ </Button>
123
+ {/* React Aria <Button> is a real <button>; a download is a navigation,
124
+ so we use a styled anchor (the only anchor in these modals). */}
125
+ <a
126
+ data-testid="delivery-download-link"
127
+ href={artifactUrl ?? undefined}
128
+ download
129
+ className="inline-flex h-8 items-center justify-center gap-2 rounded-md bg-brand-primary px-3 text-sm font-medium text-fg-inverted no-underline outline-none transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
130
+ >
131
+ {labels.download}
132
+ </a>
133
+ </div>
134
+ </>
135
+ ) : isPreparing ? (
136
+ <>
137
+ <p data-testid="delivery-download-preparing" role="status" className="text-sm text-fg-muted">
138
+ {labels.preparing}
139
+ </p>
140
+ <div className="mt-2 flex justify-end gap-2">
141
+ <Button variant="secondary" size="sm" onPress={() => onOpenChange(false)}>
142
+ {labels.cancel}
143
+ </Button>
144
+ </div>
145
+ </>
146
+ ) : (
147
+ <>
148
+ <RadioGroup
149
+ aria-label={labels.formatLabel}
150
+ value={format}
151
+ onChange={(value: string) => setFormat(value as DownloadFormat)}
152
+ className="flex flex-col gap-2"
153
+ >
154
+ <Label className="text-sm font-medium text-fg">{labels.formatLabel}</Label>
155
+ <div className="flex gap-2">
156
+ <DownloadFormatOption value="png" label={labels.formatPng} />
157
+ <DownloadFormatOption value="pdf" label={labels.formatPdf} />
158
+ </div>
159
+ </RadioGroup>
160
+
161
+ {error ? (
162
+ <p data-testid="delivery-download-error" role="alert" className="text-sm text-danger">
163
+ {error}
164
+ </p>
165
+ ) : null}
166
+
167
+ <div className="mt-2 flex justify-end gap-2">
168
+ <Button
169
+ variant="secondary"
170
+ size="sm"
171
+ isDisabled={isPending}
172
+ onPress={() => onOpenChange(false)}
173
+ >
174
+ {labels.cancel}
175
+ </Button>
176
+ <Button
177
+ data-testid="delivery-download-confirm"
178
+ variant="primary"
179
+ size="sm"
180
+ isDisabled={isPending}
181
+ isPending={isPending}
182
+ onPress={handleConfirm}
183
+ >
184
+ {isPending ? labels.submitting : labels.confirm}
185
+ </Button>
186
+ </div>
187
+ </>
188
+ )}
189
+ </div>
190
+ </Dialog>
191
+ )
192
+ }
193
+
194
+ /** One radio chip in the download-format group. */
195
+ function DownloadFormatOption({ value, label }: { value: Key; label: string }): React.ReactNode {
196
+ return (
197
+ <Radio
198
+ value={String(value)}
199
+ className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm text-fg outline-none transition-colors data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[selected]:border-brand-primary data-[selected]:font-medium"
200
+ >
201
+ {label}
202
+ </Radio>
203
+ )
204
+ }
@@ -0,0 +1,170 @@
1
+ // @app/ui — publish modal (issue 14).
2
+ //
3
+ // PRESENTATIONAL publish dialog built on the @app/ui <Dialog> (React Aria Modal
4
+ // — focus trap, Esc, aria-modal). Collects a destination + channel and, on
5
+ // confirm, the owning surface in apps/web creates a `publish` delivery job.
6
+ //
7
+ // VALIDATION: destination + channel are both required (non-blank, trimmed) —
8
+ // confirm stays DISABLED until both hold a value. The channel is a constrained
9
+ // choice (the app supplies the option list as `labels.channels`).
10
+ //
11
+ // APP-AGNOSTIC (hard rule #1): every string is a prop; the action is delegated
12
+ // via `onConfirm(payload)`; apps/web wires i18n + the REST mutation (`publish`
13
+ // kind) + invalidation.
14
+ //
15
+ // a11y: the destination input is label-associated; the channel select is a RAC
16
+ // Select (listbox ARIA); the server error is role="alert"; confirm shows a
17
+ // pending state and disables while in flight / invalid.
18
+
19
+ import { useState } from 'react'
20
+ import { Button } from '../button'
21
+ import { Dialog } from '../dialog'
22
+ import { Input } from '../input'
23
+ import { Select, SelectItem } from '../select'
24
+
25
+ /** One publish channel option (value + visible label), supplied by the app. */
26
+ export interface PublishChannelOption {
27
+ /** Stable channel id stored on the job (e.g. "instagram", "linkedin"). */
28
+ value: string
29
+ /** Translated, human-readable channel name. */
30
+ label: string
31
+ }
32
+
33
+ /** The publish payload the modal yields on confirm. */
34
+ export interface PublishPayload {
35
+ /** Destination label (e.g. an account / page name) — non-blank. */
36
+ destination: string
37
+ /** Selected channel value. */
38
+ channel: string
39
+ }
40
+
41
+ /** Strings the publish modal renders — supplied by the app (i18n). */
42
+ export interface PublishModalLabels {
43
+ /** Dialog heading. */
44
+ title: string
45
+ /** Explanatory body copy under the title. */
46
+ body: string
47
+ /** Destination field label + placeholder. */
48
+ destinationLabel: string
49
+ destinationPlaceholder: string
50
+ /** Channel select label + placeholder. */
51
+ channelLabel: string
52
+ channelPlaceholder: string
53
+ /** Cancel button. */
54
+ cancel: string
55
+ /** Confirm (create publish job) button — idle state. */
56
+ confirm: string
57
+ /** Confirm button — pending state. */
58
+ submitting: string
59
+ /** The channel options to choose from. */
60
+ channels: PublishChannelOption[]
61
+ }
62
+
63
+ export interface PublishModalProps {
64
+ /** Controlled open state. */
65
+ isOpen: boolean
66
+ /** Notified on open/close (false on Esc / click-outside / cancel). */
67
+ onOpenChange: (isOpen: boolean) => void
68
+ /** Create the `publish` delivery job with the chosen destination + channel. */
69
+ onConfirm: (payload: PublishPayload) => void
70
+ /** Whether the confirm mutation is in flight (disables + shows pending). */
71
+ isPending?: boolean
72
+ /** Server / mutation error message to surface inline (role="alert"). */
73
+ error?: string
74
+ /** App-supplied i18n strings. */
75
+ labels: PublishModalLabels
76
+ /** Optional test id forwarded to the dialog. */
77
+ 'data-testid'?: string
78
+ }
79
+
80
+ /** Publish modal with a destination input + a channel choice. */
81
+ export function PublishModal({
82
+ isOpen,
83
+ onOpenChange,
84
+ onConfirm,
85
+ isPending = false,
86
+ error,
87
+ labels,
88
+ 'data-testid': testId,
89
+ }: PublishModalProps): React.ReactNode {
90
+ const [destination, setDestination] = useState('')
91
+ const [channel, setChannel] = useState<string>('')
92
+
93
+ const reset = (): void => {
94
+ setDestination('')
95
+ setChannel('')
96
+ }
97
+
98
+ const canConfirm = !isPending && destination.trim().length > 0 && channel.trim().length > 0
99
+
100
+ const handleOpenChange = (open: boolean): void => {
101
+ if (!open) reset()
102
+ onOpenChange(open)
103
+ }
104
+
105
+ const handleConfirm = (): void => {
106
+ if (!canConfirm) return
107
+ onConfirm({ destination: destination.trim(), channel })
108
+ }
109
+
110
+ return (
111
+ <Dialog
112
+ title={labels.title}
113
+ data-testid={testId ?? 'delivery-publish-modal'}
114
+ isOpen={isOpen}
115
+ onOpenChange={handleOpenChange}
116
+ >
117
+ <div className="flex flex-col gap-3">
118
+ <p className="text-sm text-fg-muted">{labels.body}</p>
119
+
120
+ <Input
121
+ label={labels.destinationLabel}
122
+ placeholder={labels.destinationPlaceholder}
123
+ value={destination}
124
+ onChange={setDestination}
125
+ data-testid="delivery-publish-destination"
126
+ />
127
+ <Select
128
+ label={labels.channelLabel}
129
+ placeholder={labels.channelPlaceholder}
130
+ selectedKey={channel.length > 0 ? channel : null}
131
+ onSelectionChange={(key) => setChannel(String(key))}
132
+ data-testid="delivery-publish-channel"
133
+ >
134
+ {labels.channels.map((option) => (
135
+ <SelectItem key={option.value} id={option.value}>
136
+ {option.label}
137
+ </SelectItem>
138
+ ))}
139
+ </Select>
140
+
141
+ {error ? (
142
+ <p data-testid="delivery-publish-error" role="alert" className="text-sm text-danger">
143
+ {error}
144
+ </p>
145
+ ) : null}
146
+
147
+ <div className="mt-2 flex justify-end gap-2">
148
+ <Button
149
+ variant="secondary"
150
+ size="sm"
151
+ isDisabled={isPending}
152
+ onPress={() => handleOpenChange(false)}
153
+ >
154
+ {labels.cancel}
155
+ </Button>
156
+ <Button
157
+ data-testid="delivery-publish-confirm"
158
+ variant="primary"
159
+ size="sm"
160
+ isDisabled={!canConfirm}
161
+ isPending={isPending}
162
+ onPress={handleConfirm}
163
+ >
164
+ {isPending ? labels.submitting : labels.confirm}
165
+ </Button>
166
+ </div>
167
+ </div>
168
+ </Dialog>
169
+ )
170
+ }
@@ -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 p-6 text-center outline-none transition-colors ' +
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)