@protobase/ui 0.21.8 → 0.21.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/account/account-security-page.js +5 -4
  2. package/dist/app/app.js +1 -0
  3. package/dist/app/charts/chart-view.js +3 -1
  4. package/dist/app/displays/calendar-controls.js +2 -1
  5. package/dist/app/displays/calendar-display.js +2 -1
  6. package/dist/app/displays/calendar-event.js +2 -1
  7. package/dist/app/displays/calendar-range.js +18 -14
  8. package/dist/app/export/cells.js +6 -4
  9. package/dist/app/field-editor.js +13 -1
  10. package/dist/app/import/convert.js +2 -12
  11. package/dist/app/import/locale-formats.js +2 -1
  12. package/dist/app/incoming-card.js +35 -0
  13. package/dist/app/incoming-relations.js +36 -0
  14. package/dist/app/list-chart.js +3 -1
  15. package/dist/app/pages/field-value.js +9 -1
  16. package/dist/app/pages/record-actions.js +34 -16
  17. package/dist/app/record-page.js +26 -18
  18. package/dist/app/runtime/runtime-status.js +5 -4
  19. package/dist/app/search/navigation-hits.js +45 -0
  20. package/dist/app/search/use-global-search.js +13 -4
  21. package/dist/app/use-incoming-counts.js +38 -0
  22. package/dist/app/use-related-records.js +3 -10
  23. package/dist/app-shell/app-shell.js +9 -2
  24. package/dist/app-shell/index.d.ts +1 -0
  25. package/dist/app-shell/logo.d.ts +5 -1
  26. package/dist/app-shell/logo.js +10 -3
  27. package/dist/app-shell/sidebar.js +3 -2
  28. package/dist/app-shell/top-bar-slot.js +11 -0
  29. package/dist/app-shell/top-bar.d.ts +3 -0
  30. package/dist/app-shell/top-bar.js +27 -19
  31. package/dist/auth/auth-messages.js +2 -1
  32. package/dist/auth/auth-provider.js +8 -3
  33. package/dist/data-grid/cells.js +8 -1
  34. package/dist/data-grid/grid-header.js +17 -16
  35. package/dist/data-grid/header-menu.js +16 -6
  36. package/dist/data-grid/row-menu.js +2 -39
  37. package/dist/format/date.js +29 -9
  38. package/dist/format/number-text.js +16 -0
  39. package/dist/format/zone.js +62 -0
  40. package/dist/live/columns-from-model.js +2 -0
  41. package/dist/live/contact-link.js +25 -0
  42. package/dist/live/contact-links.js +42 -0
  43. package/dist/live/display-value.js +14 -4
  44. package/dist/live/field-values.js +20 -9
  45. package/dist/live/filter-string.js +30 -30
  46. package/dist/package.js +1 -1
  47. package/dist/primitives/popover-menu.js +56 -0
  48. package/dist/record-view/action-menu.js +59 -0
  49. package/dist/record-view/record-header.js +2 -6
  50. package/dist/record-view/record-sidebar.js +2 -30
  51. package/dist/shell/assets/app-9imuOKuW.js +69 -0
  52. package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
  53. package/dist/shell/assets/{index-xN6_oZU2.js → index-C_o0i6I2.js} +1 -1
  54. package/dist/shell/assets/index-CzqIlZPP.css +2 -0
  55. package/dist/shell/assets/{maplibre-Ba9RapjY.js → maplibre-DLYsHwYU.js} +1 -1
  56. package/dist/shell/assets/{markdown-content-BUxrbovf.js → markdown-content-B410ow-t.js} +1 -1
  57. package/dist/shell/assets/{record-map-14zotZ2s.js → record-map-DvgWFyLn.js} +1 -1
  58. package/dist/shell/assets/{records-map-wi-0pXqF.js → records-map-CLj0uHGq.js} +1 -1
  59. package/dist/shell/assets/{shared-protobase-ui-BVwhKZk9.js → shared-protobase-ui-Bf1h2ktk.js} +1 -1
  60. package/dist/shell/index.html +4 -4
  61. package/dist/shell/protobase.shell.json +1 -1
  62. package/package.json +4 -4
  63. package/dist/shell/assets/app-C01lazBK.js +0 -69
  64. package/dist/shell/assets/export-worker-BnOpS_4Q.js +0 -2
  65. package/dist/shell/assets/index-CBGigFxb.css +0 -2
@@ -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
- disabled: !spec.sortable,
44
- 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", spec.sortable && "hover:text-foreground", sorted && "text-foreground"),
45
- children: [
46
- /* @__PURE__ */ jsx("span", {
47
- className: "truncate",
48
- children: spec.header
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
- /** The "..." in a column header: for now, pinning the column to the left edge. */
6
- const HeaderMenu = ({ header, pinned, onTogglePin }) => {
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__ */ jsx("ul", {
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,56 +1,19 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { RouteLink } from "../primitives/route-link.js";
3
- import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ import { usePopoverMenu } from "../primitives/popover-menu.js";
4
4
  import { MoreHorizontal } from "lucide-react";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  import { createPortal } from "react-dom";
7
7
  //#region src/data-grid/row-menu.tsx
8
8
  /** The actions that apply to a row; a row with none gets no menu at all. */
9
9
  const availableActions = (actions, row) => actions.filter((action) => action.available?.(row) ?? true);
10
- const gap = 4;
11
- /** Below the button, or above it when the viewport has no room underneath. */
12
- const placeMenu = (button, menuHeight) => {
13
- const below = button.bottom + gap;
14
- return {
15
- top: below + menuHeight > window.innerHeight - gap ? Math.max(gap, button.top - gap - menuHeight) : below,
16
- right: window.innerWidth - button.right
17
- };
18
- };
19
10
  /**
20
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:
21
12
  * every grid row is its own stacking context and the grid body clips, so a menu inside the row would sit under the
22
13
  * rows below it.
23
14
  */
24
15
  const RowMenu = ({ row, actions }) => {
25
- const [open, setOpen] = useState(false);
26
- const [place, setPlace] = useState();
27
- const button = useRef(null);
28
- const menu = useRef(null);
29
- useLayoutEffect(() => {
30
- if (!open || !button.current || !menu.current) {
31
- setPlace(void 0);
32
- return;
33
- }
34
- setPlace(placeMenu(button.current.getBoundingClientRect(), menu.current.offsetHeight));
35
- }, [open]);
36
- useEffect(() => {
37
- if (!open) return;
38
- const inside = (target) => button.current?.contains(target) || menu.current?.contains(target);
39
- const close = (event) => {
40
- if (event instanceof KeyboardEvent ? event.key === "Escape" : !inside(event.target)) setOpen(false);
41
- };
42
- const dismiss = () => setOpen(false);
43
- document.addEventListener("mousedown", close);
44
- document.addEventListener("keydown", close);
45
- document.addEventListener("scroll", dismiss, true);
46
- window.addEventListener("resize", dismiss);
47
- return () => {
48
- document.removeEventListener("mousedown", close);
49
- document.removeEventListener("keydown", close);
50
- document.removeEventListener("scroll", dismiss, true);
51
- window.removeEventListener("resize", dismiss);
52
- };
53
- }, [open]);
16
+ const { open, setOpen, place, button, menu } = usePopoverMenu();
54
17
  return /* @__PURE__ */ jsxs("div", {
55
18
  className: "relative flex justify-center bg-inherit",
56
19
  onClick: (event) => event.stopPropagation(),
@@ -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,12 +11,6 @@ const dayShort = new Intl.DateTimeFormat("en-GB", {
10
11
  month: "short",
11
12
  timeZone: "UTC"
12
13
  });
13
- const time = new Intl.DateTimeFormat("en-GB", {
14
- hour: "2-digit",
15
- minute: "2-digit",
16
- hour12: false,
17
- timeZone: "UTC"
18
- });
19
14
  const month = new Intl.DateTimeFormat("en-GB", {
20
15
  month: "long",
21
16
  year: "numeric",
@@ -34,7 +29,32 @@ const weekdayDate = new Intl.DateTimeFormat("en-GB", {
34
29
  });
35
30
  const formatDate = (ms) => day.format(ms);
36
31
  const formatDayShort = (ms) => dayShort.format(ms);
37
- const formatDateTime = (ms) => `${day.format(ms)}, ${time.format(ms)}`;
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
+ };
38
58
  /** "October 2026". */
39
59
  const formatMonth = (ms) => month.format(ms);
40
60
  /** "Mon". */
@@ -43,8 +63,8 @@ const formatWeekday = (ms) => weekday.format(ms);
43
63
  const formatWeekdayDate = (ms) => weekdayDate.format(ms);
44
64
  /** "12–18 Oct 2026", "28 Dec 2026 – 3 Jan 2027": the days from `from` to `to`, both included. */
45
65
  const formatDayRange = (from, to) => day.formatRange(from, to);
46
- /** "09:30". */
47
- const formatTime = (ms) => time.format(ms);
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);
48
68
  const formatAgo = (seconds) => {
49
69
  if (seconds < 60) return `${seconds} s ago`;
50
70
  if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
@@ -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 };
@@ -1,16 +1,20 @@
1
+ import { viewerZone, zoneLabel } from "../format/zone.js";
1
2
  import { formatDate, formatDateTime } from "../format/date.js";
2
3
  import { fileShown } from "./file-values.js";
3
4
  import { enumLabel } from "@protobase/schema";
4
5
  //#region src/live/display-value.ts
5
6
  const affixed = (text, hints) => `${hints.prefix ?? ""}${text}${hints.suffix ?? ""}`;
6
- /** A read-only rendering of a stored value, following the view's prefix, suffix, decimals, format and value label hints. */
7
- const displayValue = (field, value, hints = {}) => {
7
+ /**
8
+ * A read-only rendering of a stored value, following the view's prefix, suffix, decimals, format and value label hints; a
9
+ * timestamp on the clock of `zone`, the viewer's unless given.
10
+ */
11
+ const displayValue = (field, value, hints = {}, zone = viewerZone()) => {
8
12
  if (value === null || value === void 0 || value === "") return "—";
9
13
  switch (field.type) {
10
14
  case "boolean": return value ? "Yes" : "No";
11
15
  case "file": return fileShown(value)?.name ?? "—";
12
16
  case "enum": return enumLabel(String(value), hints.valueLabels);
13
- case "timestamp": return formatDateTime(Date.parse(String(value)));
17
+ case "timestamp": return formatDateTime(Date.parse(String(value)), zone);
14
18
  case "date": return formatDate(Date.parse(String(value)));
15
19
  case "decimal": {
16
20
  const text = hints.decimals === void 0 ? String(value) : Number(value).toFixed(hints.decimals);
@@ -25,5 +29,11 @@ const displayValue = (field, value, hints = {}) => {
25
29
  default: return typeof value === "object" ? JSON.stringify(value) : String(value);
26
30
  }
27
31
  };
32
+ /** The hover label of a shown value: the time zone a timestamp is shown in, nothing for other values. */
33
+ const valueTitle = (field, value, zone = viewerZone()) => {
34
+ if (field.type !== "timestamp" || typeof value !== "string") return void 0;
35
+ const ms = Date.parse(value);
36
+ return Number.isNaN(ms) ? void 0 : `Shown in ${zoneLabel(ms, zone)}`;
37
+ };
28
38
  //#endregion
29
- export { displayValue };
39
+ export { displayValue, valueTitle };
@@ -1,3 +1,4 @@
1
+ import { localeDecimal, parseNumberText } from "../format/number-text.js";
1
2
  import { fileDiffers, filePatchValue } from "./file-values.js";
2
3
  //#region src/live/field-values.ts
3
4
  /** What the editor's text input holds for a stored value. */
@@ -14,32 +15,42 @@ const textEdited = /* @__PURE__ */ new Set([
14
15
  const isEditable = (field) => !field.readOnly && field.type !== "json" && field.type !== "uuid" && field.type !== "timestamp" && field.type !== "relation";
15
16
  /** A field a record's form cannot leave empty: written through the API, never null and no boolean, which always holds a value. */
16
17
  const isRequiredOnEdit = (field) => !field.readOnly && !field.nullable && field.type !== "boolean";
17
- /** The JSON value to send for an edited field: integers become numbers, empty optional fields become null. */
18
- const toPatchValue = (field, draft) => {
18
+ const numeric = /* @__PURE__ */ new Set([
19
+ "integer",
20
+ "bigint",
21
+ "decimal"
22
+ ]);
23
+ const numberText = (text, decimal) => parseNumberText(text, decimal) ?? text;
24
+ /**
25
+ * The JSON value to send for an edited field: integers become numbers, empty optional fields become null, and numbers
26
+ * typed with the `decimal` separator of the browser's language are read as such.
27
+ */
28
+ const toPatchValue = (field, draft, decimal = localeDecimal()) => {
19
29
  if (field.type === "boolean") return Boolean(draft);
20
30
  if (field.type === "file") return filePatchValue(draft);
21
- const text = String(draft).trim();
22
- if (text === "") return field.nullable ? null : "";
31
+ const typed = String(draft).trim();
32
+ if (typed === "") return field.nullable ? null : "";
33
+ const text = numeric.has(field.type) ? numberText(typed, decimal) : typed;
23
34
  if (field.type === "integer") return Number(text);
24
35
  if (field.type === "relation") return typeof draft === "number" ? draft : text;
25
36
  if (textEdited.has(field.type) || field.type === "enum") return text;
26
37
  throw new Error(`Not implemented: editing ${field.type} fields`);
27
38
  };
28
39
  /** The value a draft stands for, as the API gives one: null when empty, a boolean or number where the field holds one. */
29
- const fromDraft = (field, draft) => {
40
+ const fromDraft = (field, draft, decimal = localeDecimal()) => {
30
41
  if (field.type === "boolean") return Boolean(draft);
31
42
  if (String(draft ?? "").trim() === "") return null;
32
- return toPatchValue(field, draft);
43
+ return toPatchValue(field, draft, decimal);
33
44
  };
34
45
  /** The draft for a value, as the editor holds it: a boolean for a boolean field, else its text. */
35
46
  const toFieldDraft = (field, value) => field.type === "boolean" ? Boolean(value) : toDraft(value);
36
- /** Whether the draft differs from what the server holds, comparing numbers by value. */
37
- const differs = (field, draft, stored) => {
47
+ /** Whether the draft differs from what the server holds, comparing numbers by value, as typed with `decimal`. */
48
+ const differs = (field, draft, stored, decimal = localeDecimal()) => {
38
49
  if (field.type === "boolean") return Boolean(draft) !== Boolean(stored);
39
50
  if (field.type === "file") return fileDiffers(draft, stored);
40
51
  const a = String(draft ?? "").trim();
41
52
  const b = toDraft(stored);
42
- if (field.type === "decimal" || field.type === "integer") return a === "" || b === "" ? a !== b : Number(a) !== Number(b);
53
+ if (field.type === "decimal" || field.type === "integer") return a === "" || b === "" ? a !== b : Number(numberText(a, decimal)) !== Number(b);
43
54
  return a !== b;
44
55
  };
45
56
  //#endregion
@@ -1,3 +1,4 @@
1
+ import { dayIn, startOfDayIn, viewerZone } from "../format/zone.js";
1
2
  import { createWhere, parseFilter, printFilter } from "@protobase/schema";
2
3
  //#region src/live/filter-string.ts
3
4
  const w = createWhere();
@@ -20,18 +21,16 @@ const relative = {
20
21
  }
21
22
  };
22
23
  const iso = (ms) => new Date(ms).toISOString().replace(".000Z", "Z");
23
- /** First instant of the period a calendar preset starts at, in UTC. */
24
- const periodStart = (preset, now) => {
25
- const [year, month, day] = [
26
- now.getUTCFullYear(),
27
- now.getUTCMonth(),
28
- now.getUTCDate()
29
- ];
30
- if (preset === "year") return iso(Date.UTC(year, 0, 1));
31
- if (preset === "quarter") return iso(Date.UTC(year, Math.floor(month / 3) * 3, 1));
32
- if (preset === "month") return iso(Date.UTC(year, month, 1));
33
- if (preset === "yesterday") return iso(Date.UTC(year, month, day - 1));
34
- return iso(Date.UTC(year, month, day));
24
+ /** The first day of the period a calendar preset starts at, as `YYYY-MM-DD` on the clock of `zone`. */
25
+ const periodStart = (preset, now, zone) => {
26
+ const today = dayIn(now.getTime(), zone);
27
+ const [year, month] = [Number(today.slice(0, 4)), Number(today.slice(5, 7)) - 1];
28
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
29
+ if (preset === "year") return day(Date.UTC(year, 0, 1));
30
+ if (preset === "quarter") return day(Date.UTC(year, Math.floor(month / 3) * 3, 1));
31
+ if (preset === "month") return day(Date.UTC(year, month, 1));
32
+ if (preset === "yesterday") return day(Date.parse(`${today}T00:00:00Z`) - 864e5);
33
+ return today;
35
34
  };
36
35
  const calendarPresets = [
37
36
  "year",
@@ -40,23 +39,24 @@ const calendarPresets = [
40
39
  "yesterday",
41
40
  "today"
42
41
  ];
43
- /** Date fields take a plain date, timestamp fields an instant. */
44
- const instant = (value, kind) => kind === "date" ? {
42
+ /** The literal a day starts at: the plain date for a date field, its first instant in `zone` for a timestamp field. */
43
+ const dayLiteral = (day, kind, zone) => kind === "date" ? day : iso(startOfDayIn(day, zone));
44
+ const instant = (day, kind, zone) => kind === "date" ? {
45
45
  kind: "string",
46
- value: value.slice(0, 10),
46
+ value: dayLiteral(day, kind, zone),
47
47
  span
48
48
  } : {
49
49
  kind: "timestamp",
50
- value,
50
+ value: dayLiteral(day, kind, zone),
51
51
  span
52
52
  };
53
- const dateClauses = (field, preset, now, kind) => {
53
+ const dateClauses = (field, preset, now, kind, zone) => {
54
54
  if (preset in relative) return [w.gte(field, w.ago(preset))];
55
- const from = w.gte(field, instant(periodStart(preset, now), kind));
56
- return preset === "yesterday" ? [from, w.lt(field, instant(periodStart("today", now), kind))] : [from];
55
+ const from = w.gte(field, instant(periodStart(preset, now, zone), kind, zone));
56
+ return preset === "yesterday" ? [from, w.lt(field, instant(periodStart("today", now, zone), kind, zone))] : [from];
57
57
  };
58
- /** The panel's state as filter clauses, in a fixed order so the text is stable. */
59
- const stateToClauses = (config, state, now = /* @__PURE__ */ new Date()) => {
58
+ /** The panel's state as filter clauses, in a fixed order so the text is stable; calendar presets start at midnight in `zone`. */
59
+ const stateToClauses = (config, state, now = /* @__PURE__ */ new Date(), zone = viewerZone()) => {
60
60
  const facets = config.facets.flatMap((group) => {
61
61
  const values = state.facets[group.id] ?? [];
62
62
  if (values.length === 0) return [];
@@ -70,7 +70,7 @@ const stateToClauses = (config, state, now = /* @__PURE__ */ new Date()) => {
70
70
  const span = state.ranges?.[range.id];
71
71
  return span ? [w.gte(range.id, span[0]), w.lte(range.id, span[1])] : [];
72
72
  });
73
- const date = config.dateField && state.date ? dateClauses(config.dateField, state.date, now, config.dateKind) : [];
73
+ const date = config.dateField && state.date ? dateClauses(config.dateField, state.date, now, config.dateKind, zone) : [];
74
74
  const toggles = (config.toggles ?? []).filter((toggle) => state.toggles.includes(toggle.id)).map((toggle) => w.eq(toggle.id, true));
75
75
  return [
76
76
  ...facets,
@@ -80,7 +80,7 @@ const stateToClauses = (config, state, now = /* @__PURE__ */ new Date()) => {
80
80
  ];
81
81
  };
82
82
  const clausesToText = (clauses) => clauses.length === 0 ? "" : printFilter(w.and(...clauses));
83
- const stateToText = (config, state, extras = [], now = /* @__PURE__ */ new Date()) => clausesToText([...stateToClauses(config, state, now), ...extras]);
83
+ const stateToText = (config, state, extras = [], now = /* @__PURE__ */ new Date(), zone = viewerZone()) => clausesToText([...stateToClauses(config, state, now, zone), ...extras]);
84
84
  const flatten = (expr) => expr.kind === "and" ? expr.args.flatMap(flatten) : [expr];
85
85
  const fieldName = (expr) => expr.field.path.join(".");
86
86
  const isTrue = (expr) => expr.kind === "compare" && expr.op === "=" && expr.value.kind === "boolean" && expr.value.value;
@@ -95,20 +95,20 @@ const flagsChosen = (clause, groupOf) => {
95
95
  } : void 0;
96
96
  };
97
97
  const literalText = (value) => value.kind === "number" ? String(value.raw) : value.kind === "string" ? String(value.value) : void 0;
98
- const presetOf = (clause, now) => {
98
+ const presetOf = (clause, now, zone) => {
99
99
  const v = clause.value;
100
100
  if (v.kind === "now" && v.offset?.sign === "-") {
101
101
  const { amount, unit } = v.offset.duration;
102
102
  return Object.entries(relative).find(([, d]) => d.amount === amount && d.unit === unit)?.[0];
103
103
  }
104
- if (v.kind === "timestamp") return calendarPresets.find((preset) => periodStart(preset, now) === v.value);
105
- if (v.kind === "string") return calendarPresets.find((preset) => periodStart(preset, now).slice(0, 10) === v.value);
104
+ if (v.kind === "timestamp") return calendarPresets.find((preset) => dayLiteral(periodStart(preset, now, zone), "timestamp", zone) === v.value);
105
+ if (v.kind === "string") return calendarPresets.find((preset) => periodStart(preset, now, zone) === v.value);
106
106
  };
107
107
  /**
108
108
  * The inverse of `stateToText`: clauses that match a widget fill the panel state, everything else
109
109
  * (search text, hand-written conditions) is returned as `extras` and survives the round trip.
110
110
  */
111
- const textToState = (config, text, now = /* @__PURE__ */ new Date()) => {
111
+ const textToState = (config, text, now = /* @__PURE__ */ new Date(), zone = viewerZone()) => {
112
112
  const parsed = parseFilter(text);
113
113
  if (!parsed.ok) return {
114
114
  ok: false,
@@ -131,10 +131,10 @@ const textToState = (config, text, now = /* @__PURE__ */ new Date()) => {
131
131
  if (flags && !state.facets[flags.group]) state.facets[flags.group] = flags.fields;
132
132
  else if (clause.kind === "compare" && facetIds.has(fieldName(clause)) && clause.op === "=" && literalText(clause.value) !== void 0) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], literalText(clause.value)];
133
133
  else if (clause.kind === "in" && facetIds.has(fieldName(clause)) && clause.values.every((value) => literalText(value) !== void 0)) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], ...clause.values.map((value) => literalText(value))];
134
- else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === ">=" && presetOf(clause, now) && !state.date) {
135
- state.date = presetOf(clause, now);
134
+ else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === ">=" && presetOf(clause, now, zone) && !state.date) {
135
+ state.date = presetOf(clause, now, zone);
136
136
  dateClause = clause;
137
- } else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === "<" && (clause.value.kind === "timestamp" || clause.value.kind === "string") && periodStart("today", now).startsWith(clause.value.value.slice(0, 10))) todayEnd = clause;
137
+ } else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === "<" && (clause.value.kind === "timestamp" || clause.value.kind === "string") && clause.value.value === dayLiteral(periodStart("today", now, zone), clause.value.kind === "string" ? "date" : "timestamp", zone)) todayEnd = clause;
138
138
  else if (clause.kind === "compare" && toggleIds.has(fieldName(clause)) && clause.op === "=" && clause.value.kind === "boolean" && clause.value.value) state.toggles.push(fieldName(clause));
139
139
  else if (clause.kind === "compare" && rangeIds.has(fieldName(clause)) && (clause.op === ">=" || clause.op === "<=") && clause.value.kind === "number") {
140
140
  const range = bounds.get(fieldName(clause)) ?? { clauses: [] };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.21.8";
2
+ var version = "0.21.10";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };