@protobase/ui 0.21.7 → 0.21.9
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/dist/app/app.js +1 -0
- package/dist/app/charts/chart-view.js +3 -1
- package/dist/app/comments/comment-body.js +13 -0
- package/dist/app/comments/comment-composer.js +149 -0
- package/dist/app/comments/comments-section.js +78 -0
- package/dist/app/comments/mention-text.js +45 -0
- package/dist/app/comments/use-mentionable.js +18 -0
- package/dist/app/comments/use-post-comment.js +15 -0
- package/dist/app/comments/use-record-comments.js +24 -0
- package/dist/app/current-address.d.ts +4 -3
- package/dist/app/current-address.js +1 -0
- package/dist/app/displays/calendar-controls.js +70 -0
- package/dist/app/displays/calendar-day.js +33 -0
- package/dist/app/displays/calendar-display.js +175 -0
- package/dist/app/displays/calendar-dnd.js +27 -0
- package/dist/app/displays/calendar-event.js +73 -0
- package/dist/app/displays/calendar-range.js +129 -0
- package/dist/app/displays/list-displays.js +7 -1
- package/dist/app/displays/use-calendar-move.js +68 -0
- package/dist/app/displays/use-calendar-rows.js +35 -0
- package/dist/app/embedded-list/embedded-list.js +25 -21
- package/dist/app/embedded-list/embedded-row-targets.js +65 -0
- package/dist/app/export/cells.js +6 -4
- package/dist/app/field-editor.js +13 -1
- package/dist/app/import/convert.js +2 -12
- package/dist/app/import/locale-formats.js +2 -1
- package/dist/app/incoming-card.js +35 -0
- package/dist/app/incoming-relations.js +36 -0
- package/dist/app/list-chart.js +3 -1
- package/dist/app/list-page.js +16 -5
- package/dist/app/pages/field-value.js +9 -1
- package/dist/app/pages/record-actions.js +34 -16
- package/dist/app/pages/use-run-action.js +8 -6
- package/dist/app/record-page.js +50 -28
- package/dist/app/related-opens.js +22 -0
- package/dist/app/related-section.js +4 -1
- package/dist/app/runtime/runtime-status.js +5 -4
- package/dist/app/search/navigation-hits.js +45 -0
- package/dist/app/search/use-global-search.js +13 -4
- package/dist/app/use-app-address.d.ts +3 -1
- package/dist/app/use-app-address.js +8 -9
- package/dist/app/use-incoming-counts.js +38 -0
- package/dist/app/use-related-records.js +3 -10
- package/dist/app-shell/app-shell.js +9 -2
- package/dist/app-shell/index.d.ts +1 -0
- package/dist/app-shell/logo.d.ts +5 -1
- package/dist/app-shell/logo.js +10 -3
- package/dist/app-shell/sidebar.js +3 -2
- package/dist/app-shell/top-bar-slot.js +11 -0
- package/dist/app-shell/top-bar.d.ts +3 -0
- package/dist/app-shell/top-bar.js +27 -19
- package/dist/auth/user-to-shell.js +3 -1
- package/dist/data/api-provider.d.ts +20 -0
- package/dist/data/query-keys.js +15 -0
- package/dist/data-grid/cells.js +8 -1
- package/dist/data-grid/grid-header.js +17 -16
- package/dist/data-grid/header-menu.js +16 -6
- package/dist/data-grid/row-menu.js +10 -40
- package/dist/format/date.js +52 -6
- package/dist/format/number-text.js +16 -0
- package/dist/format/zone.js +62 -0
- package/dist/live/columns-from-model.js +2 -0
- package/dist/live/contact-link.js +25 -0
- package/dist/live/contact-links.js +42 -0
- package/dist/live/display-value.js +14 -4
- package/dist/live/field-values.js +20 -9
- package/dist/live/filter-string.js +30 -30
- package/dist/package.js +1 -1
- package/dist/primitives/popover-menu.js +56 -0
- package/dist/primitives/segmented.js +8 -3
- package/dist/primitives/textarea.d.ts +4 -3
- package/dist/primitives/textarea.js +9 -4
- package/dist/record-view/action-menu.js +59 -0
- package/dist/record-view/record-header.js +2 -6
- package/dist/record-view/record-sidebar.js +2 -30
- package/dist/shell/assets/app-B_L-OYQJ.js +69 -0
- package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
- package/dist/shell/assets/{index-CQc-60lQ.js → index-CRNWPM01.js} +1 -1
- package/dist/shell/assets/index-CzqIlZPP.css +2 -0
- package/dist/shell/assets/{maplibre-BvtgTvfx.js → maplibre-DZlOrjmh.js} +1 -1
- package/dist/shell/assets/{markdown-content-CJ8g5iKO.js → markdown-content-DT_18T89.js} +1 -1
- package/dist/shell/assets/{record-map-DSXYVoqB.js → record-map-Bmns3gJf.js} +1 -1
- package/dist/shell/assets/{records-map-cU_FAK7w.js → records-map-Ci3qFCDs.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-Bc0CoTXD.js → shared-protobase-ui-DPgVYrS4.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-BbVdHzEY.js +0 -69
- package/dist/shell/assets/export-worker-CVd5saLq.js +0 -2
- package/dist/shell/assets/index-C1fzTv_X.css +0 -2
|
@@ -6,8 +6,9 @@ import { useEffect, useRef, useState } from "react";
|
|
|
6
6
|
import { ChevronLeft, Menu, Search, X } from "lucide-react";
|
|
7
7
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
//#region src/app-shell/top-bar.tsx
|
|
9
|
-
|
|
10
|
-
const
|
|
9
|
+
/** An icon button of the top bar on a mobile screen, as the menu, back and search buttons are. */
|
|
10
|
+
const topBarButton = "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden";
|
|
11
|
+
const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControls, search, sticky, slotRef, ref }) => {
|
|
11
12
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
12
13
|
const back = backTarget(breadcrumb);
|
|
13
14
|
const desktopSearch = useRef(null);
|
|
@@ -33,7 +34,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
|
|
|
33
34
|
"aria-expanded": Boolean(menuOpen),
|
|
34
35
|
"aria-controls": menuControls,
|
|
35
36
|
onClick: onMenu,
|
|
36
|
-
className: cn(
|
|
37
|
+
className: cn("inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden", "-ml-3"),
|
|
37
38
|
children: /* @__PURE__ */ jsx(Menu, { className: "size-5" })
|
|
38
39
|
}),
|
|
39
40
|
back && /* @__PURE__ */ jsx("a", {
|
|
@@ -44,7 +45,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
|
|
|
44
45
|
event.preventDefault();
|
|
45
46
|
onNavigate(back.href);
|
|
46
47
|
},
|
|
47
|
-
className: cn(
|
|
48
|
+
className: cn("inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden", !onMenu && "-ml-3"),
|
|
48
49
|
children: /* @__PURE__ */ jsx(ChevronLeft, { className: "size-5" })
|
|
49
50
|
}),
|
|
50
51
|
/* @__PURE__ */ jsx(Breadcrumb, {
|
|
@@ -53,20 +54,27 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
|
|
|
53
54
|
}),
|
|
54
55
|
/* @__PURE__ */ jsxs("div", {
|
|
55
56
|
className: "ml-auto flex items-center gap-1 md:gap-3",
|
|
56
|
-
children: [
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
children: [
|
|
58
|
+
search && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
|
|
59
|
+
className: "hidden md:block",
|
|
60
|
+
children: /* @__PURE__ */ jsx(GlobalSearch, {
|
|
61
|
+
ref: desktopSearch,
|
|
62
|
+
search
|
|
63
|
+
})
|
|
64
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
65
|
+
type: "button",
|
|
66
|
+
"aria-label": "Search",
|
|
67
|
+
"aria-expanded": searchOpen,
|
|
68
|
+
onClick: () => setSearchOpen(true),
|
|
69
|
+
className: "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden",
|
|
70
|
+
children: /* @__PURE__ */ jsx(Search, { className: "size-5" })
|
|
71
|
+
})] }),
|
|
72
|
+
actions,
|
|
73
|
+
/* @__PURE__ */ jsx("div", {
|
|
74
|
+
ref: slotRef,
|
|
75
|
+
className: "contents md:hidden"
|
|
61
76
|
})
|
|
62
|
-
|
|
63
|
-
type: "button",
|
|
64
|
-
"aria-label": "Search",
|
|
65
|
-
"aria-expanded": searchOpen,
|
|
66
|
-
onClick: () => setSearchOpen(true),
|
|
67
|
-
className: iconButton,
|
|
68
|
-
children: /* @__PURE__ */ jsx(Search, { className: "size-5" })
|
|
69
|
-
})] }), actions]
|
|
77
|
+
]
|
|
70
78
|
}),
|
|
71
79
|
search && searchOpen && /* @__PURE__ */ jsxs("div", {
|
|
72
80
|
className: "absolute inset-0 z-20 flex items-center gap-1 bg-background px-3 md:hidden",
|
|
@@ -85,7 +93,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
|
|
|
85
93
|
type: "button",
|
|
86
94
|
"aria-label": "Close search",
|
|
87
95
|
onClick: () => setSearchOpen(false),
|
|
88
|
-
className:
|
|
96
|
+
className: "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden",
|
|
89
97
|
children: /* @__PURE__ */ jsx(X, { className: "size-5" })
|
|
90
98
|
})]
|
|
91
99
|
})
|
|
@@ -93,4 +101,4 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
|
|
|
93
101
|
});
|
|
94
102
|
};
|
|
95
103
|
//#endregion
|
|
96
|
-
export { TopBar };
|
|
104
|
+
export { TopBar, topBarButton };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
//#region src/auth/user-to-shell.ts
|
|
2
|
+
/** The hue of someone's avatar, the same for the same id. */
|
|
2
3
|
const hueOf = (text) => [...text].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) % 360, 7);
|
|
4
|
+
/** The initials of someone's avatar: the first letters of the first two words of their name. */
|
|
3
5
|
const initialsOf = (name) => name.split(/[\s@.]+/).filter(Boolean).slice(0, 2).map((part) => part[0].toUpperCase()).join("");
|
|
4
6
|
const rolesOf = (user) => (user.role ?? "user").split(",").map((role) => role.trim());
|
|
5
7
|
/** The user holds the `admin` role, among others perhaps. */
|
|
@@ -18,4 +20,4 @@ const userToShell = (user) => {
|
|
|
18
20
|
};
|
|
19
21
|
};
|
|
20
22
|
//#endregion
|
|
21
|
-
export { isAdmin, userToShell };
|
|
23
|
+
export { hueOf, initialsOf, isAdmin, userToShell };
|
|
@@ -15,11 +15,21 @@ export declare const useClient: () => {
|
|
|
15
15
|
etag?: string;
|
|
16
16
|
}) => Promise<void>;
|
|
17
17
|
undelete: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[]) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
|
|
18
|
+
generate: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], action: string) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
|
|
18
19
|
reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
|
|
19
20
|
history: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
|
|
20
21
|
pageSize?: number;
|
|
21
22
|
pageToken?: string;
|
|
22
23
|
}) => Promise<import("@protobase/client").HistoryPage>;
|
|
24
|
+
comments: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
|
|
25
|
+
pageSize?: number;
|
|
26
|
+
pageToken?: string;
|
|
27
|
+
}) => Promise<import("@protobase/client").CommentPage>;
|
|
28
|
+
comment: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { body, mentions }: {
|
|
29
|
+
body: string;
|
|
30
|
+
mentions?: string[];
|
|
31
|
+
}) => Promise<import("@protobase/client").RecordComment>;
|
|
32
|
+
mentionable: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], query: string) => Promise<import("@protobase/client").MentionableUser[]>;
|
|
23
33
|
batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
|
|
24
34
|
facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
|
|
25
35
|
filter?: import("@protobase/client").FilterInput;
|
|
@@ -63,11 +73,21 @@ export declare const useResourceClient: (resource: string) => {
|
|
|
63
73
|
etag?: string;
|
|
64
74
|
}) => Promise<void>;
|
|
65
75
|
undelete: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[]) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
|
|
76
|
+
generate: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], action: string) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
|
|
66
77
|
reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
|
|
67
78
|
history: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
|
|
68
79
|
pageSize?: number;
|
|
69
80
|
pageToken?: string;
|
|
70
81
|
}) => Promise<import("@protobase/client").HistoryPage>;
|
|
82
|
+
comments: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
|
|
83
|
+
pageSize?: number;
|
|
84
|
+
pageToken?: string;
|
|
85
|
+
}) => Promise<import("@protobase/client").CommentPage>;
|
|
86
|
+
comment: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { body, mentions }: {
|
|
87
|
+
body: string;
|
|
88
|
+
mentions?: string[];
|
|
89
|
+
}) => Promise<import("@protobase/client").RecordComment>;
|
|
90
|
+
mentionable: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], query: string) => Promise<import("@protobase/client").MentionableUser[]>;
|
|
71
91
|
batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
|
|
72
92
|
facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
|
|
73
93
|
filter?: import("@protobase/client").FilterInput;
|
package/dist/data/query-keys.js
CHANGED
|
@@ -66,6 +66,21 @@ const keys = {
|
|
|
66
66
|
"history",
|
|
67
67
|
pageSize
|
|
68
68
|
],
|
|
69
|
+
/** A record's comments; starts with `record`, so refreshing the record refreshes them too. */
|
|
70
|
+
comments: (resource, key) => [
|
|
71
|
+
"record",
|
|
72
|
+
resource,
|
|
73
|
+
key,
|
|
74
|
+
"comments"
|
|
75
|
+
],
|
|
76
|
+
/** The people a comment on a record may mention whose name contains `query`. */
|
|
77
|
+
mentionable: (resource, key, query) => [
|
|
78
|
+
"record",
|
|
79
|
+
resource,
|
|
80
|
+
key,
|
|
81
|
+
"mentionable",
|
|
82
|
+
query
|
|
83
|
+
],
|
|
69
84
|
facets: (resource, field, filter) => [
|
|
70
85
|
"facets",
|
|
71
86
|
resource,
|
package/dist/data-grid/cells.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
import { Avatar } from "../primitives/avatar.js";
|
|
3
3
|
import { Badge } from "../primitives/badge.js";
|
|
4
|
+
import { viewerZone, zoneLabel } from "../format/zone.js";
|
|
4
5
|
import { userById } from "../mocks/users.js";
|
|
5
6
|
import { fileShown, shownImage } from "../live/file-values.js";
|
|
6
7
|
import { cellText } from "./cell-text.js";
|
|
7
8
|
import { FileImage } from "../live/file-image.js";
|
|
9
|
+
import { ContactLink } from "../live/contact-link.js";
|
|
8
10
|
import { valueColor } from "../live/value-color.js";
|
|
9
11
|
import { TruncatedText } from "./truncated-text.js";
|
|
10
12
|
import { Check, Paperclip } from "lucide-react";
|
|
@@ -18,7 +20,11 @@ const renderCell = (spec, value) => {
|
|
|
18
20
|
if (value === null || value === void 0) return muted("—");
|
|
19
21
|
const text = cellText(spec, value) ?? "";
|
|
20
22
|
switch (spec.kind) {
|
|
21
|
-
case "text": return /* @__PURE__ */ jsx(
|
|
23
|
+
case "text": return spec.link ? /* @__PURE__ */ jsx(ContactLink, {
|
|
24
|
+
format: spec.link,
|
|
25
|
+
value: text,
|
|
26
|
+
className: "block min-w-0 truncate"
|
|
27
|
+
}) : /* @__PURE__ */ jsx(TruncatedText, { text });
|
|
22
28
|
case "id": return /* @__PURE__ */ jsx(TruncatedText, {
|
|
23
29
|
text,
|
|
24
30
|
className: "font-mono text-xs text-muted-foreground"
|
|
@@ -33,6 +39,7 @@ const renderCell = (spec, value) => {
|
|
|
33
39
|
});
|
|
34
40
|
case "datetime": return /* @__PURE__ */ jsx("span", {
|
|
35
41
|
className: "tabular-nums text-muted-foreground",
|
|
42
|
+
title: `Shown in ${zoneLabel(Number(value), viewerZone())}`,
|
|
36
43
|
children: text
|
|
37
44
|
});
|
|
38
45
|
case "number": return /* @__PURE__ */ jsx("span", {
|
|
@@ -2,15 +2,22 @@ import { cn } from "../lib/cn.js";
|
|
|
2
2
|
import { Checkbox } from "../primitives/checkbox.js";
|
|
3
3
|
import { gridTemplate } from "./grid-template.js";
|
|
4
4
|
import { isRightAligned } from "./column-spec.js";
|
|
5
|
-
import { HeaderMenu } from "./header-menu.js";
|
|
5
|
+
import { HeaderMenu, unsortableText } from "./header-menu.js";
|
|
6
6
|
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
|
|
7
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
//#region src/data-grid/grid-header.tsx
|
|
9
9
|
const SortIcon = ({ sorted }) => {
|
|
10
10
|
if (sorted === "asc") return /* @__PURE__ */ jsx(ArrowUp, { className: "size-3 text-foreground" });
|
|
11
11
|
if (sorted === "desc") return /* @__PURE__ */ jsx(ArrowDown, { className: "size-3 text-foreground" });
|
|
12
12
|
return /* @__PURE__ */ jsx(ChevronsUpDown, { className: "size-3 opacity-0 group-hover/head:opacity-100" });
|
|
13
13
|
};
|
|
14
|
+
const HeaderLabel = ({ spec }) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
15
|
+
className: "truncate",
|
|
16
|
+
children: spec.header
|
|
17
|
+
}), spec.highlight && /* @__PURE__ */ jsx("span", {
|
|
18
|
+
className: "rounded bg-primary px-1 text-[10px] font-semibold uppercase leading-4 text-primary-foreground",
|
|
19
|
+
children: "New"
|
|
20
|
+
})] });
|
|
14
21
|
const GridHeader = ({ cells, specs, gutter, selectedCount, selectable, trailing, minScale, pins = {}, lastPinned, scrolled, onTogglePin, onClearSelection }) => /* @__PURE__ */ jsxs("div", {
|
|
15
22
|
className: "grid h-8 shrink-0 items-center border-b bg-surface text-xs font-medium text-muted-foreground",
|
|
16
23
|
style: { gridTemplateColumns: gridTemplate(specs, gutter, trailing, minScale) },
|
|
@@ -37,27 +44,21 @@ const GridHeader = ({ cells, specs, gutter, selectedCount, selectable, trailing,
|
|
|
37
44
|
"data-col-id": spec.id,
|
|
38
45
|
style: pinned ? { left: pins[spec.id] } : void 0,
|
|
39
46
|
className: cn("group/cell flex h-full min-w-0 items-center", pinned && "sticky z-10 bg-inherit", pinned && scrolled && spec.id === lastPinned && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]", spec.highlight && "bg-primary-soft text-primary-text"),
|
|
40
|
-
children: [/* @__PURE__ */ jsxs("button", {
|
|
47
|
+
children: [spec.sortable ? /* @__PURE__ */ jsxs("button", {
|
|
41
48
|
type: "button",
|
|
42
49
|
onClick: onSort,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}),
|
|
50
|
-
spec.highlight && /* @__PURE__ */ jsx("span", {
|
|
51
|
-
className: "rounded bg-primary px-1 text-[10px] font-semibold uppercase leading-4 text-primary-foreground",
|
|
52
|
-
children: "New"
|
|
53
|
-
}),
|
|
54
|
-
spec.sortable && /* @__PURE__ */ jsx(SortIcon, { sorted })
|
|
55
|
-
]
|
|
50
|
+
className: cn("group/head flex h-full min-w-0 flex-1 items-center gap-1 pl-3 pr-1 text-left", isRightAligned(spec.kind) && "justify-end", "hover:text-foreground", sorted && "text-foreground"),
|
|
51
|
+
children: [/* @__PURE__ */ jsx(HeaderLabel, { spec }), /* @__PURE__ */ jsx(SortIcon, { sorted })]
|
|
52
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
53
|
+
title: unsortableText,
|
|
54
|
+
className: cn("flex h-full min-w-0 flex-1 items-center gap-1 pl-3 pr-1 text-left", isRightAligned(spec.kind) && "justify-end"),
|
|
55
|
+
children: /* @__PURE__ */ jsx(HeaderLabel, { spec })
|
|
56
56
|
}), /* @__PURE__ */ jsx("div", {
|
|
57
57
|
className: "pr-1.5",
|
|
58
58
|
children: /* @__PURE__ */ jsx(HeaderMenu, {
|
|
59
59
|
header: spec.header,
|
|
60
60
|
pinned,
|
|
61
|
+
sortable: Boolean(spec.sortable),
|
|
61
62
|
onTogglePin: () => onTogglePin(spec.id)
|
|
62
63
|
})
|
|
63
64
|
})]
|
|
@@ -2,8 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import { MoreHorizontal } from "lucide-react";
|
|
3
3
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
4
|
//#region src/data-grid/header-menu.tsx
|
|
5
|
-
/**
|
|
6
|
-
const
|
|
5
|
+
/** Why a column header does nothing when pressed: a list sorts only on the columns its view makes sortable. */
|
|
6
|
+
const unsortableText = "This column can't be sorted";
|
|
7
|
+
/** The "..." in a column header: pinning the column to the left edge, and saying so when the list can't sort by it. */
|
|
8
|
+
const HeaderMenu = ({ header, pinned, sortable, onTogglePin }) => {
|
|
7
9
|
const [open, setOpen] = useState(false);
|
|
8
10
|
const root = useRef(null);
|
|
9
11
|
useEffect(() => {
|
|
@@ -29,10 +31,18 @@ const HeaderMenu = ({ header, pinned, onTogglePin }) => {
|
|
|
29
31
|
onClick: () => setOpen(!open),
|
|
30
32
|
className: "relative inline-flex size-5 items-center justify-center rounded text-faint-foreground hover:bg-muted hover:text-foreground focus-visible:opacity-100 after:absolute after:-inset-2 after:content-[''] md:opacity-0 md:group-hover/cell:opacity-100 aria-expanded:opacity-100",
|
|
31
33
|
children: /* @__PURE__ */ jsx(MoreHorizontal, { className: "size-3.5" })
|
|
32
|
-
}), open && /* @__PURE__ */
|
|
34
|
+
}), open && /* @__PURE__ */ jsxs("ul", {
|
|
33
35
|
role: "menu",
|
|
34
36
|
className: "absolute right-0 top-full z-30 mt-1 min-w-32 rounded-md border bg-background py-1 text-body font-normal text-foreground shadow-pop",
|
|
35
|
-
children: /* @__PURE__ */ jsx("li", {
|
|
37
|
+
children: [!sortable && /* @__PURE__ */ jsx("li", {
|
|
38
|
+
role: "none",
|
|
39
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
40
|
+
role: "menuitem",
|
|
41
|
+
"aria-disabled": "true",
|
|
42
|
+
className: "block min-h-9 whitespace-nowrap px-3 py-1.5 text-muted-foreground",
|
|
43
|
+
children: "This column can't be sorted"
|
|
44
|
+
})
|
|
45
|
+
}), /* @__PURE__ */ jsx("li", {
|
|
36
46
|
role: "none",
|
|
37
47
|
children: /* @__PURE__ */ jsx("button", {
|
|
38
48
|
type: "button",
|
|
@@ -44,9 +54,9 @@ const HeaderMenu = ({ header, pinned, onTogglePin }) => {
|
|
|
44
54
|
},
|
|
45
55
|
children: pinned ? "Unpin" : "Pin to left"
|
|
46
56
|
})
|
|
47
|
-
})
|
|
57
|
+
})]
|
|
48
58
|
})]
|
|
49
59
|
});
|
|
50
60
|
};
|
|
51
61
|
//#endregion
|
|
52
|
-
export { HeaderMenu };
|
|
62
|
+
export { HeaderMenu, unsortableText };
|
|
@@ -1,55 +1,19 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
|
-
import {
|
|
2
|
+
import { RouteLink } from "../primitives/route-link.js";
|
|
3
|
+
import { usePopoverMenu } from "../primitives/popover-menu.js";
|
|
3
4
|
import { MoreHorizontal } from "lucide-react";
|
|
4
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
6
|
import { createPortal } from "react-dom";
|
|
6
7
|
//#region src/data-grid/row-menu.tsx
|
|
7
8
|
/** The actions that apply to a row; a row with none gets no menu at all. */
|
|
8
9
|
const availableActions = (actions, row) => actions.filter((action) => action.available?.(row) ?? true);
|
|
9
|
-
const gap = 4;
|
|
10
|
-
/** Below the button, or above it when the viewport has no room underneath. */
|
|
11
|
-
const placeMenu = (button, menuHeight) => {
|
|
12
|
-
const below = button.bottom + gap;
|
|
13
|
-
return {
|
|
14
|
-
top: below + menuHeight > window.innerHeight - gap ? Math.max(gap, button.top - gap - menuHeight) : below,
|
|
15
|
-
right: window.innerWidth - button.right
|
|
16
|
-
};
|
|
17
|
-
};
|
|
18
10
|
/**
|
|
19
11
|
* The "..." button at the end of a row, with a small menu of the row's actions. The menu is portalled to the body:
|
|
20
12
|
* every grid row is its own stacking context and the grid body clips, so a menu inside the row would sit under the
|
|
21
13
|
* rows below it.
|
|
22
14
|
*/
|
|
23
15
|
const RowMenu = ({ row, actions }) => {
|
|
24
|
-
const
|
|
25
|
-
const [place, setPlace] = useState();
|
|
26
|
-
const button = useRef(null);
|
|
27
|
-
const menu = useRef(null);
|
|
28
|
-
useLayoutEffect(() => {
|
|
29
|
-
if (!open || !button.current || !menu.current) {
|
|
30
|
-
setPlace(void 0);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
setPlace(placeMenu(button.current.getBoundingClientRect(), menu.current.offsetHeight));
|
|
34
|
-
}, [open]);
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
if (!open) return;
|
|
37
|
-
const inside = (target) => button.current?.contains(target) || menu.current?.contains(target);
|
|
38
|
-
const close = (event) => {
|
|
39
|
-
if (event instanceof KeyboardEvent ? event.key === "Escape" : !inside(event.target)) setOpen(false);
|
|
40
|
-
};
|
|
41
|
-
const dismiss = () => setOpen(false);
|
|
42
|
-
document.addEventListener("mousedown", close);
|
|
43
|
-
document.addEventListener("keydown", close);
|
|
44
|
-
document.addEventListener("scroll", dismiss, true);
|
|
45
|
-
window.addEventListener("resize", dismiss);
|
|
46
|
-
return () => {
|
|
47
|
-
document.removeEventListener("mousedown", close);
|
|
48
|
-
document.removeEventListener("keydown", close);
|
|
49
|
-
document.removeEventListener("scroll", dismiss, true);
|
|
50
|
-
window.removeEventListener("resize", dismiss);
|
|
51
|
-
};
|
|
52
|
-
}, [open]);
|
|
16
|
+
const { open, setOpen, place, button, menu } = usePopoverMenu();
|
|
53
17
|
return /* @__PURE__ */ jsxs("div", {
|
|
54
18
|
className: "relative flex justify-center bg-inherit",
|
|
55
19
|
onClick: (event) => event.stopPropagation(),
|
|
@@ -70,7 +34,13 @@ const RowMenu = ({ row, actions }) => {
|
|
|
70
34
|
className: "fixed z-50 min-w-36 rounded-md border bg-background py-1 shadow-pop",
|
|
71
35
|
children: actions.map((action) => /* @__PURE__ */ jsx("li", {
|
|
72
36
|
role: "none",
|
|
73
|
-
children: /* @__PURE__ */ jsx(
|
|
37
|
+
children: action.link ? /* @__PURE__ */ jsx(RouteLink, {
|
|
38
|
+
role: "menuitem",
|
|
39
|
+
to: action.link(row),
|
|
40
|
+
onClick: () => setOpen(false),
|
|
41
|
+
className: cn("block w-full px-3 py-1.5 text-left hover:bg-muted", action.destructive && "text-danger-text"),
|
|
42
|
+
children: action.label
|
|
43
|
+
}) : /* @__PURE__ */ jsx("button", {
|
|
74
44
|
type: "button",
|
|
75
45
|
role: "menuitem",
|
|
76
46
|
className: cn("block w-full px-3 py-1.5 text-left hover:bg-muted", action.destructive && "text-danger-text"),
|
package/dist/format/date.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { viewerZone } from "./zone.js";
|
|
1
2
|
//#region src/format/date.ts
|
|
2
3
|
const day = new Intl.DateTimeFormat("en-GB", {
|
|
3
4
|
day: "numeric",
|
|
@@ -10,15 +11,60 @@ const dayShort = new Intl.DateTimeFormat("en-GB", {
|
|
|
10
11
|
month: "short",
|
|
11
12
|
timeZone: "UTC"
|
|
12
13
|
});
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
const month = new Intl.DateTimeFormat("en-GB", {
|
|
15
|
+
month: "long",
|
|
16
|
+
year: "numeric",
|
|
17
|
+
timeZone: "UTC"
|
|
18
|
+
});
|
|
19
|
+
const weekday = new Intl.DateTimeFormat("en-GB", {
|
|
20
|
+
weekday: "short",
|
|
21
|
+
timeZone: "UTC"
|
|
22
|
+
});
|
|
23
|
+
const weekdayDate = new Intl.DateTimeFormat("en-GB", {
|
|
24
|
+
weekday: "long",
|
|
25
|
+
day: "numeric",
|
|
26
|
+
month: "short",
|
|
27
|
+
year: "numeric",
|
|
17
28
|
timeZone: "UTC"
|
|
18
29
|
});
|
|
19
30
|
const formatDate = (ms) => day.format(ms);
|
|
20
31
|
const formatDayShort = (ms) => dayShort.format(ms);
|
|
21
|
-
const
|
|
32
|
+
const zoned = /* @__PURE__ */ new Map();
|
|
33
|
+
const zonedFormats = (zone) => {
|
|
34
|
+
const known = zoned.get(zone);
|
|
35
|
+
if (known) return known;
|
|
36
|
+
const formats = {
|
|
37
|
+
day: new Intl.DateTimeFormat("en-GB", {
|
|
38
|
+
day: "numeric",
|
|
39
|
+
month: "short",
|
|
40
|
+
year: "numeric",
|
|
41
|
+
timeZone: zone
|
|
42
|
+
}),
|
|
43
|
+
time: new Intl.DateTimeFormat("en-GB", {
|
|
44
|
+
hour: "2-digit",
|
|
45
|
+
minute: "2-digit",
|
|
46
|
+
hour12: false,
|
|
47
|
+
timeZone: zone
|
|
48
|
+
})
|
|
49
|
+
};
|
|
50
|
+
zoned.set(zone, formats);
|
|
51
|
+
return formats;
|
|
52
|
+
};
|
|
53
|
+
/** "6 Oct 2026, 16:32": an instant on the clock of `zone`, the viewer's unless given. */
|
|
54
|
+
const formatDateTime = (ms, zone = viewerZone()) => {
|
|
55
|
+
const formats = zonedFormats(zone);
|
|
56
|
+
return `${formats.day.format(ms)}, ${formats.time.format(ms)}`;
|
|
57
|
+
};
|
|
58
|
+
/** "October 2026". */
|
|
59
|
+
const formatMonth = (ms) => month.format(ms);
|
|
60
|
+
/** "Mon". */
|
|
61
|
+
const formatWeekday = (ms) => weekday.format(ms);
|
|
62
|
+
/** "Monday, 12 Oct 2026". */
|
|
63
|
+
const formatWeekdayDate = (ms) => weekdayDate.format(ms);
|
|
64
|
+
/** "12–18 Oct 2026", "28 Dec 2026 – 3 Jan 2027": the days from `from` to `to`, both included. */
|
|
65
|
+
const formatDayRange = (from, to) => day.formatRange(from, to);
|
|
66
|
+
/** "09:30": an instant's time on the clock of `zone`, the viewer's unless given. */
|
|
67
|
+
const formatTime = (ms, zone = viewerZone()) => zonedFormats(zone).time.format(ms);
|
|
22
68
|
const formatAgo = (seconds) => {
|
|
23
69
|
if (seconds < 60) return `${seconds} s ago`;
|
|
24
70
|
if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
|
|
@@ -40,4 +86,4 @@ const formatRelativeTime = (seconds) => {
|
|
|
40
86
|
return relative.format(-Math.floor(seconds / size), unit);
|
|
41
87
|
};
|
|
42
88
|
//#endregion
|
|
43
|
-
export { formatAgo, formatDate, formatDateTime, formatDayShort, formatRelativeTime };
|
|
89
|
+
export { formatAgo, formatDate, formatDateTime, formatDayRange, formatDayShort, formatMonth, formatRelativeTime, formatTime, formatWeekday, formatWeekdayDate };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/format/number-text.ts
|
|
2
|
+
/** The decimal separator `locale` writes, the browser's language unless given: `,` for nl-NL and de-DE, `.` for en-GB. */
|
|
3
|
+
const localeDecimal = (locale) => new Intl.NumberFormat(locale).formatToParts(1.5).find((part) => part.type === "decimal")?.value === "," ? "," : ".";
|
|
4
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
|
+
/**
|
|
6
|
+
* A number written as text, as a plain decimal (`-1234.5`): digits grouped by thousands with the other separator or
|
|
7
|
+
* spaces, and the `decimal` separator before the fraction. Undefined when the text is no such number.
|
|
8
|
+
*/
|
|
9
|
+
const parseNumberText = (text, decimal) => {
|
|
10
|
+
const group = `[${escapeRegExp(decimal === "." ? "," : ".")}\\s\\u00a0\\u202f']`;
|
|
11
|
+
const match = new RegExp(`^([-+]?)(\\d{1,3}(?:${group}\\d{3})+|\\d+)(?:${escapeRegExp(decimal)}(\\d+))?$`).exec(text.trim());
|
|
12
|
+
if (match) return `${match[1] === "-" ? "-" : ""}${match[2].replace(/\D/g, "")}${match[3] ? `.${match[3]}` : ""}`;
|
|
13
|
+
if (decimal === "." && /^[-+]?\d+(\.\d+)?e[-+]?\d+$/i.test(text.trim())) return String(Number(text.trim()));
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { localeDecimal, parseNumberText };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//#region src/format/zone.ts
|
|
2
|
+
/** The viewer's IANA time zone, such as `Europe/Amsterdam`. */
|
|
3
|
+
const viewerZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
4
|
+
const partsFormats = /* @__PURE__ */ new Map();
|
|
5
|
+
const partsFormat = (zone) => {
|
|
6
|
+
const known = partsFormats.get(zone);
|
|
7
|
+
if (known) return known;
|
|
8
|
+
const format = new Intl.DateTimeFormat("en-US", {
|
|
9
|
+
timeZone: zone,
|
|
10
|
+
hourCycle: "h23",
|
|
11
|
+
year: "numeric",
|
|
12
|
+
month: "2-digit",
|
|
13
|
+
day: "2-digit",
|
|
14
|
+
hour: "2-digit",
|
|
15
|
+
minute: "2-digit",
|
|
16
|
+
second: "2-digit"
|
|
17
|
+
});
|
|
18
|
+
partsFormats.set(zone, format);
|
|
19
|
+
return format;
|
|
20
|
+
};
|
|
21
|
+
const wallClock = (ms, zone) => {
|
|
22
|
+
const parts = Object.fromEntries(partsFormat(zone).formatToParts(ms).map((part) => [part.type, part.value]));
|
|
23
|
+
return {
|
|
24
|
+
year: Number(parts.year),
|
|
25
|
+
month: Number(parts.month),
|
|
26
|
+
day: Number(parts.day),
|
|
27
|
+
hour: Number(parts.hour),
|
|
28
|
+
minute: Number(parts.minute),
|
|
29
|
+
second: Number(parts.second)
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/** How far `zone`'s clock is ahead of UTC at `ms`, in milliseconds: 7 200 000 for Amsterdam in summer. */
|
|
33
|
+
const zoneOffset = (ms, zone) => {
|
|
34
|
+
const clock = wallClock(ms, zone);
|
|
35
|
+
return Date.UTC(clock.year, clock.month - 1, clock.day, clock.hour, clock.minute, clock.second) - Math.floor(ms / 1e3) * 1e3;
|
|
36
|
+
};
|
|
37
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
38
|
+
/** The day `ms` falls on in `zone`, as `YYYY-MM-DD`. */
|
|
39
|
+
const dayIn = (ms, zone) => {
|
|
40
|
+
const clock = wallClock(ms, zone);
|
|
41
|
+
return `${clock.year}-${pad(clock.month)}-${pad(clock.day)}`;
|
|
42
|
+
};
|
|
43
|
+
/** The instant `zone`'s clock reads `wall`, a wall-clock time written as if it were UTC milliseconds. */
|
|
44
|
+
const instantAt = (wall, zone) => {
|
|
45
|
+
const guess = wall - zoneOffset(wall, zone);
|
|
46
|
+
return wall - zoneOffset(guess, zone);
|
|
47
|
+
};
|
|
48
|
+
/** The instant `day` (`YYYY-MM-DD`) starts in `zone`: its local midnight. */
|
|
49
|
+
const startOfDayIn = (day, zone) => instantAt(Date.parse(`${day}T00:00:00Z`), zone);
|
|
50
|
+
const zoneNames = /* @__PURE__ */ new Map();
|
|
51
|
+
/** The zone a time is shown in, as its hover label says: "Europe/Amsterdam (GMT+2)", or "UTC". */
|
|
52
|
+
const zoneLabel = (ms, zone) => {
|
|
53
|
+
const known = zoneNames.get(zone) ?? new Intl.DateTimeFormat("en-GB", {
|
|
54
|
+
timeZone: zone,
|
|
55
|
+
timeZoneName: "shortOffset"
|
|
56
|
+
});
|
|
57
|
+
zoneNames.set(zone, known);
|
|
58
|
+
const name = known.formatToParts(ms).find((part) => part.type === "timeZoneName")?.value ?? zone;
|
|
59
|
+
return zone === "UTC" || zone === "Etc/UTC" ? "UTC" : `${zone} (${name})`;
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
export { dayIn, instantAt, startOfDayIn, viewerZone, zoneLabel, zoneOffset };
|
|
@@ -3,6 +3,7 @@ import { badgeWidth, headerWidth } from "../data-grid/content-widths.js";
|
|
|
3
3
|
import { markdownExcerpt } from "../markdown/markdown-excerpt.js";
|
|
4
4
|
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
5
5
|
import { recordTitleField } from "./model-helpers.js";
|
|
6
|
+
import { contactFormat } from "./contact-links.js";
|
|
6
7
|
import { enumLabel } from "@protobase/schema";
|
|
7
8
|
//#region src/live/columns-from-model.ts
|
|
8
9
|
const tones = {
|
|
@@ -82,6 +83,7 @@ const columnsFromModel = (resource, view, highlight = [], names = listColumnName
|
|
|
82
83
|
minWidth: Math.max(kind === "id" ? 150 : 0, headerWidth(viewField?.label ?? humanize(name), field.sortable), kind === "status" ? badgeWidth(Object.values(labels)) : 0, suffixWidth > 0 ? widths[kind] + suffixWidth : 0),
|
|
83
84
|
...fixedKinds.has(kind) && { fixed: true },
|
|
84
85
|
...name === title && { title: true },
|
|
86
|
+
...kind === "text" && name !== title && contactFormat(viewField) && { link: contactFormat(viewField) },
|
|
85
87
|
...highlight.includes(name) && { highlight: true },
|
|
86
88
|
...field.type === "enum" && {
|
|
87
89
|
tones: Object.fromEntries((field.enumValues ?? []).map((value) => [value, toneFor(value)])),
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { contactHref } from "./contact-links.js";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/live/contact-link.tsx
|
|
5
|
+
const stayInRow = (event) => event.stopPropagation();
|
|
6
|
+
/** A value that is an address as a link that writes to, calls or opens it, a web page in a new tab; else its text. */
|
|
7
|
+
const ContactLink = ({ format, value, className }) => {
|
|
8
|
+
const href = contactHref(format, value);
|
|
9
|
+
if (!href) return /* @__PURE__ */ jsx("span", {
|
|
10
|
+
className,
|
|
11
|
+
children: value
|
|
12
|
+
});
|
|
13
|
+
return /* @__PURE__ */ jsx("a", {
|
|
14
|
+
href,
|
|
15
|
+
...format === "url" ? {
|
|
16
|
+
target: "_blank",
|
|
17
|
+
rel: "noreferrer"
|
|
18
|
+
} : {},
|
|
19
|
+
onClick: stayInRow,
|
|
20
|
+
className: cn("font-medium text-primary-text hover:underline", className),
|
|
21
|
+
children: value
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { ContactLink };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//#region src/live/contact-links.ts
|
|
2
|
+
const contactFormats = /* @__PURE__ */ new Set([
|
|
3
|
+
"email",
|
|
4
|
+
"phone",
|
|
5
|
+
"url"
|
|
6
|
+
]);
|
|
7
|
+
/** The view's contact format for a field, if it has one. */
|
|
8
|
+
const contactFormat = (hints) => hints?.format !== void 0 && contactFormats.has(hints.format) ? hints.format : void 0;
|
|
9
|
+
/**
|
|
10
|
+
* Where a value links: `mailto:` an email address, `tel:` a phone number with only its digits and a leading +, and a
|
|
11
|
+
* web address as it is when it is http or https. Nothing for a value that is no such address, which shows as text.
|
|
12
|
+
*/
|
|
13
|
+
const contactHref = (format, value) => {
|
|
14
|
+
const text = value.trim();
|
|
15
|
+
if (format === "email") return /^[^\s@]+@[^\s@]+$/.test(text) ? `mailto:${text}` : void 0;
|
|
16
|
+
if (format === "phone") {
|
|
17
|
+
const digits = text.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
|
|
18
|
+
return /\d/.test(digits) ? `tel:${digits}` : void 0;
|
|
19
|
+
}
|
|
20
|
+
return /^https?:\/\/\S+$/i.test(text) ? text : void 0;
|
|
21
|
+
};
|
|
22
|
+
/** The input a contact field is edited in: its type, so a mobile screen shows the keyboard for it, and its autofill. */
|
|
23
|
+
const contactInputProps = (format) => {
|
|
24
|
+
if (format === "email") return {
|
|
25
|
+
type: "email",
|
|
26
|
+
inputMode: "email",
|
|
27
|
+
autoComplete: "email"
|
|
28
|
+
};
|
|
29
|
+
if (format === "phone") return {
|
|
30
|
+
type: "tel",
|
|
31
|
+
inputMode: "tel",
|
|
32
|
+
autoComplete: "tel"
|
|
33
|
+
};
|
|
34
|
+
if (format === "url") return {
|
|
35
|
+
type: "url",
|
|
36
|
+
inputMode: "url",
|
|
37
|
+
autoComplete: "url"
|
|
38
|
+
};
|
|
39
|
+
return {};
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
export { contactFormat, contactHref, contactInputProps };
|