@protobase/ui 0.12.10 → 0.12.11

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.
@@ -1,10 +1,6 @@
1
- import { FileText, Send, Wallet, Zap } from "lucide-react";
1
+ import { namedIcon } from "./named-icon.js";
2
+ import { Zap } from "lucide-react";
2
3
  //#region src/app/action-icon.ts
3
- const byName = {
4
- wallet: Wallet,
5
- "file-text": FileText,
6
- send: Send
7
- };
8
- const actionIcon = (name) => name ? byName[name] ?? Zap : Zap;
4
+ const actionIcon = (name) => namedIcon(name) ?? Zap;
9
5
  //#endregion
10
6
  export { actionIcon };
@@ -9,7 +9,8 @@ import { displayValue } from "../../live/display-value.js";
9
9
  import { shownWidget } from "../field-types/shown-widget.js";
10
10
  import { CustomBoundary } from "../pages/custom-boundary.js";
11
11
  import { WidgetPart } from "../field-types/widget-part.js";
12
- import { toneFor } from "../../live/columns-from-model.js";
12
+ import { namedIcon } from "../named-icon.js";
13
+ import { ValueBadge } from "../value-badge.js";
13
14
  import { DisplayFooter, DisplayState } from "./display-frame.js";
14
15
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
15
16
  //#region src/app/displays/cards-display.tsx
@@ -27,12 +28,23 @@ const CardImage = ({ value }) => {
27
28
  })
28
29
  });
29
30
  };
31
+ const MetaValue = ({ icon, children }) => {
32
+ const Icon = namedIcon(icon);
33
+ return /* @__PURE__ */ jsxs("dd", {
34
+ className: "flex items-center gap-1 tabular-nums",
35
+ children: [Icon && /* @__PURE__ */ jsx(Icon, {
36
+ className: "size-3.5 shrink-0",
37
+ "aria-hidden": true
38
+ }), children]
39
+ });
40
+ };
30
41
  /**
31
- * A card per record, opening its page: its image across the top, the title over the subtitle, the badge beside it and small meta fields below;
32
- * or, with a `card` component, what it draws of the row, the built-in content again when it is missing or throws.
42
+ * A card per record, opening its page: its image across the top, the title over the subtitle, the badge beside it,
43
+ * small meta fields below and the labels of its flags that are true; or, with a `card` component, what it draws of the
44
+ * row, the built-in content again when it is missing or throws.
33
45
  */
34
46
  const CardsDisplay = ({ model, view, display, list }) => {
35
- const { image, title, subtitle, badge, meta } = cardFields(model, view, display);
47
+ const { image, title, subtitle, badge, meta, flags } = cardFields(model, view, display);
36
48
  const label = (name) => view?.fields[name]?.label ?? humanize(name);
37
49
  const text = (row, name) => {
38
50
  const field = model.fields[name];
@@ -52,6 +64,17 @@ const CardsDisplay = ({ model, view, display, list }) => {
52
64
  fallback: text(row, name)
53
65
  }) : text(row, name);
54
66
  };
67
+ const metaValue = (row, name) => {
68
+ const field = model.fields[name];
69
+ const hints = view?.fields[name];
70
+ const value = row.record[name];
71
+ if (!field || hints?.format !== "badge" || shownWidget(fieldTypes, field, hints) || value === null || value === void 0 || value === "") return shownValue(row, name);
72
+ return /* @__PURE__ */ jsx(ValueBadge, {
73
+ value,
74
+ hints,
75
+ children: text(row, name)
76
+ });
77
+ };
55
78
  const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
56
79
  return /* @__PURE__ */ jsxs(DisplayState, {
57
80
  list,
@@ -65,6 +88,7 @@ const CardsDisplay = ({ model, view, display, list }) => {
65
88
  "aria-label": view?.names?.plural ?? humanize(model.name),
66
89
  children: list.rows.map((row) => {
67
90
  const status = badge === void 0 ? void 0 : row.record[badge];
91
+ const raised = flags.filter((name) => row.record[name] === true);
68
92
  const builtIn = /* @__PURE__ */ jsxs(Fragment, { children: [
69
93
  image !== void 0 && /* @__PURE__ */ jsx(CardImage, { value: row.record[image] }),
70
94
  /* @__PURE__ */ jsxs("span", {
@@ -72,8 +96,9 @@ const CardsDisplay = ({ model, view, display, list }) => {
72
96
  children: [/* @__PURE__ */ jsx("span", {
73
97
  className: "min-w-0 truncate font-medium",
74
98
  children: shownValue(row, title)
75
- }), badge !== void 0 && status !== null && status !== void 0 && /* @__PURE__ */ jsx(Badge, {
76
- tone: toneFor(String(status)),
99
+ }), badge !== void 0 && status !== null && status !== void 0 && /* @__PURE__ */ jsx(ValueBadge, {
100
+ value: status,
101
+ hints: view?.fields[badge],
77
102
  children: text(row, badge)
78
103
  })]
79
104
  }),
@@ -82,17 +107,25 @@ const CardsDisplay = ({ model, view, display, list }) => {
82
107
  children: shownValue(row, subtitle)
83
108
  }),
84
109
  meta.length > 0 && /* @__PURE__ */ jsx("dl", {
85
- className: "mt-auto flex flex-wrap gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
110
+ className: "mt-auto flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
86
111
  children: meta.map((name) => /* @__PURE__ */ jsxs("div", {
87
112
  className: "flex gap-1",
88
113
  children: [/* @__PURE__ */ jsx("dt", {
89
114
  className: "sr-only",
90
115
  children: label(name)
91
- }), /* @__PURE__ */ jsx("dd", {
92
- className: "tabular-nums",
93
- children: shownValue(row, name)
116
+ }), /* @__PURE__ */ jsx(MetaValue, {
117
+ icon: view?.fields[name]?.icon,
118
+ children: metaValue(row, name)
94
119
  })]
95
120
  }, name))
121
+ }),
122
+ raised.length > 0 && /* @__PURE__ */ jsx("ul", {
123
+ className: "flex flex-wrap gap-1",
124
+ "aria-label": "Features",
125
+ children: raised.map((name) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Badge, {
126
+ dot: false,
127
+ children: label(name)
128
+ }) }, name))
96
129
  })
97
130
  ] });
98
131
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(AppLink, {
@@ -34,18 +34,20 @@ const cardFields = (model, view, display) => ({
34
34
  title: display.title ?? recordTitleField(model, view),
35
35
  subtitle: display.subtitle,
36
36
  badge: display.badge,
37
- meta: display.meta ?? []
37
+ meta: display.meta ?? [],
38
+ flags: display.flags ?? []
38
39
  });
39
40
  /** Every field a display shows, the fields its rows are loaded and labelled for. */
40
41
  const shownFields = (model, view, display) => {
41
42
  if (display.kind === "custom") return display.fields;
42
- const { image, title, subtitle, badge, meta } = cardFields(model, view, display);
43
+ const { image, title, subtitle, badge, meta, flags } = cardFields(model, view, display);
43
44
  return [
44
45
  image,
45
46
  title,
46
47
  subtitle,
47
48
  badge,
48
- ...meta
49
+ ...meta,
50
+ ...flags
49
51
  ].filter((name) => name !== void 0);
50
52
  };
51
53
  /** The fields to label the list's rows for: the table's columns and every display's, so one load serves them all. */
@@ -90,6 +90,10 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
90
90
  className: "text-[13px]",
91
91
  children: hints.prefix
92
92
  }) : void 0,
93
+ trailing: hints?.suffix ? /* @__PURE__ */ jsx("span", {
94
+ className: "text-[13px]",
95
+ children: hints.suffix.trim()
96
+ }) : void 0,
93
97
  placeholder,
94
98
  className: cn(numeric.has(field.type) && "text-right tabular-nums"),
95
99
  value: String(value),
@@ -0,0 +1,13 @@
1
+ import { FileText, RulerDimensionLine, Send, Wallet } from "lucide-react";
2
+ //#region src/app/named-icon.ts
3
+ /** The icons an action's or a field's `icon` may name. */
4
+ const byName = {
5
+ wallet: Wallet,
6
+ "file-text": FileText,
7
+ send: Send,
8
+ ruler: RulerDimensionLine
9
+ };
10
+ /** The icon a name stands for, or nothing for a name the app does not know. */
11
+ const namedIcon = (name) => name !== void 0 && Object.hasOwn(byName, name) ? byName[name] : void 0;
12
+ //#endregion
13
+ export { namedIcon };
@@ -1,5 +1,4 @@
1
1
  import { cn } from "../../lib/cn.js";
2
- import { Badge } from "../../primitives/badge.js";
3
2
  import { keys } from "../../data/query-keys.js";
4
3
  import { useClient } from "../../data/api-provider.js";
5
4
  import { fetchLabels } from "../../live/relation-labels.js";
@@ -9,7 +8,7 @@ import { fileShown } from "../../live/file-values.js";
9
8
  import { displayValue } from "../../live/display-value.js";
10
9
  import { useShownWidget } from "../field-types/shown-widget.js";
11
10
  import { WidgetPart } from "../field-types/widget-part.js";
12
- import { toneFor } from "../../live/columns-from-model.js";
11
+ import { ValueBadge } from "../value-badge.js";
13
12
  import { jsx } from "react/jsx-runtime";
14
13
  import { useQuery, useQueryClient } from "@tanstack/react-query";
15
14
  //#region src/app/pages/field-value.tsx
@@ -59,8 +58,9 @@ const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
59
58
  children: file.name
60
59
  });
61
60
  const text = displayValue(field, value, hints);
62
- if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(Badge, {
63
- tone: toneFor(String(value)),
61
+ if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(ValueBadge, {
62
+ value,
63
+ hints,
64
64
  children: text
65
65
  });
66
66
  return /* @__PURE__ */ jsx("span", {
@@ -1,4 +1,3 @@
1
- import { Badge } from "../primitives/badge.js";
2
1
  import { useAuth } from "../auth/auth-provider.js";
3
2
  import { formatRelativeTime } from "../format/date.js";
4
3
  import { Button } from "../primitives/button.js";
@@ -24,6 +23,7 @@ import { RecordHeader } from "../record-view/record-header.js";
24
23
  import { toneFor } from "../live/columns-from-model.js";
25
24
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
26
25
  import { useRestore } from "./delete/use-restore.js";
26
+ import { ValueBadge } from "./value-badge.js";
27
27
  import { auditField } from "../live/audit-fields.js";
28
28
  import { CustomSection } from "./custom-section.js";
29
29
  import { RecordActions } from "./pages/record-actions.js";
@@ -176,8 +176,9 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
176
176
  const field = model.fields[name];
177
177
  if (!field || !visible(name)) return [];
178
178
  const text = displayValue(field, stored.record[name], hint(name));
179
- const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(Badge, {
180
- tone: toneFor(String(stored.record[name])),
179
+ const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(ValueBadge, {
180
+ value: stored.record[name],
181
+ hints: hint(name),
181
182
  children: text
182
183
  }) : /* @__PURE__ */ jsx("strong", { children: text });
183
184
  const shown = shownWidget(fieldTypes, field, hint(name));
@@ -0,0 +1,14 @@
1
+ import { Badge } from "../primitives/badge.js";
2
+ import { valueColor } from "../live/value-color.js";
3
+ import { toneFor } from "../live/columns-from-model.js";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/app/value-badge.tsx
6
+ /** A stored value as a badge: filled with the colour the view gives it and in its badge shape, else in its tone. */
7
+ const ValueBadge = ({ value, hints, children }) => /* @__PURE__ */ jsx(Badge, {
8
+ tone: toneFor(String(value)),
9
+ color: valueColor(hints?.valueColors, value),
10
+ shape: hints?.badgeShape,
11
+ children
12
+ });
13
+ //#endregion
14
+ export { ValueBadge };
@@ -6,6 +6,7 @@ import { formatInt, formatPercent, formatSigned } from "../format/number.js";
6
6
  import { formatDate, formatDateTime } from "../format/date.js";
7
7
  import { fileShown, isPreviewable } from "../live/file-values.js";
8
8
  import { userById } from "../mocks/users.js";
9
+ import { valueColor } from "../live/value-color.js";
9
10
  import { Check, Paperclip } from "lucide-react";
10
11
  import { jsx, jsxs } from "react/jsx-runtime";
11
12
  import { enumLabel } from "@protobase/schema";
@@ -26,9 +27,9 @@ const renderCell = (spec, value) => {
26
27
  className: "truncate font-mono text-xs text-muted-foreground",
27
28
  children: String(value)
28
29
  });
29
- case "money": return /* @__PURE__ */ jsx("span", {
30
+ case "money": return /* @__PURE__ */ jsxs("span", {
30
31
  className: "tabular-nums",
31
- children: formatMoney(Number(value), spec.currency)
32
+ children: [formatMoney(Number(value), spec.currency), spec.suffix]
32
33
  });
33
34
  case "date": return /* @__PURE__ */ jsx("span", {
34
35
  className: "tabular-nums text-muted-foreground",
@@ -38,13 +39,13 @@ const renderCell = (spec, value) => {
38
39
  className: "tabular-nums text-muted-foreground",
39
40
  children: formatDateTime(Number(value))
40
41
  });
41
- case "number": return /* @__PURE__ */ jsx("span", {
42
+ case "number": return /* @__PURE__ */ jsxs("span", {
42
43
  className: "tabular-nums",
43
- children: formatInt(Number(value))
44
+ children: [formatInt(Number(value)), spec.suffix]
44
45
  });
45
- case "signed": return /* @__PURE__ */ jsx("span", {
46
+ case "signed": return /* @__PURE__ */ jsxs("span", {
46
47
  className: cn("tabular-nums", Number(value) < 0 ? "text-danger-text" : "text-success-text"),
47
- children: formatSigned(Number(value))
48
+ children: [formatSigned(Number(value)), spec.suffix]
48
49
  });
49
50
  case "percent": return Number(value) === 0 ? muted("—") : /* @__PURE__ */ jsx("span", {
50
51
  className: "tabular-nums",
@@ -52,6 +53,8 @@ const renderCell = (spec, value) => {
52
53
  });
53
54
  case "status": return /* @__PURE__ */ jsx(Badge, {
54
55
  tone: spec.tones?.[String(value)] ?? "neutral",
56
+ color: valueColor(spec.colors, value),
57
+ shape: spec.shape,
55
58
  children: enumLabel(String(value), spec.labels)
56
59
  });
57
60
  case "relation": return /* @__PURE__ */ jsx("span", {
@@ -0,0 +1,23 @@
1
+ //#region src/lib/readable-text.ts
2
+ const luminance = (hex) => {
3
+ const digits = hex.slice(1);
4
+ const full = digits.length === 3 ? [...digits].map((digit) => digit + digit).join("") : digits;
5
+ const [r, g, b] = [
6
+ 0,
7
+ 2,
8
+ 4
9
+ ].map((at) => {
10
+ const channel = Number.parseInt(full.slice(at, at + 2), 16) / 255;
11
+ return channel <= .03928 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4;
12
+ });
13
+ return .2126 * r + .7152 * g + .0722 * b;
14
+ };
15
+ const light = "#ffffff";
16
+ const dark = "#111827";
17
+ /** The text colour, white or near black, that reads best on a hex background. */
18
+ const readableOn = (background) => {
19
+ const shade = luminance(background);
20
+ return 1.05 / (shade + .05) >= (shade + .05) / (luminance(dark) + .05) ? light : dark;
21
+ };
22
+ //#endregion
23
+ export { readableOn };
@@ -82,6 +82,9 @@ const columnsFromModel = (resource, view, highlight = []) => {
82
82
  tones: Object.fromEntries((field.enumValues ?? []).map((value) => [value, toneFor(value)])),
83
83
  labels
84
84
  },
85
+ ...field.type === "enum" && viewField?.valueColors && { colors: viewField.valueColors },
86
+ ...field.type === "enum" && viewField?.badgeShape && { shape: viewField.badgeShape },
87
+ ...viewField?.suffix !== void 0 && { suffix: viewField.suffix },
85
88
  ...kind === "money" && {
86
89
  currency: "EUR",
87
90
  value: (row) => row[name] == null ? null : currencyCents(row[name])
@@ -2,7 +2,8 @@ import { formatDate, formatDateTime } from "../format/date.js";
2
2
  import { fileShown } from "./file-values.js";
3
3
  import { enumLabel } from "@protobase/schema";
4
4
  //#region src/live/display-value.ts
5
- /** A read-only rendering of a stored value, following the view's prefix, decimals, format and value label hints. */
5
+ 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. */
6
7
  const displayValue = (field, value, hints = {}) => {
7
8
  if (value === null || value === void 0 || value === "") return "—";
8
9
  switch (field.type) {
@@ -14,11 +15,13 @@ const displayValue = (field, value, hints = {}) => {
14
15
  case "decimal": {
15
16
  const text = hints.decimals === void 0 ? String(value) : Number(value).toFixed(hints.decimals);
16
17
  if (hints.format === "percent") return `${Number(value)}%`;
17
- return `${hints.prefix ?? ""}${Number(text).toLocaleString("en-IE", {
18
+ return affixed(Number(text).toLocaleString("en-IE", {
18
19
  minimumFractionDigits: hints.decimals ?? 0,
19
20
  maximumFractionDigits: hints.decimals ?? 20
20
- })}`;
21
+ }), hints);
21
22
  }
23
+ case "integer":
24
+ case "bigint": return affixed(String(value), hints);
22
25
  default: return typeof value === "object" ? JSON.stringify(value) : String(value);
23
26
  }
24
27
  };
@@ -49,7 +49,9 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
49
49
  range: void 0
50
50
  }, state, extras);
51
51
  const histogram = useHistogram(model.name, skeleton.range?.id ?? "", rangeFilter, 24, Boolean(skeleton.range));
52
- const prefix = skeleton.range ? view?.fields[skeleton.range.id]?.prefix : void 0;
52
+ const rangeHints = skeleton.range ? view?.fields[skeleton.range.id] : void 0;
53
+ const prefix = rangeHints?.prefix;
54
+ const suffix = rangeHints?.suffix ?? "";
53
55
  return {
54
56
  facets: skeleton.facets.map((group, index) => {
55
57
  const field = model.fields[group.id];
@@ -71,7 +73,7 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
71
73
  max: Math.ceil(histogram.data.max),
72
74
  step: Math.max(1, Math.round((histogram.data.max - histogram.data.min) / 100)),
73
75
  histogram: histogram.data.buckets.map((bucket) => bucket.count),
74
- format: (value) => prefix === "€" ? formatMoney(value * 100).replace(".00", "") : String(value)
76
+ format: (value) => `${prefix === "€" ? formatMoney(value * 100).replace(".00", "") : String(value)}${suffix}`
75
77
  } },
76
78
  ...skeleton.dateField && {
77
79
  datePresets: skeleton.datePresets ?? true,
@@ -0,0 +1,8 @@
1
+ //#region src/live/value-color.ts
2
+ /** The colour a view gives a value's badge, if any. */
3
+ const valueColor = (colors, value) => {
4
+ const key = String(value);
5
+ return colors && Object.hasOwn(colors, key) ? colors[key] : void 0;
6
+ };
7
+ //#endregion
8
+ export { valueColor };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.12.10";
2
+ var version = "0.12.11";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { readableOn } from "../lib/readable-text.js";
2
3
  import { jsx, jsxs } from "react/jsx-runtime";
3
4
  //#region src/primitives/badge.tsx
4
5
  const tones = {
@@ -17,9 +18,17 @@ const dots = {
17
18
  red: "bg-danger",
18
19
  violet: "bg-violet"
19
20
  };
20
- const Badge = ({ tone = "neutral", dot = true, children, className }) => /* @__PURE__ */ jsxs("span", {
21
- className: cn("inline-flex h-5 items-center gap-1.5 rounded-full px-2 text-xs font-medium whitespace-nowrap", tones[tone], className),
22
- children: [dot && /* @__PURE__ */ jsx("span", { className: cn("size-1.5 rounded-full", dots[tone]) }), children]
21
+ const arrow = "polygon(0 0, calc(100% - 0.625rem) 0, 100% 50%, calc(100% - 0.625rem) 100%, 0 100%)";
22
+ const Badge = ({ tone = "neutral", dot = true, color, shape, children, className }) => /* @__PURE__ */ jsxs("span", {
23
+ style: {
24
+ ...color !== void 0 && {
25
+ backgroundColor: color,
26
+ color: readableOn(color)
27
+ },
28
+ ...shape === "arrow" && { clipPath: arrow }
29
+ },
30
+ className: cn(shape === "arrow" ? "inline-flex h-5 items-center gap-1.5 rounded-l-sm pr-4 pl-2 text-xs font-semibold whitespace-nowrap" : "inline-flex h-5 items-center gap-1.5 rounded-full px-2 text-xs font-medium whitespace-nowrap", color === void 0 && tones[tone], className),
31
+ children: [dot && color === void 0 && shape === void 0 && /* @__PURE__ */ jsx("span", { className: cn("size-1.5 rounded-full", dots[tone]) }), children]
23
32
  });
24
33
  //#endregion
25
34
  export { Badge };