portal-mojo 0.2.20 → 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 (131) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-deployments.md +201 -0
  3. package/docs/admin-dns-certificates.md +3 -3
  4. package/docs/admin-dns-domains.md +92 -0
  5. package/docs/admin-dns.md +4 -2
  6. package/docs/admin-edge.md +200 -196
  7. package/docs/admin-geolocation.md +100 -0
  8. package/docs/admin-maintenance.md +133 -0
  9. package/docs/admin-modals.md +17 -2
  10. package/docs/admin-operational-fleet.md +206 -0
  11. package/docs/admin-presentation.md +51 -0
  12. package/docs/admin-signin.md +12 -2
  13. package/docs/client.md +7 -0
  14. package/docs/combobox.md +18 -0
  15. package/docs/popover.md +5 -4
  16. package/package.json +1 -1
  17. package/src/admin/core/OperationsHelp.tsx +10 -0
  18. package/src/admin/core/index.ts +6 -4
  19. package/src/admin/core/route-context.ts +6 -0
  20. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  21. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  22. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  23. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  24. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  25. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  26. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  27. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  28. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  29. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  30. package/src/admin/deployments/api.ts +158 -0
  31. package/src/admin/deployments/authority-state.ts +9 -0
  32. package/src/admin/deployments/deployment-state.ts +13 -0
  33. package/src/admin/deployments/index.ts +4 -0
  34. package/src/admin/deployments/onboarding-state.ts +11 -0
  35. package/src/admin/deployments/permissions.ts +37 -0
  36. package/src/admin/deployments/receipts.ts +78 -0
  37. package/src/admin/deployments/sanitize.ts +73 -0
  38. package/src/admin/deployments/shared.tsx +39 -0
  39. package/src/admin/deployments/types.ts +238 -0
  40. package/src/admin/deployments/upload.ts +53 -0
  41. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  42. package/src/admin/dns/DomainDetail.tsx +10 -6
  43. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  44. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  45. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  46. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  47. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  48. package/src/admin/dns/DomainsPage.tsx +22 -13
  49. package/src/admin/dns/SharedValidationDialog.tsx +1 -1
  50. package/src/admin/dns/api.ts +4 -2
  51. package/src/admin/dns/domain-navigation.ts +16 -0
  52. package/src/admin/dns/registrar-api.ts +13 -0
  53. package/src/admin/domains/assistant.ts +5 -4
  54. package/src/admin/domains/communications.ts +10 -9
  55. package/src/admin/domains/identity.ts +8 -7
  56. package/src/admin/domains/infrastructure.ts +19 -13
  57. package/src/admin/domains/lazy-page.ts +6 -0
  58. package/src/admin/domains/observability.ts +6 -5
  59. package/src/admin/domains/operations.ts +22 -7
  60. package/src/admin/domains/security.ts +16 -15
  61. package/src/admin/edge/EdgeHelp.tsx +10 -0
  62. package/src/admin/edge/ServingReadiness.tsx +54 -0
  63. package/src/admin/edge/VhostDetail.tsx +145 -139
  64. package/src/admin/edge/VhostWizard.tsx +204 -115
  65. package/src/admin/edge/VhostsPage.tsx +27 -28
  66. package/src/admin/edge/api.ts +24 -10
  67. package/src/admin/edge/models.ts +4 -4
  68. package/src/admin/edge/serving-data.ts +78 -0
  69. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  70. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  71. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  72. package/src/admin/fleet/FleetHelp.tsx +7 -0
  73. package/src/admin/fleet/FleetOverview.tsx +150 -0
  74. package/src/admin/fleet/FleetPage.tsx +11 -0
  75. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  76. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  77. package/src/admin/fleet/access.ts +22 -0
  78. package/src/admin/fleet/api.ts +77 -0
  79. package/src/admin/fleet/data.ts +148 -0
  80. package/src/admin/fleet/index.ts +8 -0
  81. package/src/admin/fleet/operations.ts +179 -0
  82. package/src/admin/fleet/standalone-data.ts +87 -0
  83. package/src/admin/fleet/types.ts +208 -0
  84. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  85. package/src/admin/geolocation/data.ts +138 -0
  86. package/src/admin/geolocation/index.ts +3 -0
  87. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  88. package/src/admin/index.ts +8 -0
  89. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  90. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  91. package/src/admin/maintenance/data.ts +486 -0
  92. package/src/admin/maintenance/index.ts +3 -0
  93. package/src/admin/maintenance/operations.ts +208 -0
  94. package/src/admin/public/infrastructure.ts +1 -0
  95. package/src/admin/public/operations.ts +3 -0
  96. package/src/admin/registry.ts +6 -2
  97. package/src/admin/settings/SettingDetail.tsx +7 -2
  98. package/src/admin/settings/SettingEditor.tsx +2 -1
  99. package/src/admin/settings/SettingsPage.tsx +3 -1
  100. package/src/admin/settings/model.ts +1 -0
  101. package/src/admin/signin/SigninPage.tsx +17 -3
  102. package/src/admin/signin/api.ts +2 -0
  103. package/src/charts/MetricsChartView.tsx +4 -11
  104. package/src/charts/MetricsMiniWidget.tsx +3 -15
  105. package/src/client/client.ts +6 -0
  106. package/src/client/deployments-mock.ts +375 -0
  107. package/src/client/domain-operations-mock.ts +37 -0
  108. package/src/client/geo-provider-mock.ts +82 -0
  109. package/src/client/maintenance-mock.ts +285 -0
  110. package/src/client/mock.ts +52 -10
  111. package/src/client/operational-fleet-mock.ts +279 -0
  112. package/src/client/operations-mock.ts +85 -0
  113. package/src/client/serving-mock.ts +22 -0
  114. package/src/styles/components/admin-deployments.css +35 -0
  115. package/src/styles/components/admin-domains.css +12 -0
  116. package/src/styles/components/admin-edge.css +125 -1
  117. package/src/styles/components/admin-geolocation.css +40 -0
  118. package/src/styles/components/admin-maintenance.css +127 -0
  119. package/src/styles/components/admin-operational-fleet.css +38 -0
  120. package/src/styles/components/admin-operations.css +9 -0
  121. package/src/styles/components/admin-signin.css +2 -0
  122. package/src/styles/components/combobox.css +12 -2
  123. package/src/styles/components/fleet-page.css +9 -0
  124. package/src/styles/index.css +7 -0
  125. package/src/ui/ComboBox.tsx +39 -8
  126. package/src/ui/FilterBar.tsx +3 -5
  127. package/src/ui/ModelTable.tsx +2 -8
  128. package/src/ui/Popover.tsx +15 -7
  129. package/src/ui/date/TimePicker.tsx +4 -14
  130. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  131. package/src/ui/schema-form-core.tsx +3 -5
@@ -0,0 +1,40 @@
1
+ .geo-admin { display: grid; max-width: 880px; margin-inline: auto; gap: 20px; min-width: 0; overflow-wrap: anywhere; container-type: inline-size; }
2
+ .geo-admin .page-header { display: flex; justify-content: space-between; margin: 0; align-items: start; flex-wrap: wrap; gap: 12px; }
3
+ .geo-admin h1 { font-size: 26px; font-weight: 750; line-height: 1.2; letter-spacing: -.025em; color: var(--ink-strong); margin: 4px 0 8px; }
4
+ .geo-admin h2 { font-size: 16px; font-weight: 700; line-height: 1.35; color: var(--ink-strong); margin: 0; }
5
+ .geo-admin-setup, .geo-admin-status { min-width: 0; }
6
+ .geo-admin-form { padding: 24px; }
7
+ .geo-admin-provider-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
8
+ .geo-admin-fields { border: 0; padding: 0; margin: 0; min-width: 0; display: grid; gap: 22px; }
9
+ .geo-admin .field { display: grid; gap: 8px; min-width: 0; }
10
+ .geo-admin .field-label, .geo-admin-key-heading, .geo-admin-sharing { display: flex; gap: 8px; align-items: center; }
11
+ .geo-admin .input { width: 100%; box-sizing: border-box; min-width: 0; }
12
+ .geo-admin [hidden] { display: none !important; }
13
+ .geo-admin-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
14
+ .geo-admin-actions .btn { white-space: normal; }
15
+ .geo-admin-key { display: grid; gap: 12px; padding: 16px; background: var(--surface2); border: 1px solid var(--line); border-radius: 8px; min-width: 0; }
16
+ .geo-admin-key p, .geo-admin-result p, .geo-admin-status p, .geo-admin-notice p { margin: 0; }
17
+ .geo-admin-provider-list { padding: 0; margin: 0; list-style: none; display: grid; gap: 8px; }
18
+ .geo-admin-provider-list li { display: flex; justify-content: space-between; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; }
19
+ .geo-admin-provider-list small { display: block; }
20
+ .geo-admin-provider-list .btn { padding: 5px 8px; }
21
+ .geo-admin-context, .geo-admin-result { margin: 0 24px 20px; }
22
+ .geo-admin-result { display: grid; gap: 8px; }
23
+ .geo-admin-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 16px 24px; border-top: 1px solid var(--line); }
24
+ .geo-admin-status { padding: 18px 24px; display: grid; gap: 10px; }
25
+ .geo-admin-status-heading, .geo-admin-activation { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
26
+ .geo-admin-status-heading .btn { font-size: 12px; padding: 5px 8px; }
27
+ .geo-admin-notice { border: 1px solid var(--line); background: var(--accent-soft); border-radius: 8px; padding: 12px; margin: 0; display: grid; gap: 6px; }
28
+ .geo-admin-dialog dl { display: grid; gap: 14px; }
29
+ .geo-admin-dialog dt { color: var(--mute); font-size: 12px; margin-bottom: 4px; }
30
+ .geo-admin-dialog dd { margin: 0; overflow-wrap: anywhere; }
31
+ @container (max-width: 420px) {
32
+ .geo-admin-provider-fields { grid-template-columns: 1fr; }
33
+ .geo-admin-form, .geo-admin-status { padding: 18px; }
34
+ .geo-admin-key { padding: 12px; }
35
+ .geo-admin-provider-list li { flex-wrap: wrap; }
36
+ .geo-admin-actions { gap: 6px; }
37
+ .geo-admin-footer { padding: 16px 18px; }
38
+ .geo-admin-context, .geo-admin-result { margin-inline: 18px; }
39
+ .geo-admin-sharing { align-items: start; }
40
+ }
@@ -0,0 +1,127 @@
1
+ .admin-maintenance {
2
+ display: grid;
3
+ gap: 1.25rem;
4
+ max-width: 80rem;
5
+ margin: auto;
6
+ padding: 0;
7
+ overflow-wrap: anywhere;
8
+ }
9
+ .admin-maintenance header,
10
+ .maintenance-resource {
11
+ display: flex;
12
+ align-items: flex-start;
13
+ justify-content: space-between;
14
+ gap: 1rem;
15
+ }
16
+ .admin-maintenance section {
17
+ background: var(--surface);
18
+ border: 1px solid var(--line);
19
+ border-radius: var(--radius-lg, 0.75rem);
20
+ padding: 1.25rem;
21
+ min-width: 0;
22
+ }
23
+ .admin-maintenance h1,
24
+ .admin-maintenance h2,
25
+ .admin-maintenance h3 {
26
+ margin: 0 0 0.5rem;
27
+ }
28
+ .maintenance-resource {
29
+ padding: 1rem 0;
30
+ border-bottom: 1px solid var(--line);
31
+ }
32
+ .maintenance-resource:last-child {
33
+ border-bottom: 0;
34
+ }
35
+ .admin-maintenance p {
36
+ margin: 0.5rem 0;
37
+ }
38
+ .admin-maintenance dl {
39
+ display: grid;
40
+ grid-template-columns: minmax(7rem, auto) 1fr;
41
+ gap: 0.5rem 1rem;
42
+ }
43
+ .admin-maintenance dd {
44
+ margin: 0;
45
+ }
46
+ .maintenance-warning {
47
+ color: var(--warn);
48
+ }
49
+ .maintenance-mode {
50
+ color: var(--mute);
51
+ }
52
+ .maintenance-dialog {
53
+ color: var(--ink);
54
+ background: var(--surface);
55
+ border: 1px solid var(--line);
56
+ border-radius: var(--radius-lg, 0.75rem);
57
+ max-width: min(38rem, calc(100vw - 2rem));
58
+ max-height: calc(100vh - 2rem);
59
+ overflow: auto;
60
+ }
61
+ .maintenance-dialog::backdrop {
62
+ background: rgb(0 0 0 / 0.6);
63
+ }
64
+ .maintenance-dialog-body {
65
+ padding: 1.25rem;
66
+ display: grid;
67
+ gap: 1rem;
68
+ }
69
+ .maintenance-dialog h2,
70
+ .maintenance-dialog p {
71
+ margin: 0;
72
+ }
73
+ .maintenance-dialog fieldset {
74
+ display: grid;
75
+ gap: 0.75rem;
76
+ }
77
+ .maintenance-confirm-label {
78
+ display: grid;
79
+ gap: 0.5rem;
80
+ }
81
+ .maintenance-confirm-label input {
82
+ width: 100%;
83
+ min-width: 0;
84
+ background: var(--field-bg, var(--surface));
85
+ color: var(--ink);
86
+ border: 1px solid var(--line);
87
+ padding: 0.65rem;
88
+ border-radius: 0.35rem;
89
+ }
90
+ .maintenance-actions {
91
+ display: flex;
92
+ justify-content: flex-end;
93
+ gap: 0.75rem;
94
+ }
95
+ @media (max-width: 600px) {
96
+ .admin-maintenance {
97
+ padding: 0;
98
+ }
99
+ .admin-maintenance header,
100
+ .maintenance-resource {
101
+ flex-direction: column;
102
+ }
103
+ .admin-maintenance section {
104
+ padding: 0.8rem;
105
+ }
106
+ .maintenance-actions {
107
+ flex-wrap: wrap;
108
+ }
109
+ }
110
+
111
+ .admin-maintenance h1{font-size:26px;font-weight:750;line-height:1.2;letter-spacing:-.025em;color:var(--ink-strong)}.admin-maintenance h2,.maintenance-dialog h2{font-size:18px;font-weight:700;line-height:1.35;color:var(--ink-strong)}.admin-maintenance h3{font-size:16px;font-weight:650;line-height:1.4;color:var(--ink-strong)}
112
+
113
+ .maintenance-resource>div{min-width:0}.maintenance-resource>button{flex-shrink:0}.maintenance-resource-status{color:var(--mute)}.maintenance-details{margin-block:.65rem}.maintenance-details>summary{font-size:13px;font-weight:600;cursor:pointer;color:var(--mute)}.maintenance-details[open]>summary{margin-bottom:.65rem}.admin-maintenance .maintenance-framework-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem}.maintenance-framework-summary>div{min-width:0}.maintenance-framework-summary dt{font-size:12px;color:var(--mute);margin-bottom:4px}.maintenance-framework-summary dd{font-size:16px;font-weight:600;overflow-wrap:anywhere}.admin-maintenance .maintenance-resource h3{margin-bottom:.25rem}.admin-maintenance .maintenance-resource p{margin:.25rem 0}.admin-maintenance .maintenance-details p{margin:.5rem 0}@media(max-width:600px){.admin-maintenance .maintenance-framework-summary{grid-template-columns:1fr}.maintenance-resource>button{align-self:flex-start}}
114
+
115
+ .admin-maintenance { gap: 24px; }
116
+ .admin-maintenance header > div > p:last-child { color: var(--mute); }
117
+ .admin-maintenance section.maintenance-scan { padding: 0; background: transparent; border: 0; }
118
+ .maintenance-scan .ops-details > summary { padding: 0; }
119
+ .maintenance-dialog { margin: auto; padding: 0; width: min(38rem, calc(100vw - 2rem)); }
120
+ .maintenance-dialog fieldset { border: 1px solid var(--line); border-radius: 8px; padding: 12px 16px; }
121
+ .maintenance-dialog legend { padding-inline: 4px; font-weight: 600; }
122
+ .maintenance-dialog-body { padding: 24px; gap: 20px; }
123
+
124
+ .maintenance-dialog input[type="radio"] { margin-right: 6px; }
125
+
126
+ /* Maintenance-only dialogs and compact progress. No shared Dashboard rules. */
127
+ .maintenance-dialog-body{max-height:calc(100dvh - 2rem);display:flex;flex-direction:column;overflow:hidden;box-sizing:border-box}.maintenance-dialog-scroll{min-height:0;overflow:auto;display:grid;gap:16px;padding:2px}.maintenance-dialog .maintenance-actions{flex-shrink:0;padding-top:12px;align-items:center}.maintenance-dialog .maintenance-progress-facts{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px 16px;margin:0}.maintenance-progress-facts dt{color:var(--mute)}.maintenance-progress-facts dd{margin:0;overflow-wrap:anywhere}.maintenance-attempt-row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:12px 0;border-bottom:1px solid var(--line)}.maintenance-attempt-row:last-of-type{border-bottom:0}.admin-maintenance .maintenance-actions{justify-content:flex-start;margin-top:16px;flex-wrap:wrap}.maintenance-dialog .maintenance-dialog-scroll>p{margin:0}.maintenance-dialog .maintenance-dialog-scroll fieldset{margin:0}.maintenance-dialog .maintenance-dialog-scroll .field{margin:0}.maintenance-attempt-row>div{min-width:0}@media(max-width:600px){.maintenance-dialog-body{padding:20px;gap:16px}.maintenance-attempt-row{align-items:flex-start;flex-direction:column}.maintenance-dialog .maintenance-actions{gap:8px}.maintenance-dialog .maintenance-actions .btn{max-width:100%;white-space:normal}}
@@ -0,0 +1,38 @@
1
+ .operational-fleet{display:grid;gap:16px;min-width:0;container-type:inline-size;overflow-wrap:anywhere}.fleet-heading,.fleet-buttons{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.fleet-heading h2{margin:0}.fleet-heading p{margin:4px 0 0}.fleet-evidence-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.fleet-evidence{min-width:0;overflow:hidden}.fleet-evidence h3{padding:14px 16px;margin:0;border-bottom:1px solid var(--line)}.fleet-evidence-row{display:grid;gap:6px;padding:12px 16px;border-bottom:1px solid var(--line);min-width:0}.fleet-evidence-row:last-child{border-bottom:0}.fleet-evidence-row>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.fleet-evidence-row p{margin:2px 0 5px}.fleet-source-status{font-size:11px;letter-spacing:.03em;text-transform:uppercase;color:var(--mute)}.fleet-status-healthy{color:var(--ok)}.fleet-status-unhealthy{color:var(--bad)}.fleet-status-degraded,.fleet-status-stale{color:var(--warn)}.fleet-availability{border-left:3px solid var(--mute)}.fleet-availability-ok{border-left-color:var(--ok)}.fleet-availability-down{border-left-color:var(--bad)}.fleet-capacity-facts summary{cursor:pointer;font-weight:600}.fleet-fact-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.fleet-member{display:block;margin:4px 0;color:var(--mute)}.fleet-scale{display:flex;flex-direction:column;max-height:calc(100dvh - 80px);min-height:0;overflow-wrap:anywhere}.fleet-scale-body{overflow:auto;min-height:0;padding-right:4px}.fleet-scale h3{margin:20px 0 8px}.fleet-scale-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.fleet-scale-fields .field{min-width:0}.fleet-node{display:flex;gap:12px;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--line)}.fleet-node small{display:block}.fleet-plan-steps{padding-left:24px}.fleet-plan-steps li{margin-bottom:14px}.fleet-progress-step{padding:10px 0;border-bottom:1px solid var(--line)}.fleet-scale .modal-actions{flex-shrink:0}.fleet-scale fieldset{border:0;margin:0;padding:0;min-width:0}@container (max-width:850px){.fleet-evidence-grid{grid-template-columns:1fr}.fleet-fact-grid{grid-template-columns:1fr}}@media(max-width:560px){.fleet-scale-fields{grid-template-columns:1fr}.fleet-node{align-items:flex-start;flex-direction:column}.fleet-scale{max-height:calc(100dvh - 48px)}}
2
+
3
+ .fleet-page>.page-header{display:flex;justify-content:space-between;align-items:start;margin-bottom:18px}.fleet-page h1{font-size:26px;font-weight:750;line-height:1.2;letter-spacing:-.025em;color:var(--ink-strong);margin:0}.fleet-heading h2{font-size:20px;font-weight:700;line-height:1.3;color:var(--ink-strong)}.fleet-evidence h3{font-size:16px;font-weight:700;color:var(--ink-strong)}.fleet-availability>strong{font-size:18px;line-height:1.35;font-weight:700;color:var(--ink-strong)}.fleet-scale h3{font-size:17px;font-weight:700;color:var(--ink-strong)}.fleet-scale h4,.fleet-fact-grid h4{font-size:15px;font-weight:650;color:var(--ink-strong)}.fleet-scale .fleet-scale-disclosure{border-top:1px solid var(--line);padding:12px 0}.fleet-scale-disclosure>summary{font-size:15px;font-weight:650;cursor:pointer;color:var(--ink-strong)}.fleet-scale-disclosure[open]>summary{margin-bottom:14px}.fleet-scale fieldset>h3:first-child{margin-top:12px}.fleet-scale fieldset>p{margin-bottom:12px}.fleet-scale fieldset>.fleet-scale-fields{margin-bottom:14px}
4
+
5
+ .fleet-evidence-grid { grid-template-columns: minmax(0, 1fr); gap: 24px; }
6
+ .fleet-evidence { border: 0; border-radius: 0; box-shadow: none; background: transparent; }
7
+ .fleet-evidence h3 { padding: 0 0 10px; font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--mute); }
8
+ .fleet-evidence-row { grid-template-columns: minmax(130px, .7fr) minmax(0, 2fr) auto 62px; align-items: center; gap: 16px; padding: 17px 0; }
9
+ .fleet-row-value { min-width: 0; color: var(--mute); }
10
+ .fleet-detail-button { border: 0; padding: 4px 0; background: transparent; color: var(--accent); cursor: pointer; text-align: right; }
11
+ .fleet-source-detail > p { margin: 14px 0; }
12
+ .operational-fleet { gap: 24px; }
13
+ @container (max-width: 600px) { .fleet-evidence-row { grid-template-columns: minmax(0, 1fr) auto; gap: 6px 12px; } .fleet-row-value { grid-row: 2; } .fleet-source-status { grid-column: 2; grid-row: 1; } .fleet-detail-button { grid-column: 2; grid-row: 2; } }
14
+
15
+ .fleet-capacity-tab { padding-top: 4px; }
16
+ .fleet-scale-body .fleet-buttons { margin-block: 10px 18px; }
17
+ .fleet-scale-body > fieldset { margin-block: 12px 20px; }
18
+ .fleet-scale-body > section { margin-block: 16px; }
19
+ .fleet-scale .modal-actions { padding-top: 16px; border-top: 1px solid var(--line); }
20
+ .fleet-scale .input { width: 100%; min-width: 0; }
21
+
22
+ .fleet-evidence.panel { padding: 0; border: 0; border-radius: 0; box-shadow: none; background: transparent; }
23
+ .fleet-evidence-row { padding-block: 14px; }
24
+
25
+ .fleet-heading h2 { font-size: 26px; letter-spacing: -.025em; }
26
+
27
+ .fleet-heading h1 { margin: 0; font-size: 26px; line-height: 1.3; font-weight: 700; letter-spacing: -.025em; color: var(--ink-strong); }
28
+
29
+ .fleet-capacity-tab .panel > p { margin: 8px 0 16px; }
30
+ .fleet-scale-fields .field { gap: 8px; }
31
+
32
+ .fleet-capacity-dialog { display: flex; flex-direction: column; max-height: calc(100dvh - 64px); min-height: 0; overflow-wrap: anywhere; }
33
+ .fleet-capacity-body { overflow: auto; min-height: 0; }
34
+ .fleet-capacity-body p { margin: 8px 0; line-height: 1.5; }
35
+ .fleet-capacity-body h4 { margin: 0 0 8px; }
36
+ .fleet-capacity-body > .dim { margin: 0 0 18px; }
37
+ .fleet-capacity-dialog .modal-actions { flex: none; padding-top: 16px; border-top: 1px solid var(--line); }
38
+ @media (max-width: 560px) { .fleet-capacity-dialog .fleet-fact-grid { grid-template-columns: minmax(0, 1fr); } }
@@ -0,0 +1,9 @@
1
+ .ops-help { display: inline-flex; align-items: center; justify-content: center; padding: 3px; border: 0; background: transparent; color: var(--mute); cursor: pointer; vertical-align: middle; font-size: 14px; line-height: 1; border-radius: 4px; }
2
+ .ops-help:hover, .ops-help[aria-expanded="true"] { color: var(--accent); background: var(--accent-soft); }
3
+ .ops-help-content { width: min(340px, calc(100vw - 32px)); padding: 14px 16px; font-size: 13px; line-height: 1.6; color: var(--ink); overflow-wrap: anywhere; }
4
+ .ops-help-content p { margin: 0 0 8px; }
5
+ .ops-help-content p:last-child { margin-bottom: 0; }
6
+ .ops-details > summary { cursor: pointer; font-size: 13px; font-weight: 600; color: var(--mute); padding-block: 6px; }
7
+ .ops-details[open] > summary { margin-bottom: 10px; }
8
+ .ops-cell { display: grid; gap: 3px; min-width: 0; }
9
+ .ops-cell small { color: var(--mute); font-size: 12px; }
@@ -52,6 +52,8 @@ textarea.signin-code { resize: vertical; }
52
52
  .signin-note i { flex: none; color: var(--accent); }
53
53
  .signin-note.is-warning { border-color: color-mix(in srgb, var(--warn) 35%, var(--line)); background: var(--warn-soft); }
54
54
  .signin-note.is-warning i { color: var(--warn); }
55
+ .signin-note.is-danger { border-color: color-mix(in srgb, var(--bad) 35%, var(--line)); background: var(--bad-soft); }
56
+ .signin-note.is-danger i { color: var(--bad); }
55
57
  .signin-provider-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
56
58
 
57
59
  @media (max-width: 1100px) { .signin-groups { grid-template-columns: 1fr; } }
@@ -1,7 +1,6 @@
1
1
  /* ComboBox — styles for portal-mojo <ComboBox> (packages/portal-mojo/src/ui/ComboBox.tsx).
2
2
  Tokens only; reads correctly under data-theme="light" AND "dark".
3
- NOT imported yet — the orchestrator adds `@import "./theme/combobox.css";`
4
- to theme.css at merge. */
3
+ */
5
4
 
6
5
  .combo { position: relative; width: 100%; }
7
6
  .combo-input-wrap { position: relative; }
@@ -30,6 +29,8 @@
30
29
  /* Keyboard highlight (.combo-hot) and hover share one look — there is only
31
30
  one "pointed at" state. */
32
31
  .combo-option:hover, .combo-option.combo-hot { background: var(--surface2); color: var(--ink-strong); }
32
+ .combo-option[aria-disabled="true"] { opacity: .5; cursor: not-allowed; background: transparent; }
33
+ .combo-input[aria-invalid="true"] { border-color: var(--bad); }
33
34
  .combo-option-main { flex: 1; min-width: 0; }
34
35
  .combo-option-label { font-weight: 550; }
35
36
  .combo-option-desc { font-size: 12px; color: var(--mute); margin-top: 1px; }
@@ -40,3 +41,12 @@
40
41
  /* The <mark> around the matched substring — override the UA's yellow/black
41
42
  so it stays legible on both themes. */
42
43
  .combo-mark { background: var(--warn-soft); color: inherit; border-radius: 3px; padding: 0 1px; }
44
+
45
+ .page-size .combo { width: 78px; }
46
+ .combo { min-width: 0; }
47
+ .combo-input { width: 100%; min-width: 0; }
48
+ .combo-pop { max-height: min(300px, calc(100dvh - 16px)); min-width: min(260px, calc(100vw - 16px)); }
49
+
50
+ .page-size .combo-input { min-height: 30px; padding: 5px 30px 5px 9px; font-size: 12px; }
51
+ .combo.gran-select { width: 110px; }
52
+ .gran-select .combo-input { min-height: 30px; padding: 5px 30px 5px 9px; font-size: 12px; }
@@ -0,0 +1,9 @@
1
+ /* Standalone Fleet only. Dashboard FleetOverview retains its own presentation. */
2
+ .fleet-page .fp-workspace{display:flex;flex-direction:column;gap:20px;min-width:0}.fleet-page .fp-header{display:flex;align-items:center;justify-content:space-between;gap:24px}.fleet-page .fp-header h1{margin:3px 0 4px}.fleet-page .fp-header p{margin:0}.fleet-page .fp-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px}.fleet-page .fp-health{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:20px 24px;border-left:3px solid var(--ok)}.fleet-page .fp-health-down{border-left-color:var(--bad)}.fleet-page .fp-health-unknown{border-left-color:var(--warn)}.fleet-page .fp-health p{margin:6px 0 0}.fleet-page .fp-evidence-grid{display:grid;grid-template-columns:1fr 1fr;align-items:start;gap:0}.fleet-page .fp-section{overflow:hidden;padding:8px 18px}.fleet-page .fp-section .mojo-tab-panel{padding:4px 0}.fleet-page .fp-section .mojo-tab-list{margin:0 2px}.fleet-page .fp-section h2{font-size:14px;margin:0;padding:18px 20px;border-bottom:1px solid var(--line)}.fleet-page .fp-source{padding:14px 12px;border-bottom:1px solid var(--line);min-width:0}.fleet-page .fp-source:nth-last-child(-n+2){border-bottom:0}.fleet-page .fp-source:last-child{border-bottom:0}.fleet-page .fp-source p{margin:5px 0 3px}.fleet-page .fp-row,.fleet-standalone-dialog .fp-row{display:flex;align-items:center;justify-content:space-between;gap:12px}.fleet-page .fp-source-meta{font-size:11px;color:var(--mute);gap:8px;flex-wrap:wrap}.fleet-page .fp-state,.fleet-standalone-dialog .fp-state{font-size:12px;font-weight:600;text-transform:capitalize}.fleet-page .fp-capacity-strip{display:grid;grid-template-columns:repeat(3,1fr) 1.6fr;padding:18px 24px;gap:18px}.fleet-page .fp-capacity-strip>div{display:flex;flex-direction:column;gap:4px}.fleet-page .fp-capacity-strip strong{font-size:23px;line-height:1.3}.fleet-page .fp-capacity-context{justify-content:center;align-items:flex-end}.fleet-page .fp-operation{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px 24px}.fleet-page .fp-operation p{margin:6px 0 0}.fleet-page .fp-restricted{padding:16px 20px;margin:0}.fleet-page .fp-access-note{font-size:12px;margin:0}.fleet-standalone-dialog{display:flex;flex-direction:column;max-height:min(850px,calc(100dvh - 60px));min-width:0}.fleet-standalone-dialog .fleet-standalone-body{overflow:auto;min-height:0;overscroll-behavior:contain;padding:2px 2px 12px}.fleet-standalone-dialog .modal-message{flex-shrink:0}.fleet-standalone-dialog .modal-actions{flex-shrink:0}.fleet-standalone-dialog fieldset{min-width:0;border:0;padding:0;margin:0}.fleet-standalone-dialog .fp-stack{display:flex;flex-direction:column;gap:16px}.fleet-standalone-dialog .fp-stack>p,.fleet-standalone-dialog .fp-resource p,.fleet-standalone-dialog .fp-resource h3{margin:0}.fleet-standalone-dialog .mojo-tab-panel{padding-top:20px}.fleet-standalone-dialog .fp-resource{border:1px solid var(--line);border-radius:10px;padding:18px;overflow-wrap:anywhere}.fleet-standalone-dialog .fp-resource>p+small,.fleet-standalone-dialog .fp-resource>p+p{display:block;margin-top:8px}.fleet-standalone-dialog .fp-resource h3{font-size:14px}.fleet-standalone-dialog .fp-member{display:flex;flex-direction:column;gap:6px;padding:14px 0;border-bottom:1px solid var(--line)}.fleet-standalone-dialog .fp-member:last-child{border-bottom:0}.fleet-standalone-dialog .fp-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}.fleet-standalone-dialog .fp-check{display:flex;align-items:flex-start;gap:9px;font-size:13px;line-height:1.6}.fleet-standalone-dialog .fp-check input{flex:none;margin-top:4px}.fleet-standalone-dialog .fp-toolbar{margin:0 0 18px;flex-wrap:wrap}.fleet-standalone-dialog .fp-empty{margin:20px 0 4px}.fleet-standalone-dialog .fp-plan{display:flex;flex-direction:column;gap:14px;padding-left:24px;margin:0}.fleet-standalone-dialog .fp-plan p{margin:6px 0 0}.fleet-standalone-dialog pre{white-space:pre-wrap;font-family:var(--font-mono,monospace);font-size:12px}.fleet-standalone-dialog .field-help{line-height:1.6}.fleet-standalone-dialog .input{width:100%;min-width:0}.fleet-standalone-dialog .modal-title{overflow-wrap:anywhere}.fleet-standalone-dialog .fp-member .fp-fields{margin-top:8px}
3
+ @media(max-width:700px){.fleet-page .fp-header{align-items:flex-start;flex-direction:column;gap:16px}.fleet-page .fp-actions{justify-content:flex-start}.fleet-page .fp-evidence-grid{grid-template-columns:1fr}.fleet-page .fp-capacity-strip{grid-template-columns:repeat(3,1fr);padding:16px;gap:16px}.fleet-page .fp-capacity-context{grid-column:1/-1;align-items:flex-start}.fleet-page .fp-health,.fleet-page .fp-operation{padding:18px;align-items:flex-start}.fleet-page .fp-operation{flex-direction:column}.fleet-standalone-dialog{max-height:calc(100dvh - 32px)}.fleet-standalone-dialog .fp-fields{grid-template-columns:1fr}.fleet-standalone-dialog .fp-resource>.fp-row{align-items:flex-start;flex-wrap:wrap}.fleet-standalone-dialog .fp-resource{padding:14px}.fleet-page .fp-source{padding:12px 4px}.fleet-page .fp-source:nth-last-child(2){border-bottom:1px solid var(--line)}.fleet-page .fp-section{padding:8px 14px}}
4
+ /* Keep every inspection tab visible within a narrow native dialog. */
5
+ @media(max-width:700px){.fleet-standalone-dialog .mojo-tab-list{flex-wrap:wrap;overflow:visible}}
6
+
7
+ /* Destination names are actions; keep evidence-only labels visually distinct. */
8
+ .fleet-page .fp-source strong a{color:var(--accent);text-decoration:underline;text-underline-offset:3px}
9
+ .fleet-page .fp-source strong a:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:2px}
@@ -24,6 +24,13 @@
24
24
  @import "./components/admin-assistant.css" layer(portal-mojo);
25
25
  @import "./components/admin-security.css" layer(portal-mojo);
26
26
  @import "./components/admin-dashboard.css" layer(portal-mojo);
27
+ @import "./components/admin-operational-fleet.css" layer(portal-mojo);
28
+ @import "./components/fleet-page.css" layer(portal-mojo);
29
+ @import "./components/admin-maintenance.css" layer(portal-mojo);
30
+ @import "./components/admin-deployments.css" layer(portal-mojo);
31
+ @import "./components/admin-domains.css" layer(portal-mojo);
32
+ @import "./components/admin-operations.css" layer(portal-mojo);
33
+ @import "./components/admin-geolocation.css" layer(portal-mojo);
27
34
  @import "./components/admin-cloudwatch.css" layer(portal-mojo);
28
35
  @import "./components/admin-monitoring.css" layer(portal-mojo);
29
36
  @import "./components/admin-bouncer.css" layer(portal-mojo);
@@ -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