portal-mojo 0.2.19 → 0.2.21
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/docs/README.md +7 -1
- package/docs/admin-credentials.md +27 -0
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +160 -0
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +18 -0
- package/docs/admin-edge.md +200 -196
- package/docs/admin-fleet-configuration.md +98 -2
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-identity-directories.md +12 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +45 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/auth-pages.md +18 -6
- package/docs/auth.md +16 -2
- package/docs/client.md +7 -0
- package/docs/collection-select.md +14 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
- package/src/admin/credentials/acme-consumer-key.ts +34 -0
- package/src/admin/credentials/group-api-keys.tsx +5 -3
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/CertificateDetail.tsx +8 -3
- package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
- package/src/admin/dns/DomainDetail.tsx +14 -4
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +26 -13
- package/src/admin/dns/SharedValidationDialog.tsx +298 -0
- package/src/admin/dns/SharedValidationPanel.tsx +76 -0
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/dns/shared-validation-api.ts +90 -0
- package/src/admin/dns/shared-validation-data.ts +106 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +27 -10
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
- package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
- package/src/admin/fleet-configuration/data.ts +4 -0
- package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
- package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
- package/src/admin/identity/groups/models.ts +2 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/auth.ts +77 -25
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/fleet-mock.ts +73 -1
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/index.ts +2 -2
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +280 -11
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-dns-certificates.css +33 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/CollectionSelect.tsx +30 -8
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- package/src/ui/schema-form-core.tsx +3 -5
package/src/ui/ComboBox.tsx
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// (web-mojo strict-equalled `data-value` strings against numbers).
|
|
19
19
|
// - Popover is the sole outside-click/Escape owner. ComboBox owns only the
|
|
20
20
|
// meaning of each dismissal (settle outside, revert on Escape).
|
|
21
|
-
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
21
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState, type InputHTMLAttributes, type ReactNode, type Ref } from 'react';
|
|
22
22
|
import { Popover } from './Popover';
|
|
23
23
|
|
|
24
24
|
export type ComboValue = string | number;
|
|
@@ -28,11 +28,13 @@ export interface ComboOption {
|
|
|
28
28
|
label: string;
|
|
29
29
|
/** Muted second line in the dropdown (rendered while `showDescription`). */
|
|
30
30
|
description?: string;
|
|
31
|
+
/** Visible but unavailable; cannot be committed by pointer, typing or keyboard. */
|
|
32
|
+
disabled?: boolean;
|
|
31
33
|
/** Opaque payload handed back through onSelect — rule-builder hook. */
|
|
32
34
|
meta?: Record<string, unknown>;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
export interface ComboBoxProps {
|
|
37
|
+
export interface ComboBoxProps extends Pick<InputHTMLAttributes<HTMLInputElement>, 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'aria-invalid' | 'title' | 'autoFocus' | 'name'> {
|
|
36
38
|
/** Committed value. The input DISPLAYS the matching option's label. */
|
|
37
39
|
value?: ComboValue | null;
|
|
38
40
|
/** Bare strings normalize to { value: s, label: s }. */
|
|
@@ -54,6 +56,7 @@ export interface ComboBoxProps {
|
|
|
54
56
|
required?: boolean;
|
|
55
57
|
id?: string;
|
|
56
58
|
className?: string;
|
|
59
|
+
inputRef?: Ref<HTMLInputElement>;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
function normalizeOptions(input: ReadonlyArray<ComboOption | string>): ComboOption[] {
|
|
@@ -66,6 +69,7 @@ function normalizeOptions(input: ReadonlyArray<ComboOption | string>): ComboOpti
|
|
|
66
69
|
const opt: ComboOption = { value: raw.value, label: raw.label || String(raw.value) };
|
|
67
70
|
if (raw.description) opt.description = raw.description;
|
|
68
71
|
if (raw.meta) opt.meta = raw.meta;
|
|
72
|
+
if (raw.disabled) opt.disabled = true;
|
|
69
73
|
out.push(opt);
|
|
70
74
|
}
|
|
71
75
|
return out;
|
|
@@ -115,6 +119,8 @@ export function ComboBox({
|
|
|
115
119
|
required = false,
|
|
116
120
|
id,
|
|
117
121
|
className,
|
|
122
|
+
inputRef: externalInputRef,
|
|
123
|
+
...inputAttributes
|
|
118
124
|
}: ComboBoxProps) {
|
|
119
125
|
// `draft` is the uncommitted typed text; null = not editing, display the
|
|
120
126
|
// committed value's label. This split IS the one-pipeline fix.
|
|
@@ -123,6 +129,11 @@ export function ComboBox({
|
|
|
123
129
|
const [highlight, setHighlight] = useState(-1);
|
|
124
130
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
125
131
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
132
|
+
const setInputRef = useCallback((node: HTMLInputElement | null) => {
|
|
133
|
+
inputRef.current = node;
|
|
134
|
+
if (typeof externalInputRef === 'function') return externalInputRef(node);
|
|
135
|
+
if (externalInputRef) externalInputRef.current = node;
|
|
136
|
+
}, [externalInputRef]);
|
|
126
137
|
// Mirror of `draft`, nulled synchronously on commit/revert so the
|
|
127
138
|
// mousedown-outside + blur pair can both call settle() without
|
|
128
139
|
// double-committing (blur fires before React re-renders).
|
|
@@ -152,6 +163,9 @@ export function ComboBox({
|
|
|
152
163
|
}
|
|
153
164
|
|
|
154
165
|
const canOpen = !disabled && !readOnly;
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!canOpen) { draftRef.current = null; setDraft(null); setOpen(false); setHighlight(-1); }
|
|
168
|
+
}, [canOpen]);
|
|
155
169
|
const displayValue = draft ?? (selected ? selected.label : value == null ? '' : String(value));
|
|
156
170
|
|
|
157
171
|
const setDraftBoth = (d: string | null) => { draftRef.current = d; setDraft(d); };
|
|
@@ -159,12 +173,15 @@ export function ComboBox({
|
|
|
159
173
|
|
|
160
174
|
// ── the ONE committed pipeline ───────────────────────────────────────
|
|
161
175
|
const commitOption = (opt: ComboOption) => {
|
|
176
|
+
if (!canOpen || inputRef.current?.matches(':disabled')) return;
|
|
162
177
|
setDraftBoth(null);
|
|
163
178
|
close();
|
|
179
|
+
if (opt.disabled) return;
|
|
164
180
|
if (opt.value !== value) onChange?.(opt.value, opt);
|
|
165
181
|
onSelect?.(opt);
|
|
166
182
|
};
|
|
167
183
|
const commitCustom = (text: string) => {
|
|
184
|
+
if (!canOpen || inputRef.current?.matches(':disabled')) return;
|
|
168
185
|
setDraftBoth(null);
|
|
169
186
|
close();
|
|
170
187
|
if (text !== (value == null ? '' : String(value))) onChange?.(text);
|
|
@@ -193,18 +210,27 @@ export function ComboBox({
|
|
|
193
210
|
document.getElementById(`combo-opt-${uid}-${highlight}`)?.scrollIntoView({ block: 'nearest' });
|
|
194
211
|
}, [highlight, uid]);
|
|
195
212
|
|
|
213
|
+
const moveHighlight = (direction: 1 | -1) => setHighlight((current) => {
|
|
214
|
+
let next = current < 0 && direction === -1 ? 0 : current;
|
|
215
|
+
for (let count = 0; count < visible.length; count++) {
|
|
216
|
+
next = (next + direction + visible.length) % visible.length;
|
|
217
|
+
if (!visible[next].disabled) return next;
|
|
218
|
+
}
|
|
219
|
+
return -1;
|
|
220
|
+
});
|
|
221
|
+
|
|
196
222
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
197
223
|
switch (e.key) {
|
|
198
224
|
case 'ArrowDown':
|
|
199
225
|
if (!canOpen) break;
|
|
200
226
|
e.preventDefault();
|
|
201
227
|
if (!open) setOpen(true);
|
|
202
|
-
else
|
|
228
|
+
else moveHighlight(1);
|
|
203
229
|
break;
|
|
204
230
|
case 'ArrowUp':
|
|
205
231
|
if (!open) break;
|
|
206
232
|
e.preventDefault();
|
|
207
|
-
|
|
233
|
+
moveHighlight(-1);
|
|
208
234
|
break;
|
|
209
235
|
case 'Enter':
|
|
210
236
|
if (open && highlight >= 0 && highlight < visible.length) {
|
|
@@ -251,7 +277,8 @@ export function ComboBox({
|
|
|
251
277
|
<div className={`combo${className ? ` ${className}` : ''}`} ref={wrapRef}>
|
|
252
278
|
<div className="combo-input-wrap">
|
|
253
279
|
<input
|
|
254
|
-
|
|
280
|
+
{...inputAttributes}
|
|
281
|
+
ref={setInputRef}
|
|
255
282
|
id={id}
|
|
256
283
|
type="text"
|
|
257
284
|
className="input combo-input"
|
|
@@ -267,6 +294,7 @@ export function ComboBox({
|
|
|
267
294
|
readOnly={readOnly}
|
|
268
295
|
required={required}
|
|
269
296
|
onFocus={() => { if (canOpen) setOpen(true); }}
|
|
297
|
+
onClick={() => { if (canOpen) setOpen(true); }}
|
|
270
298
|
onBlur={(e) => {
|
|
271
299
|
if (wrapRef.current && e.relatedTarget && wrapRef.current.contains(e.relatedTarget as Node)) return;
|
|
272
300
|
settle();
|
|
@@ -287,7 +315,8 @@ export function ComboBox({
|
|
|
287
315
|
type="button"
|
|
288
316
|
className="combo-toggle"
|
|
289
317
|
tabIndex={-1}
|
|
290
|
-
aria-
|
|
318
|
+
aria-hidden="true"
|
|
319
|
+
title="Show options"
|
|
291
320
|
onMouseDown={(e) => e.preventDefault() /* keep input focus */}
|
|
292
321
|
onClick={onToggle}
|
|
293
322
|
>
|
|
@@ -297,7 +326,7 @@ export function ComboBox({
|
|
|
297
326
|
</div>
|
|
298
327
|
<Popover
|
|
299
328
|
anchorRef={wrapRef}
|
|
300
|
-
open={open}
|
|
329
|
+
open={open && canOpen}
|
|
301
330
|
onClose={(reason) => reason === 'escape' ? revert() : settle()}
|
|
302
331
|
id={listId}
|
|
303
332
|
className="combo-pop"
|
|
@@ -316,9 +345,11 @@ export function ComboBox({
|
|
|
316
345
|
id={optionId(i)}
|
|
317
346
|
role="option"
|
|
318
347
|
aria-selected={isSelected}
|
|
348
|
+
data-value={opt.value}
|
|
349
|
+
aria-disabled={opt.disabled || undefined}
|
|
319
350
|
className={`combo-option${i === highlight ? ' combo-hot' : ''}${isSelected ? ' combo-selected' : ''}`}
|
|
320
351
|
onMouseDown={(e) => e.preventDefault() /* option click must not blur-settle first */}
|
|
321
|
-
onClick={() => commitOption(opt)}
|
|
352
|
+
onClick={() => { if (!opt.disabled) commitOption(opt); }}
|
|
322
353
|
>
|
|
323
354
|
<div className="combo-option-main">
|
|
324
355
|
<div className="combo-option-label">{highlightLabel(opt.label, (draft ?? '').trim())}</div>
|
package/src/ui/FilterBar.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ComboBox } from './ComboBox';
|
|
1
2
|
// FilterBar — web-mojo's filter UX: an "Add filter" menu listing every
|
|
2
3
|
// available filter with a per-type icon and a check on the active ones, each
|
|
3
4
|
// opening a type-appropriate dialog; applied filters become editable pills.
|
|
@@ -126,13 +127,10 @@ function FilterForm({ def, current, onDone }: {
|
|
|
126
127
|
))}
|
|
127
128
|
</div>
|
|
128
129
|
) : def.type === 'select' || def.type === 'boolean' ? (
|
|
129
|
-
<
|
|
130
|
-
<option value="">Select…</option>
|
|
131
|
-
{(def.type === 'boolean'
|
|
130
|
+
<ComboBox allowCustom={false} maxSuggestions={Number.MAX_SAFE_INTEGER} value={String(text)} onChange={(selectedValue) => setText(String(selectedValue))} autoFocus options={[({ value: "", label: "Select…" }), ...((def.type === 'boolean'
|
|
132
131
|
? [{ value: 'true', label: def.trueLabel ?? 'True' }, { value: 'false', label: def.falseLabel ?? 'False' }]
|
|
133
132
|
: def.options ?? []
|
|
134
|
-
).map((o) =>
|
|
135
|
-
</select>
|
|
133
|
+
).map((o) => ({ value: String(o.value), label: String(o.label) })) ?? [])]} />
|
|
136
134
|
) : (
|
|
137
135
|
<input
|
|
138
136
|
className="input"
|
package/src/ui/ModelTable.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ComboBox } from './ComboBox';
|
|
1
2
|
// ModelTable — schema-driven server table at web-mojo TableView fidelity
|
|
2
3
|
// (ListView.js / TableView.js / TablePage.js port). No table library: sort,
|
|
3
4
|
// filter, search and paging are all wire params django-mojo processes, so
|
|
@@ -986,14 +987,7 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
|
|
|
986
987
|
)}
|
|
987
988
|
<label className="page-size">
|
|
988
989
|
<span className="dim">Rows</span>
|
|
989
|
-
<
|
|
990
|
-
className="input input-compact"
|
|
991
|
-
value={p.size}
|
|
992
|
-
onChange={(e) => p.setSize(Number(e.target.value))}
|
|
993
|
-
aria-label="Rows per page"
|
|
994
|
-
>
|
|
995
|
-
{PAGE_SIZES.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
996
|
-
</select>
|
|
990
|
+
<ComboBox allowCustom={false} maxSuggestions={Number.MAX_SAFE_INTEGER} value={String(p.size)} onChange={(selectedValue) => p.setSize(Number(String(selectedValue)))} aria-label="Rows per page" options={[...(PAGE_SIZES.map((s) => ({ value: String(s), label: String(s) })) ?? [])]} />
|
|
997
991
|
</label>
|
|
998
992
|
</div>
|
|
999
993
|
</div>
|
package/src/ui/Popover.tsx
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// flagged autofocus, and we don't. Typing in an anchored input keeps
|
|
27
27
|
// working; per-option focus retention (e.g. preventDefault on option
|
|
28
28
|
// mousedown) belongs to the consuming control.
|
|
29
|
-
import {
|
|
29
|
+
import { useLayoutEffect, useRef, type HTMLAttributes, type RefObject } from 'react';
|
|
30
30
|
import { createPortal } from 'react-dom';
|
|
31
31
|
|
|
32
32
|
export type PopoverPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
@@ -52,6 +52,7 @@ export interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
52
52
|
|
|
53
53
|
const PLACEMENTS: readonly PopoverPlacement[] = ['bottom-start', 'bottom-end', 'top-start', 'top-end'];
|
|
54
54
|
const VIEWPORT_INSET = 8; // px breathing room at viewport edges (source parity)
|
|
55
|
+
const openPopovers: HTMLDivElement[] = [];
|
|
55
56
|
|
|
56
57
|
const SUPPORTS_POPOVER =
|
|
57
58
|
typeof HTMLElement !== 'undefined' && 'showPopover' in HTMLElement.prototype;
|
|
@@ -71,7 +72,7 @@ export function Popover({
|
|
|
71
72
|
// Latest-callback ref so an inline `onClose` doesn't churn the document
|
|
72
73
|
// listeners (and their deferred attach) on every parent render.
|
|
73
74
|
const onCloseRef = useRef(onClose);
|
|
74
|
-
|
|
75
|
+
useLayoutEffect(() => { onCloseRef.current = onClose; });
|
|
75
76
|
|
|
76
77
|
// ── Show + position ──────────────────────────────────────────────
|
|
77
78
|
// Layout effect: promote to the top layer and position BEFORE paint, so
|
|
@@ -146,8 +147,11 @@ export function Popover({
|
|
|
146
147
|
}, [open, placement, gap, anchorRef]);
|
|
147
148
|
|
|
148
149
|
// ── Outside-mousedown + Escape ───────────────────────────────────
|
|
149
|
-
|
|
150
|
+
useLayoutEffect(() => {
|
|
150
151
|
if (!open) return;
|
|
152
|
+
const popover = popRef.current;
|
|
153
|
+
if (!popover) return;
|
|
154
|
+
openPopovers.push(popover);
|
|
151
155
|
|
|
152
156
|
const onDocDown = (event: MouseEvent) => {
|
|
153
157
|
const target = event.target as Node;
|
|
@@ -159,7 +163,9 @@ export function Popover({
|
|
|
159
163
|
onCloseRef.current('outside');
|
|
160
164
|
};
|
|
161
165
|
const onKey = (event: KeyboardEvent) => {
|
|
162
|
-
if (event.key !== 'Escape') return;
|
|
166
|
+
if (event.key !== 'Escape' || event.defaultPrevented || openPopovers.at(-1) !== popover) return;
|
|
167
|
+
const targetDialog = event.target instanceof popover.ownerDocument.defaultView!.Element ? event.target.closest('dialog') : null;
|
|
168
|
+
if (targetDialog && targetDialog !== anchorRef.current?.closest('dialog')) return;
|
|
163
169
|
// Capture phase + preventDefault: when the anchor lives inside
|
|
164
170
|
// an open <dialog>, the first Escape must close the popover
|
|
165
171
|
// ONLY — preventing default stops the dialog's cancel/close
|
|
@@ -170,16 +176,18 @@ export function Popover({
|
|
|
170
176
|
onCloseRef.current('escape');
|
|
171
177
|
};
|
|
172
178
|
|
|
173
|
-
//
|
|
174
|
-
//
|
|
179
|
+
// Escape must be active in the opening frame, before native dialog
|
|
180
|
+
// cancellation. Only outside-click registration waits out the opener.
|
|
181
|
+
document.addEventListener('keydown', onKey, true);
|
|
175
182
|
const timer = window.setTimeout(() => {
|
|
176
183
|
document.addEventListener('mousedown', onDocDown, true);
|
|
177
|
-
document.addEventListener('keydown', onKey, true);
|
|
178
184
|
}, 0);
|
|
179
185
|
return () => {
|
|
180
186
|
window.clearTimeout(timer);
|
|
181
187
|
document.removeEventListener('mousedown', onDocDown, true);
|
|
182
188
|
document.removeEventListener('keydown', onKey, true);
|
|
189
|
+
const index = openPopovers.indexOf(popover);
|
|
190
|
+
if (index !== -1) openPopovers.splice(index, 1);
|
|
183
191
|
};
|
|
184
192
|
}, [open, anchorRef]);
|
|
185
193
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ComboBox } from '../ComboBox';
|
|
1
2
|
// TimePicker — HH:MM time input with the stepper panel and optional IANA
|
|
2
3
|
// timezone. Ported from web-mojo src/core/forms/inputs/TimePicker.js (648
|
|
3
4
|
// lines, read in full) + the `.mojo-time-*` block of calendar.css.
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
//
|
|
26
27
|
// Timezone-picker SEAM: this component owns every bit of the zone's value and
|
|
27
28
|
// serialization logic but renders the picker through `renderTimezoneSelect`.
|
|
28
|
-
// Without that prop it falls back to a
|
|
29
|
+
// Without that prop it falls back to a searchable ComboBox built from
|
|
29
30
|
// Intl.supportedValuesOf('timeZone'), so the feature is complete standalone.
|
|
30
31
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
31
32
|
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject } from 'react';
|
|
@@ -92,7 +93,7 @@ export interface TimePickerProps {
|
|
|
92
93
|
timezones?: string[] | null;
|
|
93
94
|
/** Default 'iso'. Unknown values warn + fall back. */
|
|
94
95
|
outputFormat?: TimeOutputFormat;
|
|
95
|
-
/** Zone-picker slot. Absent → the built-in
|
|
96
|
+
/** Zone-picker slot. Absent → the built-in ComboBox. */
|
|
96
97
|
renderTimezoneSelect?: (props: TimezoneSelectSlotProps) => ReactNode;
|
|
97
98
|
/** Fires on COMMIT only, and only when the serialized value changed. */
|
|
98
99
|
onChange?: (event: TimeChangeEvent) => void;
|
|
@@ -252,18 +253,7 @@ function FallbackTimezoneSelect({ value, onChange, timezones, disabled }: Timezo
|
|
|
252
253
|
}, [known, value]);
|
|
253
254
|
|
|
254
255
|
return (
|
|
255
|
-
<select
|
|
256
|
-
className="input mojo-time-tz-select"
|
|
257
|
-
aria-label="Timezone"
|
|
258
|
-
value={value ?? ''}
|
|
259
|
-
disabled={disabled}
|
|
260
|
-
onChange={(e) => onChange(e.currentTarget.value || null)}
|
|
261
|
-
>
|
|
262
|
-
<option value="">(no timezone)</option>
|
|
263
|
-
{/* A value the list doesn't carry still shows — never render nothing. */}
|
|
264
|
-
{!known && value ? <option value={value}>{value}</option> : null}
|
|
265
|
-
{zones.map((z) => <option key={z} value={z}>{z}</option>)}
|
|
266
|
-
</select>
|
|
256
|
+
<ComboBox allowCustom={false} maxSuggestions={Number.MAX_SAFE_INTEGER} className="mojo-time-tz-select" aria-label="Timezone" value={String(value ?? '')} disabled={disabled} onChange={(selectedValue) => onChange(String(selectedValue) || null)} options={[({ value: "", label: "(no timezone)" }), ...(!known && value ? [({ value: String(value), label: String(value) })] : []), ...(zones.map((z) => ({ value: String(z), label: String(z) })) ?? [])]} />
|
|
267
257
|
);
|
|
268
258
|
}
|
|
269
259
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ComboBox } from '../ComboBox';
|
|
1
2
|
import {
|
|
2
3
|
useCallback, useEffect, useMemo, useRef, useState,
|
|
3
4
|
type PointerEvent as ReactPointerEvent,
|
|
@@ -403,14 +404,12 @@ export function ImageEditor(props: ImageEditorProps) {
|
|
|
403
404
|
<span className="image-editor-value">{Math.round(cropBox.width)} × {Math.round(cropBox.height)} px{cropOptions.cropAndScale ? ` → ${cropOptions.cropAndScale.width} × ${cropOptions.cropAndScale.height}` : ''}</span>
|
|
404
405
|
</>}
|
|
405
406
|
{mode === 'filters' && <label className="image-editor-preset">Effect
|
|
406
|
-
<
|
|
407
|
-
const next =
|
|
407
|
+
<ComboBox allowCustom={false} maxSuggestions={Number.MAX_SAFE_INTEGER} value={String(preset)} disabled={disabled} onChange={(selectedValue) => {
|
|
408
|
+
const next = String(selectedValue);
|
|
408
409
|
if (next in FILTER_PRESETS) setPreset(next as FilterPresetName);
|
|
409
410
|
else { console.warn(`ImageEditor: unknown filter preset ${JSON.stringify(next)} — falling back to Original.`); setPreset('none'); }
|
|
410
411
|
setDirty(true);
|
|
411
|
-
}}
|
|
412
|
-
{Object.entries(FILTER_PRESETS).map(([key, value]) => <option key={key} value={key}>{value.name}</option>)}
|
|
413
|
-
</select>
|
|
412
|
+
}} options={[...(Object.entries(FILTER_PRESETS).map(([key, value]) => ({ value: String(key), label: String(value.name) })) ?? [])]} />
|
|
414
413
|
</label>}
|
|
415
414
|
<button type="button" className="btn btn-primary btn-compact image-editor-apply" disabled={disabled || (mode !== 'crop' && !dirty && !transformDirty(transform) && !filterDirty(filters, preset))} onClick={() => commit(mode, mode === 'crop')}>Apply</button>
|
|
416
415
|
</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ComboBox } from './ComboBox';
|
|
1
2
|
import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from 'react';
|
|
2
3
|
import type { Field, FieldValue, FormData } from '../client/types';
|
|
3
4
|
import { declaredFieldPatch, getPath, resolveShowWhen, validateFieldValue } from './form-autosave';
|
|
@@ -220,7 +221,7 @@ export function SchemaSelect({ field, value, invalid, disabled, id, describedBy,
|
|
|
220
221
|
disabled?: boolean;
|
|
221
222
|
id?: string;
|
|
222
223
|
describedBy?: string;
|
|
223
|
-
focusTarget?: (node:
|
|
224
|
+
focusTarget?: (node: HTMLInputElement | null) => void;
|
|
224
225
|
onChange: (value: string) => void;
|
|
225
226
|
}) {
|
|
226
227
|
const options = field.options ?? [];
|
|
@@ -234,10 +235,7 @@ export function SchemaSelect({ field, value, invalid, disabled, id, describedBy,
|
|
|
234
235
|
}
|
|
235
236
|
const shown = known ? value : '';
|
|
236
237
|
return (
|
|
237
|
-
<
|
|
238
|
-
{shown === '' && <option value="" disabled>{field.placeholder ?? 'Select…'}</option>}
|
|
239
|
-
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
240
|
-
</select>
|
|
238
|
+
<ComboBox allowCustom={false} maxSuggestions={Number.MAX_SAFE_INTEGER} inputRef={focusTarget} id={id} aria-invalid={invalid || undefined} aria-describedby={describedBy} disabled={disabled || field.disabled} value={String(shown)} onChange={(selectedValue) => onChange(String(selectedValue))} options={[...(shown === '' ? [({ value: "", label: String(field.placeholder ?? 'Select…'), disabled: true })] : []), ...(options.map((option) => ({ value: String(option.value), label: String(option.label) })) ?? [])]} />
|
|
241
239
|
);
|
|
242
240
|
}
|
|
243
241
|
|