@colixsystems/widget-sdk 0.13.0 → 0.15.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/README.md +78 -3
- package/dist/clipboard.js +88 -0
- package/dist/clipboard.native.js +64 -0
- package/dist/contract.cjs +318 -11
- package/dist/contract.js +280 -9
- package/dist/datetimepicker.js +102 -0
- package/dist/hooks.js +233 -1
- package/dist/icon.js +29 -0
- package/dist/index.d.ts +126 -0
- package/dist/index.js +10 -0
- package/dist/index.native.js +8 -0
- package/dist/linter.cjs +243 -9
- package/dist/linter.js +309 -10
- package/dist/primitives.js +8 -0
- package/dist/primitives.native.js +9 -0
- package/dist/property-schema.js +7 -0
- package/dist/toast.js +73 -0
- package/dist/toast.native.js +46 -0
- package/package.json +2 -2
package/dist/contract.js
CHANGED
|
@@ -171,6 +171,112 @@ const HOOKS = [
|
|
|
171
171
|
requiredContextSlice: ["payments.requestPayment"],
|
|
172
172
|
scopes: ["payments.charge:appUser"],
|
|
173
173
|
},
|
|
174
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
175
|
+
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
176
|
+
// Reads need `users.read:*` scope; mutations additionally need
|
|
177
|
+
// `users.write:*`. The `invite` call accepts `{ email, name, groupIds? }`
|
|
178
|
+
// and returns the resulting AppUserInvite row (the email is sent by the
|
|
179
|
+
// host). Mutating users from a widget requires the corresponding
|
|
180
|
+
// SystemAcl `users.write` capability grant in the tenant; widgets that
|
|
181
|
+
// only call read methods need only `users.read:*`.
|
|
182
|
+
{
|
|
183
|
+
name: "useUsers",
|
|
184
|
+
signature: "useUsers(query?)",
|
|
185
|
+
description:
|
|
186
|
+
"AppUser administration. Returns { users, loading, error, refetch, invite, " +
|
|
187
|
+
"deactivate, reactivate, remove }. Reads need users.read:* scope; mutations " +
|
|
188
|
+
"need users.write:*. The `invite` call accepts { email, name, groupIds? } " +
|
|
189
|
+
"and returns the resulting AppUserInvite row (the email is sent by the host).",
|
|
190
|
+
returnShape: {
|
|
191
|
+
users: "Array<{ id, name, email?, role, isActive }>",
|
|
192
|
+
loading: "boolean",
|
|
193
|
+
error: "DirectoryError | null",
|
|
194
|
+
refetch: "() => Promise<void>",
|
|
195
|
+
invite:
|
|
196
|
+
"({ email, name, groupIds? }) => Promise<Invite> // rejects with DirectoryError",
|
|
197
|
+
deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
|
|
198
|
+
reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
|
|
199
|
+
remove: "(userId) => Promise<void> // rejects with DirectoryError",
|
|
200
|
+
},
|
|
201
|
+
requiredContextSlice: [
|
|
202
|
+
"users.listUsers",
|
|
203
|
+
"users.invite",
|
|
204
|
+
"users.deactivate",
|
|
205
|
+
"users.reactivate",
|
|
206
|
+
"users.remove",
|
|
207
|
+
],
|
|
208
|
+
scopes: ["users.read:*"],
|
|
209
|
+
},
|
|
210
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
|
|
211
|
+
// `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
|
|
212
|
+
// Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
|
|
213
|
+
// member requires the corresponding `users.write` capability since it
|
|
214
|
+
// affects the user's effective access.
|
|
215
|
+
{
|
|
216
|
+
name: "useGroups",
|
|
217
|
+
signature: "useGroups(query?)",
|
|
218
|
+
description:
|
|
219
|
+
"AppUserGroup administration. Returns { groups, loading, error, refetch, " +
|
|
220
|
+
"create, remove, addMember, removeMember }. Reads need groups.read:*; " +
|
|
221
|
+
"mutations need groups.write:*.",
|
|
222
|
+
returnShape: {
|
|
223
|
+
groups: "Array<{ id, name, memberCount }>",
|
|
224
|
+
loading: "boolean",
|
|
225
|
+
error: "DirectoryError | null",
|
|
226
|
+
refetch: "() => Promise<void>",
|
|
227
|
+
create:
|
|
228
|
+
"({ name }) => Promise<Group> // rejects with DirectoryError",
|
|
229
|
+
remove: "(groupId) => Promise<void> // rejects with DirectoryError",
|
|
230
|
+
addMember:
|
|
231
|
+
"(groupId, userId) => Promise<void> // rejects with DirectoryError",
|
|
232
|
+
removeMember:
|
|
233
|
+
"(groupId, userId) => Promise<void> // rejects with DirectoryError",
|
|
234
|
+
},
|
|
235
|
+
requiredContextSlice: [
|
|
236
|
+
"groups.listGroups",
|
|
237
|
+
"groups.create",
|
|
238
|
+
"groups.remove",
|
|
239
|
+
"groups.addMember",
|
|
240
|
+
"groups.removeMember",
|
|
241
|
+
],
|
|
242
|
+
scopes: ["groups.read:*"],
|
|
243
|
+
},
|
|
244
|
+
// REQ-WSDK-PLATFORM §6 — Tier A SDK hooks.
|
|
245
|
+
{
|
|
246
|
+
name: "useClipboard",
|
|
247
|
+
signature: "useClipboard()",
|
|
248
|
+
description:
|
|
249
|
+
"Cross-platform clipboard access. Returns { copy, paste, hasContent }. " +
|
|
250
|
+
"All methods return Promises; rejections surface a structured ClipboardError " +
|
|
251
|
+
"with .code in PERMISSION_DENIED | INTERNAL. On web the browser may " +
|
|
252
|
+
"require a user gesture for read access — surface the error to the " +
|
|
253
|
+
"user as a 'try again after clicking' prompt when code === PERMISSION_DENIED.",
|
|
254
|
+
returnShape: {
|
|
255
|
+
copy:
|
|
256
|
+
"(text: string) => Promise<void> // rejects with ClipboardError",
|
|
257
|
+
paste:
|
|
258
|
+
"() => Promise<string> // rejects with ClipboardError",
|
|
259
|
+
hasContent: "() => Promise<boolean> // best-effort, never throws",
|
|
260
|
+
},
|
|
261
|
+
requiredContextSlice: [],
|
|
262
|
+
scopes: null,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: "useToast",
|
|
266
|
+
signature: "useToast()",
|
|
267
|
+
description:
|
|
268
|
+
"Surfaces a short auto-dismissing notification. Returns { showToast }. " +
|
|
269
|
+
"showToast({ kind: 'success' | 'error' | 'info' | 'warning', message }) " +
|
|
270
|
+
"asks the host to render a workspace-themed toast. If the host hasn't " +
|
|
271
|
+
"wired a renderer, the web variant dispatches an 'appstudio:widget-toast' " +
|
|
272
|
+
"CustomEvent on window; native logs to the console. The widget never " +
|
|
273
|
+
"owns the toast UI — that's the host's responsibility.",
|
|
274
|
+
returnShape: {
|
|
275
|
+
showToast: "({ kind, message }) => void",
|
|
276
|
+
},
|
|
277
|
+
requiredContextSlice: ["toast.showToast"],
|
|
278
|
+
scopes: null,
|
|
279
|
+
},
|
|
174
280
|
];
|
|
175
281
|
|
|
176
282
|
// REQ-WSDK-RN-WEB: see contract.cjs for the source-of-truth comment.
|
|
@@ -259,6 +365,22 @@ const PRIMITIVES = [
|
|
|
259
365
|
rnComponent: "Linking",
|
|
260
366
|
docsUrl: "https://reactnative.dev/docs/linking",
|
|
261
367
|
},
|
|
368
|
+
// REQ-WSDK-PLATFORM §6 — Tier A SDK primitive.
|
|
369
|
+
{
|
|
370
|
+
name: "Icon",
|
|
371
|
+
description:
|
|
372
|
+
'Lucide icon. `<Icon name="check" size={16} color="..." />`. Unknown names render the Square fallback so the canvas always shows something visible. Names are the lucide icon ids (`https://lucide.dev/icons`). Works on both web and native.',
|
|
373
|
+
rnComponent: "lucide-react-native",
|
|
374
|
+
docsUrl: "https://lucide.dev/icons",
|
|
375
|
+
},
|
|
376
|
+
// REQ-WSDK-PLATFORM §6 — Tier A SDK primitive.
|
|
377
|
+
{
|
|
378
|
+
name: "DateTimePicker",
|
|
379
|
+
description:
|
|
380
|
+
'Cross-platform date / time / datetime picker. `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. The value prop and the onChange callback both speak ISO 8601 strings (the datastore wire format) — authors never round-trip through `new Date()`. Web renders the browser\'s native input via react-native-web; native uses @react-native-community/datetimepicker.',
|
|
381
|
+
rnComponent: "@react-native-community/datetimepicker",
|
|
382
|
+
docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
|
|
383
|
+
},
|
|
262
384
|
];
|
|
263
385
|
|
|
264
386
|
const CATEGORIES = [
|
|
@@ -427,6 +549,38 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
427
549
|
required: true,
|
|
428
550
|
fields: { requestPayment: "function", getPayment: "function" },
|
|
429
551
|
},
|
|
552
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration facade backing
|
|
553
|
+
// useUsers(). Reads gated by `users.read:*`; mutations by `users.write:*`.
|
|
554
|
+
// The host signs an `X-Widget-Scopes` header against JWT_SECRET so an
|
|
555
|
+
// APP_USER cannot forge scope claims, and the request is additionally
|
|
556
|
+
// gated by a SystemAcl `users.read` / `users.write` capability grant.
|
|
557
|
+
users: {
|
|
558
|
+
description:
|
|
559
|
+
"AppUser administration. { listUsers(query?) -> Promise<User[]>, invite({ email, name, groupIds? }) -> Promise<Invite>, deactivate(userId) -> Promise<User>, reactivate(userId) -> Promise<User>, remove(userId) -> Promise<void> }. Backs useUsers(); reads require users.read:*, mutations require users.write:*.",
|
|
560
|
+
required: true,
|
|
561
|
+
fields: {
|
|
562
|
+
listUsers: "function",
|
|
563
|
+
invite: "function",
|
|
564
|
+
deactivate: "function",
|
|
565
|
+
reactivate: "function",
|
|
566
|
+
remove: "function",
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
|
|
570
|
+
// backing useGroups(). Reads gated by `groups.read:*`; mutations by
|
|
571
|
+
// `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
|
|
572
|
+
groups: {
|
|
573
|
+
description:
|
|
574
|
+
"AppUserGroup administration. { listGroups(query?) -> Promise<Group[]>, create({ name }) -> Promise<Group>, remove(groupId) -> Promise<void>, addMember(groupId, userId) -> Promise<void>, removeMember(groupId, userId) -> Promise<void> }. Backs useGroups(); reads require groups.read:*, mutations require groups.write:*.",
|
|
575
|
+
required: true,
|
|
576
|
+
fields: {
|
|
577
|
+
listGroups: "function",
|
|
578
|
+
create: "function",
|
|
579
|
+
remove: "function",
|
|
580
|
+
addMember: "function",
|
|
581
|
+
removeMember: "function",
|
|
582
|
+
},
|
|
583
|
+
},
|
|
430
584
|
i18n: {
|
|
431
585
|
description: "{ t(key, fallback?), locale }.",
|
|
432
586
|
required: true,
|
|
@@ -443,6 +597,20 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
443
597
|
error: "function",
|
|
444
598
|
},
|
|
445
599
|
},
|
|
600
|
+
// REQ-WSDK-PLATFORM §6 — backs useToast(). The host installs its own
|
|
601
|
+
// workspace-themed toast renderer here; if omitted, the SDK's useToast
|
|
602
|
+
// hook falls back to a CustomEvent on web / console.log on native so
|
|
603
|
+
// widget code still runs without a host integration.
|
|
604
|
+
toast: {
|
|
605
|
+
description:
|
|
606
|
+
"Optional host toast slot. { showToast({ kind, message }): void }. " +
|
|
607
|
+
"The host populates this to render workspace-themed notifications " +
|
|
608
|
+
"from any widget that calls useToast(). When omitted the SDK falls " +
|
|
609
|
+
"back to dispatching an 'appstudio:widget-toast' CustomEvent on web " +
|
|
610
|
+
"and console.log on native.",
|
|
611
|
+
required: false,
|
|
612
|
+
fields: { showToast: "function" },
|
|
613
|
+
},
|
|
446
614
|
};
|
|
447
615
|
|
|
448
616
|
const BUNDLE_EXPORT_CONTRACT = [
|
|
@@ -465,6 +633,9 @@ const BUNDLE_EXPORT_CONTRACT = [
|
|
|
465
633
|
},
|
|
466
634
|
];
|
|
467
635
|
|
|
636
|
+
// REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
|
|
637
|
+
// §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. See contract.cjs
|
|
638
|
+
// for the source-of-truth comment.
|
|
468
639
|
const BANNED_APIS = [
|
|
469
640
|
{ identifier: "eval", reason: "Arbitrary code evaluation." },
|
|
470
641
|
{
|
|
@@ -493,21 +664,119 @@ const BANNED_APIS = [
|
|
|
493
664
|
reason: "Same reason as localStorage.",
|
|
494
665
|
},
|
|
495
666
|
{
|
|
496
|
-
identifier: "
|
|
497
|
-
reason: "
|
|
667
|
+
identifier: "import(",
|
|
668
|
+
reason: "Dynamic import bypasses the loader's allowlist.",
|
|
498
669
|
},
|
|
670
|
+
{ identifier: "globalThis", reason: "Host environment escape." },
|
|
671
|
+
];
|
|
672
|
+
|
|
673
|
+
// REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. See contract.cjs
|
|
674
|
+
// for the source-of-truth comment.
|
|
675
|
+
const VETTED_IMPORTS = [
|
|
499
676
|
{
|
|
500
|
-
|
|
501
|
-
|
|
677
|
+
specifier: "react",
|
|
678
|
+
platforms: ["web", "native"],
|
|
679
|
+
category: "core",
|
|
680
|
+
description: "React. Hooks, JSX, lifecycle. Unchanged.",
|
|
502
681
|
},
|
|
503
682
|
{
|
|
504
|
-
|
|
505
|
-
|
|
683
|
+
specifier: "@colixsystems/widget-sdk",
|
|
684
|
+
platforms: ["web", "native"],
|
|
685
|
+
category: "core",
|
|
686
|
+
description:
|
|
687
|
+
"The AppStudio widget SDK — primitives, hooks, manifest helpers. Unchanged.",
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
specifier: "react-native",
|
|
691
|
+
platforms: ["web", "native"],
|
|
692
|
+
category: "primitive",
|
|
693
|
+
description:
|
|
694
|
+
"Direct RN imports for APIs the SDK hasn't re-exported yet. On web the host bundler aliases this to react-native-web; on native Metro resolves the real library.",
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
specifier: "axios",
|
|
698
|
+
platforms: ["web", "native"],
|
|
699
|
+
category: "network",
|
|
700
|
+
description:
|
|
701
|
+
"HTTP client for third-party APIs. Calls to the host's /api/* surface are blocked at runtime — widgets get no JWT token, so use SDK hooks for workspace data.",
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
specifier: "date-fns",
|
|
705
|
+
platforms: ["web", "native"],
|
|
706
|
+
category: "utility",
|
|
707
|
+
description: "Pure-JS date math. Works on both platforms unchanged.",
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
specifier: "react-native-svg",
|
|
711
|
+
platforms: ["web", "native"],
|
|
712
|
+
category: "drawing",
|
|
713
|
+
description:
|
|
714
|
+
"Cross-platform SVG drawing primitives. Used by the built-in Chart widget; works on both platforms.",
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
specifier: "lucide-react-native",
|
|
718
|
+
platforms: ["web", "native"],
|
|
719
|
+
category: "iconography",
|
|
720
|
+
description:
|
|
721
|
+
"Lucide icon set as React components. Used by the built-in Icon widget; works on both platforms.",
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
specifier: "react-native-maps",
|
|
725
|
+
platforms: ["native"],
|
|
726
|
+
category: "geo",
|
|
727
|
+
description:
|
|
728
|
+
"Native map view + markers. Native-only; pair with leaflet/react-leaflet in widget.web.jsx for a web variant.",
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
specifier: "leaflet",
|
|
732
|
+
platforms: ["web"],
|
|
733
|
+
category: "geo",
|
|
734
|
+
description:
|
|
735
|
+
"Web-only mapping library. Use alongside react-leaflet in widget.web.jsx as the web counterpart to react-native-maps.",
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
specifier: "react-leaflet",
|
|
739
|
+
platforms: ["web"],
|
|
740
|
+
category: "geo",
|
|
741
|
+
description: "React bindings for leaflet. Web-only.",
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
specifier: "expo-av",
|
|
745
|
+
platforms: ["native"],
|
|
746
|
+
category: "media",
|
|
747
|
+
description:
|
|
748
|
+
"Native audio + video playback. Native-only; pair with browser <audio>/<video> in widget.web.jsx.",
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
specifier: "@react-native-community/datetimepicker",
|
|
752
|
+
platforms: ["native"],
|
|
753
|
+
category: "input",
|
|
754
|
+
description:
|
|
755
|
+
"Native date/time picker. The SDK's <DateTimePicker> primitive already wraps this; reach for it directly only if you need RN-specific options.",
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
specifier: "expo-clipboard",
|
|
759
|
+
platforms: ["native"],
|
|
760
|
+
category: "system",
|
|
761
|
+
description:
|
|
762
|
+
"Native clipboard. The SDK's useClipboard() hook already wraps this; reach for it directly only if you need RN-specific options.",
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
specifier: "expo-haptics",
|
|
766
|
+
platforms: ["native"],
|
|
767
|
+
category: "system",
|
|
768
|
+
description:
|
|
769
|
+
"Native haptic feedback. Pair with navigator.vibrate in widget.web.jsx.",
|
|
506
770
|
},
|
|
507
|
-
{ identifier: "globalThis", reason: "Host environment escape." },
|
|
508
771
|
];
|
|
509
772
|
|
|
510
|
-
const ALLOWED_BARE_IMPORTS =
|
|
773
|
+
const ALLOWED_BARE_IMPORTS = VETTED_IMPORTS.map((v) => v.specifier);
|
|
774
|
+
|
|
775
|
+
const HOST_API_URL_PATTERNS = [
|
|
776
|
+
"/api/v1",
|
|
777
|
+
"/uploads/",
|
|
778
|
+
"Authorization: Bearer",
|
|
779
|
+
];
|
|
511
780
|
|
|
512
781
|
function deepFreeze(value) {
|
|
513
782
|
if (value === null || typeof value !== "object") return value;
|
|
@@ -517,7 +786,7 @@ function deepFreeze(value) {
|
|
|
517
786
|
}
|
|
518
787
|
|
|
519
788
|
const CONTRACT = deepFreeze({
|
|
520
|
-
version: "1.
|
|
789
|
+
version: "1.5.0",
|
|
521
790
|
hooks: HOOKS,
|
|
522
791
|
primitives: PRIMITIVES,
|
|
523
792
|
manifestSchema: MANIFEST_SCHEMA,
|
|
@@ -527,7 +796,9 @@ const CONTRACT = deepFreeze({
|
|
|
527
796
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
528
797
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
529
798
|
bannedApis: BANNED_APIS,
|
|
799
|
+
vettedImports: VETTED_IMPORTS,
|
|
530
800
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
|
801
|
+
hostApiUrlPatterns: HOST_API_URL_PATTERNS,
|
|
531
802
|
});
|
|
532
803
|
|
|
533
804
|
function isHookAllowed(name) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` SDK primitive.
|
|
2
|
+
//
|
|
3
|
+
// Cross-platform date / time / datetime picker. Wraps
|
|
4
|
+
// `@react-native-community/datetimepicker` (works on both web and native:
|
|
5
|
+
// on web it renders the browser's native `<input type="date|time">`
|
|
6
|
+
// surface through react-native-web's mapping). The wire format is ISO
|
|
7
|
+
// 8601 strings — the same format the datastore speaks, so widget authors
|
|
8
|
+
// never round-trip through `new Date()`.
|
|
9
|
+
//
|
|
10
|
+
// Props:
|
|
11
|
+
// value: string | null — ISO 8601 (`2026-05-28` for date mode,
|
|
12
|
+
// `2026-05-28T14:30:00.000Z` for datetime).
|
|
13
|
+
// `null` defaults to "now".
|
|
14
|
+
// onChange: (iso: string) => void
|
|
15
|
+
// mode: "date" | "time" | "datetime" — default "date"
|
|
16
|
+
// minimumDate / maximumDate: string | null — ISO bounds
|
|
17
|
+
// disabled: boolean
|
|
18
|
+
//
|
|
19
|
+
// The author writes:
|
|
20
|
+
// const [day, setDay] = useState(null);
|
|
21
|
+
// <DateTimePicker value={day} onChange={setDay} mode="date" />
|
|
22
|
+
//
|
|
23
|
+
// …and `day` ends up as an ISO string suitable for storing directly into
|
|
24
|
+
// a DATE column. The previous pattern of importing the RN library
|
|
25
|
+
// directly and managing `Date` objects in widget state is gone — the
|
|
26
|
+
// primitive normalizes both ends.
|
|
27
|
+
|
|
28
|
+
import React, { useMemo } from "react";
|
|
29
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
30
|
+
import RNDateTimePicker from "@react-native-community/datetimepicker";
|
|
31
|
+
|
|
32
|
+
function _parseToDate(value) {
|
|
33
|
+
if (value == null || value === "") return new Date();
|
|
34
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? new Date() : value;
|
|
35
|
+
if (typeof value === "string") {
|
|
36
|
+
const d = new Date(value);
|
|
37
|
+
return Number.isNaN(d.getTime()) ? new Date() : d;
|
|
38
|
+
}
|
|
39
|
+
return new Date();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _formatToIso(date, mode) {
|
|
43
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
|
|
44
|
+
if (mode === "date") {
|
|
45
|
+
// Local-date ISO (yyyy-mm-dd) — calendar dates should be timezone-free
|
|
46
|
+
// so a "May 28" picked in Stockholm doesn't read as "May 27" in NYC.
|
|
47
|
+
const y = date.getFullYear();
|
|
48
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
49
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
50
|
+
return `${y}-${m}-${d}`;
|
|
51
|
+
}
|
|
52
|
+
if (mode === "time") {
|
|
53
|
+
// Local-time ISO (hh:mm) — time-of-day is timezone-free for the same
|
|
54
|
+
// reason. Authors who want a full datetime get mode="datetime".
|
|
55
|
+
const h = String(date.getHours()).padStart(2, "0");
|
|
56
|
+
const mm = String(date.getMinutes()).padStart(2, "0");
|
|
57
|
+
return `${h}:${mm}`;
|
|
58
|
+
}
|
|
59
|
+
// datetime — full UTC ISO so the wire format round-trips through the
|
|
60
|
+
// datastore's DATE column unchanged.
|
|
61
|
+
return date.toISOString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function DateTimePicker({
|
|
65
|
+
value,
|
|
66
|
+
onChange,
|
|
67
|
+
mode,
|
|
68
|
+
minimumDate,
|
|
69
|
+
maximumDate,
|
|
70
|
+
disabled,
|
|
71
|
+
}) {
|
|
72
|
+
const effectiveMode = mode === "time" || mode === "datetime" ? mode : "date";
|
|
73
|
+
const dateValue = useMemo(() => _parseToDate(value), [value]);
|
|
74
|
+
const min = useMemo(
|
|
75
|
+
() => (minimumDate ? _parseToDate(minimumDate) : undefined),
|
|
76
|
+
[minimumDate],
|
|
77
|
+
);
|
|
78
|
+
const max = useMemo(
|
|
79
|
+
() => (maximumDate ? _parseToDate(maximumDate) : undefined),
|
|
80
|
+
[maximumDate],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const handleChange = (_event, picked) => {
|
|
84
|
+
if (typeof onChange !== "function") return;
|
|
85
|
+
if (!(picked instanceof Date)) return;
|
|
86
|
+
const iso = _formatToIso(picked, effectiveMode);
|
|
87
|
+
if (iso != null) onChange(iso);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// The RN library's `mode` accepts "date" / "time"; for "datetime" we
|
|
91
|
+
// ask for "datetime" on iOS / Android and let the picker's
|
|
92
|
+
// implementation handle it. react-native-web's mapping interprets
|
|
93
|
+
// "datetime" as `<input type="datetime-local">`.
|
|
94
|
+
return React.createElement(RNDateTimePicker, {
|
|
95
|
+
value: dateValue,
|
|
96
|
+
mode: effectiveMode,
|
|
97
|
+
minimumDate: min,
|
|
98
|
+
maximumDate: max,
|
|
99
|
+
disabled: !!disabled,
|
|
100
|
+
onChange: handleChange,
|
|
101
|
+
});
|
|
102
|
+
}
|