portal-mojo 0.1.0
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/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// TagInput — chip-style free-text tags with a full keyboard flow, ported from
|
|
2
|
+
// web-mojo's TagInputView (src/core/forms/inputs/TagInput.js).
|
|
3
|
+
//
|
|
4
|
+
// THE VALUE IS A CSV STRING, both directions. django-mojo models split the
|
|
5
|
+
// stored string on the separator, so `onChange` hands back the joined string
|
|
6
|
+
// first and the array second — a component that emitted only an array would
|
|
7
|
+
// quietly change the wire shape. Two invariants keep that string honest:
|
|
8
|
+
// 1. no committed tag may contain the separator (every commit is split first);
|
|
9
|
+
// 2. the separator must be exactly ONE character (anything else falls back to
|
|
10
|
+
// "," with a console.warn — the house rule for unknown option values).
|
|
11
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
12
|
+
|
|
13
|
+
export interface TagInputProps {
|
|
14
|
+
/** Renders a hidden input under this name, for native form posts. */
|
|
15
|
+
name?: string;
|
|
16
|
+
/** CSV string (the wire shape) or an array — both parse to the same tags. */
|
|
17
|
+
value?: string | string[];
|
|
18
|
+
/** Fires on every commit/removal: the CSV string FIRST, then the array. */
|
|
19
|
+
onChange?: (value: string, tags: string[]) => void;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
/** Cap on interactive adds (default 50); also the counter's denominator. */
|
|
22
|
+
maxTags?: number;
|
|
23
|
+
allowDuplicates?: boolean;
|
|
24
|
+
/** One character; joins the CSV and splits incoming values (default ","). */
|
|
25
|
+
separator?: string;
|
|
26
|
+
trimTags?: boolean;
|
|
27
|
+
minLength?: number;
|
|
28
|
+
maxLength?: number;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
/** Chips only: no input, no remove icons. */
|
|
31
|
+
readonly?: boolean;
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// One warn per bad separator — a mis-sized separator is a config bug, not a
|
|
36
|
+
// render event (same policy as SchemaForm's unknown select values).
|
|
37
|
+
const warnedSeparators = new Set<string>();
|
|
38
|
+
|
|
39
|
+
function resolveSeparator(separator: string): string {
|
|
40
|
+
if (separator.length === 1) return separator;
|
|
41
|
+
if (!warnedSeparators.has(separator)) {
|
|
42
|
+
warnedSeparators.add(separator);
|
|
43
|
+
console.warn(`TagInput: separator ${JSON.stringify(separator)} must be exactly one character — falling back to ","`);
|
|
44
|
+
}
|
|
45
|
+
return ',';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* String/array → tag list: split on the separator, trim, drop empties, and
|
|
50
|
+
* drop duplicates unless they are allowed. Array entries are split too, so an
|
|
51
|
+
* array can never smuggle a separator into a tag and break the CSV round-trip.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately does NOT apply minLength/maxLength/maxTags: those cap what a
|
|
54
|
+
* user may ADD. Silently dropping stored values would lose backend data on the
|
|
55
|
+
* next save (web-mojo's setTags did exactly that).
|
|
56
|
+
*/
|
|
57
|
+
function parseTags(
|
|
58
|
+
value: string | string[] | null | undefined,
|
|
59
|
+
separator: string,
|
|
60
|
+
trimTags: boolean,
|
|
61
|
+
allowDuplicates: boolean,
|
|
62
|
+
): string[] {
|
|
63
|
+
const pieces = Array.isArray(value)
|
|
64
|
+
? value.flatMap((v) => String(v ?? '').split(separator))
|
|
65
|
+
: String(value ?? '').split(separator);
|
|
66
|
+
const out: string[] = [];
|
|
67
|
+
for (const piece of pieces) {
|
|
68
|
+
const tag = trimTags ? piece.trim() : piece;
|
|
69
|
+
if (tag === '' || tag.trim() === '') continue;
|
|
70
|
+
if (!allowDuplicates && out.includes(tag)) continue;
|
|
71
|
+
out.push(tag);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function TagInput({
|
|
77
|
+
name,
|
|
78
|
+
value = '',
|
|
79
|
+
onChange,
|
|
80
|
+
placeholder = 'Add tags...',
|
|
81
|
+
maxTags = 50,
|
|
82
|
+
allowDuplicates = false,
|
|
83
|
+
separator = ',',
|
|
84
|
+
trimTags = true,
|
|
85
|
+
minLength = 1,
|
|
86
|
+
maxLength = 50,
|
|
87
|
+
disabled = false,
|
|
88
|
+
readonly = false,
|
|
89
|
+
className,
|
|
90
|
+
}: TagInputProps) {
|
|
91
|
+
const sep = resolveSeparator(separator);
|
|
92
|
+
const tags = useMemo(
|
|
93
|
+
() => parseTags(value, sep, trimTags, allowDuplicates),
|
|
94
|
+
[value, sep, trimTags, allowDuplicates],
|
|
95
|
+
);
|
|
96
|
+
const editable = !disabled && !readonly;
|
|
97
|
+
|
|
98
|
+
// Draft text is the ONLY local value state — it is not a tag yet. The ref
|
|
99
|
+
// mirrors it synchronously so blur can't commit a stale draft after a Tab
|
|
100
|
+
// (Tab clears the draft, then the browser moves focus in the same tick).
|
|
101
|
+
const [draft, setDraft] = useState('');
|
|
102
|
+
const draftRef = useRef('');
|
|
103
|
+
const setDraftValue = (next: string) => {
|
|
104
|
+
draftRef.current = next;
|
|
105
|
+
setDraft(next);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// A rejected add flashes here for ~3s. Boxed in an object ON PURPOSE: a
|
|
109
|
+
// bare string would make a repeat of the same message a no-op setState, so
|
|
110
|
+
// the second rejection would inherit the first one's dying timer.
|
|
111
|
+
const [error, setError] = useState<{ text: string } | null>(null);
|
|
112
|
+
const flashError = (text: string) => setError({ text });
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!error) return;
|
|
115
|
+
const t = setTimeout(() => setError(null), 3000);
|
|
116
|
+
return () => clearTimeout(t);
|
|
117
|
+
}, [error]);
|
|
118
|
+
|
|
119
|
+
// Controlled: with no onChange the field can only ever show what the parent
|
|
120
|
+
// already holds, so adds would silently vanish. Say so once per instance
|
|
121
|
+
// rather than rendering a dead control (house rule: never fail silently).
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!onChange && !readonly && !disabled) {
|
|
124
|
+
console.warn('TagInput: no onChange handler — value is controlled, so added/removed tags will not stick. Pass onChange, or mark the field readonly.');
|
|
125
|
+
}
|
|
126
|
+
// Mount-only: this is a config check, not a render event.
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
130
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
131
|
+
const chipRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
|
132
|
+
|
|
133
|
+
// Focus after a removal has to wait for the re-render that drops the chip,
|
|
134
|
+
// so it is a request applied post-commit. -1 means the text input. The
|
|
135
|
+
// nonce guarantees a render even if the parent ignores onChange.
|
|
136
|
+
const pendingFocus = useRef<number | null>(null);
|
|
137
|
+
const [, bumpFocus] = useState(0);
|
|
138
|
+
const requestFocus = (index: number) => {
|
|
139
|
+
pendingFocus.current = index;
|
|
140
|
+
bumpFocus((n) => n + 1);
|
|
141
|
+
};
|
|
142
|
+
useLayoutEffect(() => {
|
|
143
|
+
const req = pendingFocus.current;
|
|
144
|
+
if (req === null) return;
|
|
145
|
+
pendingFocus.current = null;
|
|
146
|
+
if (req < 0) inputRef.current?.focus();
|
|
147
|
+
else (chipRefs.current[req] ?? inputRef.current)?.focus();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const emit = (next: string[]) => onChange?.(next.join(sep), next);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The single add path — every commit route (Enter, Tab, separator key,
|
|
154
|
+
* input-change, blur) funnels through here. Splits on the separator and on
|
|
155
|
+
* newlines first, so a paste of "a,b,c," becomes three tags rather than one
|
|
156
|
+
* tag that would re-split on the next load.
|
|
157
|
+
*/
|
|
158
|
+
const commitText = (raw: string): boolean => {
|
|
159
|
+
if (!editable) return false;
|
|
160
|
+
const pieces = raw.split(sep).flatMap((p) => p.split(/[\r\n]+/));
|
|
161
|
+
let next = tags;
|
|
162
|
+
let added = false;
|
|
163
|
+
for (const piece of pieces) {
|
|
164
|
+
const tag = trimTags ? piece.trim() : piece;
|
|
165
|
+
if (tag.trim() === '') continue; // nothing typed — no error
|
|
166
|
+
if (tag.length < minLength) {
|
|
167
|
+
flashError(`Tag must be at least ${minLength} character${minLength === 1 ? '' : 's'}`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (tag.length > maxLength) {
|
|
171
|
+
flashError(`Tag must be at most ${maxLength} characters`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!allowDuplicates && next.includes(tag)) {
|
|
175
|
+
flashError(`Tag "${tag}" already exists`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (next.length >= maxTags) {
|
|
179
|
+
flashError(`Maximum ${maxTags} tags allowed`);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
next = [...next, tag];
|
|
183
|
+
added = true;
|
|
184
|
+
}
|
|
185
|
+
if (added) emit(next);
|
|
186
|
+
return added;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const removeAt = (index: number): boolean => {
|
|
190
|
+
if (!editable) return false;
|
|
191
|
+
if (index < 0 || index >= tags.length) return false;
|
|
192
|
+
emit(tags.filter((_, i) => i !== index));
|
|
193
|
+
return true;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Remove + keep the keyboard flow alive: previous chip, else the input. */
|
|
197
|
+
const removeAndRefocus = (index: number) => {
|
|
198
|
+
const held = wrapRef.current?.contains(document.activeElement) ?? false;
|
|
199
|
+
if (!removeAt(index)) return;
|
|
200
|
+
if (held) requestFocus(index > 0 ? index - 1 : -1);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// ── input ─────────────────────────────────────────────────────────────
|
|
204
|
+
// Change-path commit: text ending in the separator or a newline. Covers
|
|
205
|
+
// pastes and soft keyboards, which never fire a usable keydown.
|
|
206
|
+
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
207
|
+
const v = e.target.value;
|
|
208
|
+
const last = v.slice(-1);
|
|
209
|
+
if (last === sep || last === '\n' || last === '\r') {
|
|
210
|
+
commitText(v.slice(0, -1));
|
|
211
|
+
setDraftValue('');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
setDraftValue(v);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
218
|
+
const text = draftRef.current;
|
|
219
|
+
switch (e.key) {
|
|
220
|
+
case 'Enter':
|
|
221
|
+
// Only swallow Enter when it does something here; an empty
|
|
222
|
+
// field lets an enclosing form submit as usual.
|
|
223
|
+
if (text.trim()) {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
commitText(text);
|
|
226
|
+
setDraftValue('');
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
case 'Tab':
|
|
230
|
+
// Deliberate deviation from web-mojo: commit but do NOT
|
|
231
|
+
// preventDefault, so Tab still leaves the field. The source
|
|
232
|
+
// trapped focus in the input for as long as it held text.
|
|
233
|
+
if (text.trim()) {
|
|
234
|
+
commitText(text);
|
|
235
|
+
setDraftValue('');
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
case sep:
|
|
239
|
+
// The separator can never be part of a tag — always swallowed.
|
|
240
|
+
e.preventDefault();
|
|
241
|
+
if (text.trim()) {
|
|
242
|
+
commitText(text);
|
|
243
|
+
setDraftValue('');
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
case 'Backspace':
|
|
247
|
+
if (text === '' && tags.length > 0) {
|
|
248
|
+
e.preventDefault();
|
|
249
|
+
removeAt(tags.length - 1);
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
case 'ArrowLeft':
|
|
253
|
+
if (text === '' && tags.length > 0) {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
chipRefs.current[tags.length - 1]?.focus();
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
case 'ArrowRight':
|
|
259
|
+
if (text === '' && tags.length > 0) {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
chipRefs.current[0]?.focus();
|
|
262
|
+
}
|
|
263
|
+
break;
|
|
264
|
+
case 'Escape':
|
|
265
|
+
// Clears the draft only. web-mojo also blurred; leaving focus
|
|
266
|
+
// put means Escape in a modal still reaches the dialog once
|
|
267
|
+
// there is nothing left to clear.
|
|
268
|
+
if (text !== '') {
|
|
269
|
+
e.preventDefault();
|
|
270
|
+
setDraftValue('');
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const onInputBlur = () => {
|
|
277
|
+
const text = draftRef.current;
|
|
278
|
+
if (text === '') return;
|
|
279
|
+
// Commit on blur (the house commit set is select/Enter/blur) — typed
|
|
280
|
+
// text is never silently dropped. Rejected adds clear too and say why,
|
|
281
|
+
// exactly as the Enter path does.
|
|
282
|
+
commitText(text);
|
|
283
|
+
setDraftValue('');
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// ── chips ─────────────────────────────────────────────────────────────
|
|
287
|
+
const onChipKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>, index: number) => {
|
|
288
|
+
switch (e.key) {
|
|
289
|
+
case 'Backspace':
|
|
290
|
+
case 'Delete':
|
|
291
|
+
// Delete is handled too — the chip's own aria-label promises it
|
|
292
|
+
// (web-mojo said so but only wired Backspace).
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
if (!editable) return;
|
|
295
|
+
if (removeAt(index)) requestFocus(index > 0 ? index - 1 : -1);
|
|
296
|
+
break;
|
|
297
|
+
case 'ArrowLeft':
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
if (index === 0) inputRef.current?.focus();
|
|
300
|
+
else chipRefs.current[index - 1]?.focus();
|
|
301
|
+
break;
|
|
302
|
+
case 'ArrowRight':
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
if (index === tags.length - 1) inputRef.current?.focus();
|
|
305
|
+
else chipRefs.current[index + 1]?.focus();
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const classes = [
|
|
311
|
+
'tag-input',
|
|
312
|
+
disabled ? 'tag-input-disabled' : '',
|
|
313
|
+
readonly ? 'tag-input-readonly' : '',
|
|
314
|
+
className ?? '',
|
|
315
|
+
].filter(Boolean).join(' ');
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<div className={classes}>
|
|
319
|
+
{/* role=group, not web-mojo's role=combobox: there is no popup to
|
|
320
|
+
expand, and aria-expanded="false" forever is a lie to AT. */}
|
|
321
|
+
<div
|
|
322
|
+
ref={wrapRef}
|
|
323
|
+
className="tag-input-wrap"
|
|
324
|
+
role="group"
|
|
325
|
+
aria-label={name ? `${name} tags` : 'Tags'}
|
|
326
|
+
// Clicking the wrapper's own padding must not blur the input
|
|
327
|
+
// (blur commits) — swallow the default focus shift and place
|
|
328
|
+
// focus explicitly on click instead.
|
|
329
|
+
onMouseDown={(e) => { if (editable && e.target === e.currentTarget) e.preventDefault(); }}
|
|
330
|
+
onClick={() => { if (editable) inputRef.current?.focus(); }}
|
|
331
|
+
>
|
|
332
|
+
{tags.map((tag, i) => (
|
|
333
|
+
<span
|
|
334
|
+
key={`${i}:${tag}`}
|
|
335
|
+
ref={(el) => { chipRefs.current[i] = el; }}
|
|
336
|
+
className="tag-chip"
|
|
337
|
+
tabIndex={0}
|
|
338
|
+
role="button"
|
|
339
|
+
aria-label={editable ? `Tag: ${tag}. Press Delete or Backspace to remove.` : `Tag: ${tag}`}
|
|
340
|
+
onKeyDown={(e) => onChipKeyDown(e, i)}
|
|
341
|
+
onClick={(e) => { e.stopPropagation(); chipRefs.current[i]?.focus(); }}
|
|
342
|
+
>
|
|
343
|
+
<span className="tag-chip-text">{tag}</span>
|
|
344
|
+
{editable && (
|
|
345
|
+
// Mouse affordance only — aria-hidden so it does not
|
|
346
|
+
// double the chip in the a11y tree (nesting an
|
|
347
|
+
// interactive element inside role=button is invalid).
|
|
348
|
+
<i
|
|
349
|
+
className="bi bi-x tag-chip-x"
|
|
350
|
+
aria-hidden="true"
|
|
351
|
+
onClick={(e) => { e.stopPropagation(); removeAndRefocus(i); }}
|
|
352
|
+
/>
|
|
353
|
+
)}
|
|
354
|
+
</span>
|
|
355
|
+
))}
|
|
356
|
+
{!readonly && (
|
|
357
|
+
<input
|
|
358
|
+
ref={inputRef}
|
|
359
|
+
type="text"
|
|
360
|
+
className="tag-input-field"
|
|
361
|
+
value={draft}
|
|
362
|
+
placeholder={placeholder}
|
|
363
|
+
disabled={disabled}
|
|
364
|
+
autoComplete="off"
|
|
365
|
+
onChange={onInputChange}
|
|
366
|
+
onKeyDown={onInputKeyDown}
|
|
367
|
+
onBlur={onInputBlur}
|
|
368
|
+
/>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
{/* Native form posts get the CSV, same as web-mojo's hidden input. */}
|
|
372
|
+
{name && <input type="hidden" name={name} value={tags.join(sep)} />}
|
|
373
|
+
<div className="tag-input-foot">
|
|
374
|
+
<span className="tag-input-count">{tags.length}/{maxTags} tags</span>
|
|
375
|
+
{/* Live region stays mounted so AT announces the flash; CSS
|
|
376
|
+
hides it while empty (.tag-input-error:empty). */}
|
|
377
|
+
<span className="tag-input-error" role="status" aria-live="polite">{error?.text ?? ''}</span>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// ThemeManager port: the preference/resolved two-value model. Preference is
|
|
2
|
+
// 'light' | 'dark' | 'system' (persisted); resolved is what's on screen.
|
|
3
|
+
// Stamps data-theme on <html>; all tokens key off it (see theme.css).
|
|
4
|
+
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
export type ThemePref = 'light' | 'dark' | 'system';
|
|
7
|
+
type Resolved = 'light' | 'dark';
|
|
8
|
+
|
|
9
|
+
const STORAGE_KEY = 'mojo:theme';
|
|
10
|
+
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
|
11
|
+
|
|
12
|
+
function resolve(pref: ThemePref): Resolved {
|
|
13
|
+
return pref === 'system' ? (media.matches ? 'dark' : 'light') : pref;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ThemeContext = createContext<{ pref: ThemePref; resolved: Resolved; setPref: (p: ThemePref) => void }>({
|
|
17
|
+
pref: 'system',
|
|
18
|
+
resolved: 'light',
|
|
19
|
+
setPref: () => {},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
23
|
+
const [pref, setPrefState] = useState<ThemePref>(() => {
|
|
24
|
+
try {
|
|
25
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
26
|
+
return saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system';
|
|
27
|
+
} catch { return 'system'; }
|
|
28
|
+
});
|
|
29
|
+
const [resolved, setResolved] = useState<Resolved>(() => resolve(pref));
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const apply = () => setResolved(resolve(pref));
|
|
33
|
+
apply();
|
|
34
|
+
media.addEventListener('change', apply);
|
|
35
|
+
return () => media.removeEventListener('change', apply);
|
|
36
|
+
}, [pref]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
document.documentElement.dataset.theme = resolved;
|
|
40
|
+
}, [resolved]);
|
|
41
|
+
|
|
42
|
+
const setPref = (p: ThemePref) => {
|
|
43
|
+
setPrefState(p);
|
|
44
|
+
try { localStorage.setItem(STORAGE_KEY, p); } catch { /* private mode */ }
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return <ThemeContext.Provider value={{ pref, resolved, setPref }}>{children}</ThemeContext.Provider>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useTheme() {
|
|
51
|
+
return useContext(ThemeContext);
|
|
52
|
+
}
|