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.
Files changed (157) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-credentials.md +27 -0
  3. package/docs/admin-deployments.md +201 -0
  4. package/docs/admin-dns-certificates.md +160 -0
  5. package/docs/admin-dns-domains.md +92 -0
  6. package/docs/admin-dns.md +18 -0
  7. package/docs/admin-edge.md +200 -196
  8. package/docs/admin-fleet-configuration.md +98 -2
  9. package/docs/admin-geolocation.md +100 -0
  10. package/docs/admin-identity-directories.md +12 -0
  11. package/docs/admin-maintenance.md +133 -0
  12. package/docs/admin-modals.md +45 -2
  13. package/docs/admin-operational-fleet.md +206 -0
  14. package/docs/admin-presentation.md +51 -0
  15. package/docs/admin-signin.md +12 -2
  16. package/docs/auth-pages.md +18 -6
  17. package/docs/auth.md +16 -2
  18. package/docs/client.md +7 -0
  19. package/docs/collection-select.md +14 -0
  20. package/docs/combobox.md +18 -0
  21. package/docs/popover.md +5 -4
  22. package/package.json +1 -1
  23. package/src/admin/core/OperationsHelp.tsx +10 -0
  24. package/src/admin/core/index.ts +6 -4
  25. package/src/admin/core/route-context.ts +6 -0
  26. package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
  27. package/src/admin/credentials/acme-consumer-key.ts +34 -0
  28. package/src/admin/credentials/group-api-keys.tsx +5 -3
  29. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  30. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  31. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  32. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  33. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  34. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  35. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  36. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  37. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  38. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  39. package/src/admin/deployments/api.ts +158 -0
  40. package/src/admin/deployments/authority-state.ts +9 -0
  41. package/src/admin/deployments/deployment-state.ts +13 -0
  42. package/src/admin/deployments/index.ts +4 -0
  43. package/src/admin/deployments/onboarding-state.ts +11 -0
  44. package/src/admin/deployments/permissions.ts +37 -0
  45. package/src/admin/deployments/receipts.ts +78 -0
  46. package/src/admin/deployments/sanitize.ts +73 -0
  47. package/src/admin/deployments/shared.tsx +39 -0
  48. package/src/admin/deployments/types.ts +238 -0
  49. package/src/admin/deployments/upload.ts +53 -0
  50. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  51. package/src/admin/dns/CertificateDetail.tsx +8 -3
  52. package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
  53. package/src/admin/dns/DomainDetail.tsx +14 -4
  54. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  55. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  56. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  57. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  58. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  59. package/src/admin/dns/DomainsPage.tsx +26 -13
  60. package/src/admin/dns/SharedValidationDialog.tsx +298 -0
  61. package/src/admin/dns/SharedValidationPanel.tsx +76 -0
  62. package/src/admin/dns/api.ts +4 -2
  63. package/src/admin/dns/domain-navigation.ts +16 -0
  64. package/src/admin/dns/registrar-api.ts +13 -0
  65. package/src/admin/dns/shared-validation-api.ts +90 -0
  66. package/src/admin/dns/shared-validation-data.ts +106 -0
  67. package/src/admin/domains/assistant.ts +5 -4
  68. package/src/admin/domains/communications.ts +10 -9
  69. package/src/admin/domains/identity.ts +8 -7
  70. package/src/admin/domains/infrastructure.ts +19 -13
  71. package/src/admin/domains/lazy-page.ts +6 -0
  72. package/src/admin/domains/observability.ts +6 -5
  73. package/src/admin/domains/operations.ts +27 -10
  74. package/src/admin/domains/security.ts +16 -15
  75. package/src/admin/edge/EdgeHelp.tsx +10 -0
  76. package/src/admin/edge/ServingReadiness.tsx +54 -0
  77. package/src/admin/edge/VhostDetail.tsx +145 -139
  78. package/src/admin/edge/VhostWizard.tsx +204 -115
  79. package/src/admin/edge/VhostsPage.tsx +27 -28
  80. package/src/admin/edge/api.ts +24 -10
  81. package/src/admin/edge/models.ts +4 -4
  82. package/src/admin/edge/serving-data.ts +78 -0
  83. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  84. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  85. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  86. package/src/admin/fleet/FleetHelp.tsx +7 -0
  87. package/src/admin/fleet/FleetOverview.tsx +150 -0
  88. package/src/admin/fleet/FleetPage.tsx +11 -0
  89. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  90. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  91. package/src/admin/fleet/access.ts +22 -0
  92. package/src/admin/fleet/api.ts +77 -0
  93. package/src/admin/fleet/data.ts +148 -0
  94. package/src/admin/fleet/index.ts +8 -0
  95. package/src/admin/fleet/operations.ts +179 -0
  96. package/src/admin/fleet/standalone-data.ts +87 -0
  97. package/src/admin/fleet/types.ts +208 -0
  98. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
  99. package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
  100. package/src/admin/fleet-configuration/data.ts +4 -0
  101. package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
  102. package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
  103. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  104. package/src/admin/geolocation/data.ts +138 -0
  105. package/src/admin/geolocation/index.ts +3 -0
  106. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
  107. package/src/admin/identity/groups/models.ts +2 -0
  108. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  109. package/src/admin/index.ts +8 -0
  110. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  111. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  112. package/src/admin/maintenance/data.ts +486 -0
  113. package/src/admin/maintenance/index.ts +3 -0
  114. package/src/admin/maintenance/operations.ts +208 -0
  115. package/src/admin/public/infrastructure.ts +1 -0
  116. package/src/admin/public/operations.ts +3 -0
  117. package/src/admin/registry.ts +6 -2
  118. package/src/admin/settings/SettingDetail.tsx +7 -2
  119. package/src/admin/settings/SettingEditor.tsx +2 -1
  120. package/src/admin/settings/SettingsPage.tsx +3 -1
  121. package/src/admin/settings/model.ts +1 -0
  122. package/src/admin/signin/SigninPage.tsx +17 -3
  123. package/src/admin/signin/api.ts +2 -0
  124. package/src/charts/MetricsChartView.tsx +4 -11
  125. package/src/charts/MetricsMiniWidget.tsx +3 -15
  126. package/src/client/auth.ts +77 -25
  127. package/src/client/client.ts +6 -0
  128. package/src/client/deployments-mock.ts +375 -0
  129. package/src/client/domain-operations-mock.ts +37 -0
  130. package/src/client/fleet-mock.ts +73 -1
  131. package/src/client/geo-provider-mock.ts +82 -0
  132. package/src/client/index.ts +2 -2
  133. package/src/client/maintenance-mock.ts +285 -0
  134. package/src/client/mock.ts +280 -11
  135. package/src/client/operational-fleet-mock.ts +279 -0
  136. package/src/client/operations-mock.ts +85 -0
  137. package/src/client/serving-mock.ts +22 -0
  138. package/src/styles/components/admin-deployments.css +35 -0
  139. package/src/styles/components/admin-dns-certificates.css +33 -0
  140. package/src/styles/components/admin-domains.css +12 -0
  141. package/src/styles/components/admin-edge.css +125 -1
  142. package/src/styles/components/admin-geolocation.css +40 -0
  143. package/src/styles/components/admin-maintenance.css +127 -0
  144. package/src/styles/components/admin-operational-fleet.css +38 -0
  145. package/src/styles/components/admin-operations.css +9 -0
  146. package/src/styles/components/admin-signin.css +2 -0
  147. package/src/styles/components/combobox.css +12 -2
  148. package/src/styles/components/fleet-page.css +9 -0
  149. package/src/styles/index.css +7 -0
  150. package/src/ui/CollectionSelect.tsx +30 -8
  151. package/src/ui/ComboBox.tsx +39 -8
  152. package/src/ui/FilterBar.tsx +3 -5
  153. package/src/ui/ModelTable.tsx +2 -8
  154. package/src/ui/Popover.tsx +15 -7
  155. package/src/ui/date/TimePicker.tsx +4 -14
  156. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  157. package/src/ui/schema-form-core.tsx +3 -5
@@ -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 if (visible.length > 0) setHighlight((h) => (h + 1) % visible.length);
228
+ else moveHighlight(1);
203
229
  break;
204
230
  case 'ArrowUp':
205
231
  if (!open) break;
206
232
  e.preventDefault();
207
- if (visible.length > 0) setHighlight((h) => (h <= 0 ? visible.length - 1 : h - 1));
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
- ref={inputRef}
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-label="Toggle dropdown"
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>
@@ -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
- <select className="input" value={text} onChange={(e) => setText(e.target.value)} autoFocus>
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) => <option key={o.value} value={o.value}>{o.label}</option>)}
135
- </select>
133
+ ).map((o) => ({ value: String(o.value), label: String(o.label) })) ?? [])]} />
136
134
  ) : (
137
135
  <input
138
136
  className="input"
@@ -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
- <select
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>
@@ -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 { useEffect, useLayoutEffect, useRef, type HTMLAttributes, type RefObject } from 'react';
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
- useEffect(() => { onCloseRef.current = onClose; });
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
- useEffect(() => {
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
- // Defer attach so the interaction that opened the popover can't
174
- // immediately close it (source parity).
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 plain token-styled <select> built from
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 fallback <select>. */
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
- <select className="select" value={preset} disabled={disabled} onChange={(event) => {
407
- const next = event.target.value;
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: HTMLSelectElement | null) => void;
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
- <select ref={focusTarget} id={id} aria-invalid={invalid || undefined} aria-describedby={describedBy} disabled={disabled || field.disabled} className={`input${invalid ? ' input-invalid' : ''}`} value={shown} onChange={(event) => onChange(event.target.value)}>
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