@protobase/ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/LICENSE +202 -0
  2. package/dist/activity-chart/activity-chart.js +125 -0
  3. package/dist/activity-chart/activity-math.js +11 -0
  4. package/dist/activity-chart/activity-tooltip.js +24 -0
  5. package/dist/app/action-icon.js +10 -0
  6. package/dist/app/app.d.ts +24 -0
  7. package/dist/app/app.js +118 -0
  8. package/dist/app/batch-failure.js +21 -0
  9. package/dist/app/breadcrumb-for.js +27 -0
  10. package/dist/app/create/create-page.js +118 -0
  11. package/dist/app/create/create-record-form.js +68 -0
  12. package/dist/app/create/create-values.js +52 -0
  13. package/dist/app/delete/dialogs.js +109 -0
  14. package/dist/app/delete/record-diff.js +17 -0
  15. package/dist/app/delete/use-delete-flow.js +193 -0
  16. package/dist/app/delete/use-restore.js +9 -0
  17. package/dist/app/draft-storage.js +18 -0
  18. package/dist/app/error-banner.js +28 -0
  19. package/dist/app/field-editor.js +88 -0
  20. package/dist/app/icons.js +27 -0
  21. package/dist/app/index.d.ts +2 -0
  22. package/dist/app/index.html +12 -0
  23. package/dist/app/list-chart.js +36 -0
  24. package/dist/app/list-order.js +15 -0
  25. package/dist/app/list-page.js +213 -0
  26. package/dist/app/list-search.js +30 -0
  27. package/dist/app/main.d.ts +1 -0
  28. package/dist/app/main.js +14 -0
  29. package/dist/app/menu-icons.js +21 -0
  30. package/dist/app/meta-gate.js +46 -0
  31. package/dist/app/nav-from-meta.js +65 -0
  32. package/dist/app/nav-recent.js +33 -0
  33. package/dist/app/pages/action-href.js +7 -0
  34. package/dist/app/pages/blocks/action.js +38 -0
  35. package/dist/app/pages/blocks/block-props.js +5 -0
  36. package/dist/app/pages/blocks/card-frame.js +34 -0
  37. package/dist/app/pages/blocks/card-row.js +55 -0
  38. package/dist/app/pages/blocks/card.js +19 -0
  39. package/dist/app/pages/blocks/field.js +45 -0
  40. package/dist/app/pages/blocks/grid.js +34 -0
  41. package/dist/app/pages/blocks/link.js +27 -0
  42. package/dist/app/pages/blocks/modal-form.js +149 -0
  43. package/dist/app/pages/blocks/page-block.js +20 -0
  44. package/dist/app/pages/blocks/progress.js +54 -0
  45. package/dist/app/pages/blocks/record-card.js +56 -0
  46. package/dist/app/pages/blocks/show.js +43 -0
  47. package/dist/app/pages/blocks/stat.js +78 -0
  48. package/dist/app/pages/blocks/table.js +65 -0
  49. package/dist/app/pages/composed-page.js +19 -0
  50. package/dist/app/pages/confirm-action.js +20 -0
  51. package/dist/app/pages/field-value.js +58 -0
  52. package/dist/app/pages/project-ui.d.ts +25 -0
  53. package/dist/app/pages/project-ui.js +13 -0
  54. package/dist/app/pages/record-actions.js +40 -0
  55. package/dist/app/pages/record-scope.d.ts +16 -0
  56. package/dist/app/pages/record-scope.js +12 -0
  57. package/dist/app/pages/record-table.js +110 -0
  58. package/dist/app/pages/render-node.js +84 -0
  59. package/dist/app/pages/use-layout-data.js +73 -0
  60. package/dist/app/pages/use-path-value.js +65 -0
  61. package/dist/app/pages/use-relation-labels.js +30 -0
  62. package/dist/app/pages/use-run-action.js +88 -0
  63. package/dist/app/permissions.js +15 -0
  64. package/dist/app/record-lines.js +39 -0
  65. package/dist/app/record-page.js +318 -0
  66. package/dist/app/related-section.js +126 -0
  67. package/dist/app/relation-picker.js +69 -0
  68. package/dist/app/router.js +155 -0
  69. package/dist/app/search/search-model.js +70 -0
  70. package/dist/app/search/use-global-search.js +68 -0
  71. package/dist/app/sensitive-field.js +65 -0
  72. package/dist/app/use-lines-draft.js +45 -0
  73. package/dist/app/use-lines.js +19 -0
  74. package/dist/app/use-live-save.js +54 -0
  75. package/dist/app/use-nav-recent.js +41 -0
  76. package/dist/app/use-record-editor.js +154 -0
  77. package/dist/app/use-related-records.js +31 -0
  78. package/dist/app/user-menu-from-meta.js +36 -0
  79. package/dist/app-shell/app-shell.d.ts +4 -0
  80. package/dist/app-shell/app-shell.js +57 -0
  81. package/dist/app-shell/breadcrumb.js +37 -0
  82. package/dist/app-shell/drawer.js +30 -0
  83. package/dist/app-shell/global-search.d.ts +1 -0
  84. package/dist/app-shell/global-search.js +145 -0
  85. package/dist/app-shell/index.d.ts +9 -0
  86. package/dist/app-shell/logo.js +46 -0
  87. package/dist/app-shell/nav-open-storage.js +10 -0
  88. package/dist/app-shell/nav.d.ts +2 -0
  89. package/dist/app-shell/nav.js +85 -0
  90. package/dist/app-shell/page-header.d.ts +1 -0
  91. package/dist/app-shell/page-header.js +17 -0
  92. package/dist/app-shell/profile-menu.d.ts +2 -0
  93. package/dist/app-shell/profile-menu.js +115 -0
  94. package/dist/app-shell/sidebar-group.js +94 -0
  95. package/dist/app-shell/sidebar-item.js +46 -0
  96. package/dist/app-shell/sidebar-mode.d.ts +4 -0
  97. package/dist/app-shell/sidebar-mode.js +13 -0
  98. package/dist/app-shell/sidebar.d.ts +2 -0
  99. package/dist/app-shell/sidebar.js +64 -0
  100. package/dist/app-shell/status-dot.d.ts +1 -0
  101. package/dist/app-shell/status-dot.js +20 -0
  102. package/dist/app-shell/top-bar.js +82 -0
  103. package/dist/auth/auth-gate.js +70 -0
  104. package/dist/auth/auth-layout.js +29 -0
  105. package/dist/auth/auth-provider.js +64 -0
  106. package/dist/auth/first-run-page.js +51 -0
  107. package/dist/auth/forgot-password-form.js +75 -0
  108. package/dist/auth/forgot-password-page.js +35 -0
  109. package/dist/auth/reset-link.js +29 -0
  110. package/dist/auth/reset-password-form.js +88 -0
  111. package/dist/auth/reset-password-page.js +43 -0
  112. package/dist/auth/sign-in-form.js +74 -0
  113. package/dist/auth/sign-in-page.js +38 -0
  114. package/dist/auth/user-to-shell.js +18 -0
  115. package/dist/data/api-provider.d.ts +54 -0
  116. package/dist/data/api-provider.js +53 -0
  117. package/dist/data/meta-data.d.ts +14 -0
  118. package/dist/data/meta-data.js +9 -0
  119. package/dist/data/query-keys.js +95 -0
  120. package/dist/data/use-aggregates.d.ts +8 -0
  121. package/dist/data/use-aggregates.js +43 -0
  122. package/dist/data/use-list.d.ts +231 -0
  123. package/dist/data/use-list.js +31 -0
  124. package/dist/data/use-meta.d.ts +8 -0
  125. package/dist/data/use-meta.js +35 -0
  126. package/dist/data/use-record.d.ts +6 -0
  127. package/dist/data/use-record.js +16 -0
  128. package/dist/data/use-update-record.d.ts +15 -0
  129. package/dist/data/use-update-record.js +26 -0
  130. package/dist/data-grid/cells.js +85 -0
  131. package/dist/data-grid/column-spec.js +4 -0
  132. package/dist/data-grid/columns.js +16 -0
  133. package/dist/data-grid/data-grid.js +238 -0
  134. package/dist/data-grid/geometry.js +32 -0
  135. package/dist/data-grid/grid-footer.js +33 -0
  136. package/dist/data-grid/grid-header.js +69 -0
  137. package/dist/data-grid/grid-row.js +83 -0
  138. package/dist/data-grid/grid-template.js +12 -0
  139. package/dist/data-grid/header-menu.js +52 -0
  140. package/dist/data-grid/page-cache.js +27 -0
  141. package/dist/data-grid/pinned-storage.js +13 -0
  142. package/dist/data-grid/row-menu.js +88 -0
  143. package/dist/data-grid/use-grid-scroll.js +80 -0
  144. package/dist/data-grid/use-horizontal-scroll.js +38 -0
  145. package/dist/data-grid/use-paged-rows.js +50 -0
  146. package/dist/data-grid/use-pinned-columns.js +16 -0
  147. package/dist/filter-panel/active-filter-chips.js +34 -0
  148. package/dist/filter-panel/date-presets.js +20 -0
  149. package/dist/filter-panel/facet-group.js +48 -0
  150. package/dist/filter-panel/filter-config.js +18 -0
  151. package/dist/filter-panel/filter-menu.js +41 -0
  152. package/dist/filter-panel/filter-panel.js +112 -0
  153. package/dist/filter-panel/filter-section.js +11 -0
  154. package/dist/filter-panel/filter-state.js +63 -0
  155. package/dist/filter-panel/range-filter.js +54 -0
  156. package/dist/filter-panel/range-math.js +11 -0
  157. package/dist/filter-panel/range-slider.css +32 -0
  158. package/dist/filter-panel/toggle-row.js +13 -0
  159. package/dist/filter-panel/use-filter-state.js +15 -0
  160. package/dist/format/date.js +37 -0
  161. package/dist/format/money.js +15 -0
  162. package/dist/format/number.js +12 -0
  163. package/dist/index.d.ts +12 -0
  164. package/dist/index.js +10 -0
  165. package/dist/inline-child-table/columns.js +4 -0
  166. package/dist/inline-child-table/inline-child-table.js +120 -0
  167. package/dist/inline-child-table/line-menu.js +54 -0
  168. package/dist/inline-child-table/line-row.js +91 -0
  169. package/dist/inline-child-table/number-cell.js +25 -0
  170. package/dist/inline-child-table/reorder.js +10 -0
  171. package/dist/inline-child-table/totals-footer.js +42 -0
  172. package/dist/lib/cn.js +4 -0
  173. package/dist/lib/guarded.js +12 -0
  174. package/dist/lib/use-debounced.js +13 -0
  175. package/dist/lib/use-focus-trap.js +46 -0
  176. package/dist/lib/use-media-query.js +12 -0
  177. package/dist/live/columns-from-model.js +93 -0
  178. package/dist/live/display-value.js +24 -0
  179. package/dist/live/field-values.js +32 -0
  180. package/dist/live/filter-string.js +135 -0
  181. package/dist/live/grid-loader.js +63 -0
  182. package/dist/live/model-helpers.js +30 -0
  183. package/dist/live/naming.js +9 -0
  184. package/dist/live/relation-labels.js +53 -0
  185. package/dist/live/use-filter-config.js +88 -0
  186. package/dist/live/use-live-rows.js +45 -0
  187. package/dist/live/view-fields.js +35 -0
  188. package/dist/mocks/activity.js +24 -0
  189. package/dist/mocks/invoices.js +13 -0
  190. package/dist/mocks/products.js +167 -0
  191. package/dist/mocks/rng.js +20 -0
  192. package/dist/mocks/users.js +63 -0
  193. package/dist/primitives/avatar.js +22 -0
  194. package/dist/primitives/badge.d.ts +1 -0
  195. package/dist/primitives/badge.js +25 -0
  196. package/dist/primitives/button.js +23 -0
  197. package/dist/primitives/checkbox.js +21 -0
  198. package/dist/primitives/dialog.js +41 -0
  199. package/dist/primitives/input.js +16 -0
  200. package/dist/primitives/kbd.js +8 -0
  201. package/dist/primitives/segmented.js +14 -0
  202. package/dist/primitives/sheet.js +44 -0
  203. package/dist/primitives/skeleton.js +6 -0
  204. package/dist/primitives/spinner.js +10 -0
  205. package/dist/primitives/switch.js +14 -0
  206. package/dist/primitives/tooltip.js +13 -0
  207. package/dist/record-view/action-rail.js +28 -0
  208. package/dist/record-view/field.js +62 -0
  209. package/dist/record-view/record-header.js +50 -0
  210. package/dist/record-view/record-sidebar.js +58 -0
  211. package/dist/record-view/section.js +21 -0
  212. package/dist/styles.css +132 -0
  213. package/dist/toasts/toast-provider.js +69 -0
  214. package/dist/toasts/toast.js +45 -0
  215. package/package.json +45 -3
  216. package/README.md +0 -3
@@ -0,0 +1,37 @@
1
+ //#region src/format/date.ts
2
+ const day = new Intl.DateTimeFormat("en-GB", {
3
+ day: "numeric",
4
+ month: "short",
5
+ year: "numeric",
6
+ timeZone: "UTC"
7
+ });
8
+ const dayShort = new Intl.DateTimeFormat("en-GB", {
9
+ day: "numeric",
10
+ month: "short",
11
+ timeZone: "UTC"
12
+ });
13
+ const time = new Intl.DateTimeFormat("en-GB", {
14
+ hour: "2-digit",
15
+ minute: "2-digit",
16
+ hour12: false,
17
+ timeZone: "UTC"
18
+ });
19
+ const formatDate = (ms) => day.format(ms);
20
+ const formatDayShort = (ms) => dayShort.format(ms);
21
+ const formatDateTime = (ms) => `${day.format(ms)}, ${time.format(ms)}`;
22
+ const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
23
+ const units = [
24
+ ["year", 31536e3],
25
+ ["month", 2592e3],
26
+ ["day", 86400],
27
+ ["hour", 3600],
28
+ ["minute", 60]
29
+ ];
30
+ /** "5 minutes ago", "yesterday", "3 years ago"; `seconds` is how long ago it was. */
31
+ const formatRelativeTime = (seconds) => {
32
+ const [unit, size] = units.find(([, size]) => seconds >= size) ?? ["second", 1];
33
+ if (unit === "second" && seconds < 45) return "just now";
34
+ return relative.format(-Math.floor(seconds / size), unit);
35
+ };
36
+ //#endregion
37
+ export { formatDate, formatDateTime, formatDayShort, formatRelativeTime };
@@ -0,0 +1,15 @@
1
+ //#region src/format/money.ts
2
+ const formatters = /* @__PURE__ */ new Map();
3
+ const formatterFor = (currency) => {
4
+ const cached = formatters.get(currency);
5
+ if (cached) return cached;
6
+ const created = new Intl.NumberFormat("en-IE", {
7
+ style: "currency",
8
+ currency
9
+ });
10
+ formatters.set(currency, created);
11
+ return created;
12
+ };
13
+ const formatMoney = (cents, currency = "EUR") => formatterFor(currency).format(cents / 100);
14
+ //#endregion
15
+ export { formatMoney };
@@ -0,0 +1,12 @@
1
+ //#region src/format/number.ts
2
+ const grouped = new Intl.NumberFormat("en-IE");
3
+ const formatInt = (value) => grouped.format(value);
4
+ const formatSigned = (value) => value > 0 ? `+${grouped.format(value)}` : grouped.format(value).replace("-", "−");
5
+ const formatPercent = (value) => `${value}%`;
6
+ const formatApproxRows = (count) => {
7
+ if (count < 1e4) return `${formatInt(count)} rows`;
8
+ const magnitude = 10 ** (Math.floor(Math.log10(count)) - 3);
9
+ return `about ${formatInt(Math.round(count / magnitude) * magnitude)} rows`;
10
+ };
11
+ //#endregion
12
+ export { formatApproxRows, formatInt, formatPercent, formatSigned };
@@ -0,0 +1,12 @@
1
+ import { ActionContext, ActionHandler, ProjectUi, defineUi } from "./app/pages/project-ui.js";
2
+ import { App, AppProps } from "./app/app.js";
3
+ import "./app/index.js";
4
+ import { ApiProvider, ApiProviderProps, useClient } from "./data/api-provider.js";
5
+ import { MetaData } from "./data/meta-data.js";
6
+ import { useMeta } from "./data/use-meta.js";
7
+ import { useList } from "./data/use-list.js";
8
+ import { useRecord } from "./data/use-record.js";
9
+ import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
10
+ import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
11
+ import { LayoutRecord, useLayoutRecord } from "./app/pages/record-scope.js";
12
+ export { type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, type AppProps, type LayoutRecord, type MetaData, type ProjectUi, defineUi, isConflict, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ import { ApiProvider, useClient } from "./data/api-provider.js";
2
+ import { useRecord } from "./data/use-record.js";
3
+ import { useMeta } from "./data/use-meta.js";
4
+ import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
5
+ import { useLayoutRecord } from "./app/pages/record-scope.js";
6
+ import { defineUi } from "./app/pages/project-ui.js";
7
+ import { useList } from "./data/use-list.js";
8
+ import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
9
+ import { App } from "./app/app.js";
10
+ export { ApiProvider, App, defineUi, isConflict, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
@@ -0,0 +1,4 @@
1
+ //#region src/inline-child-table/columns.ts
2
+ const lineGrid = "grid grid-cols-[28px_minmax(0,1fr)_88px_112px_112px_32px] items-center gap-2 pointer-coarse:grid-cols-[44px_minmax(0,1fr)_88px_112px_112px_44px]";
3
+ //#endregion
4
+ export { lineGrid };
@@ -0,0 +1,120 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { productAt } from "../mocks/products.js";
3
+ import { lineGrid } from "./columns.js";
4
+ import { LineRow } from "./line-row.js";
5
+ import { reorder } from "./reorder.js";
6
+ import { TotalsFooter } from "./totals-footer.js";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ import { Plus } from "lucide-react";
9
+ import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
10
+ import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable";
11
+ //#region src/inline-child-table/inline-child-table.tsx
12
+ const headerCell = "px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground";
13
+ const instructions = "To pick up a line, press space or enter on its drag handle. Use the up and down arrow keys to move it, space or enter to drop it, and escape to cancel. The row menu also has Move up and Move down.";
14
+ const announcementsFor = (lines) => {
15
+ const name = (id) => lines.find((line) => line.id === id)?.description ?? "line";
16
+ const place = (id) => lines.findIndex((line) => line.id === id) + 1;
17
+ return {
18
+ onDragStart: ({ active }) => `Picked up ${name(active.id)}, line ${place(active.id)} of ${lines.length}.`,
19
+ onDragOver: ({ active, over }) => over ? `${name(active.id)} is over line ${place(over.id)} of ${lines.length}.` : void 0,
20
+ onDragEnd: ({ active, over }) => over ? `${name(active.id)} was dropped at line ${place(over.id)} of ${lines.length}.` : `${name(active.id)} was dropped where it started.`,
21
+ onDragCancel: ({ active }) => `Moving ${name(active.id)} was cancelled. It is back at line ${place(active.id)}.`
22
+ };
23
+ };
24
+ const InlineChildTable = ({ lines, onChange, onReorder, vatRate = .21, readOnly, reorderable = !readOnly }) => {
25
+ const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), useSensor(TouchSensor, { activationConstraint: {
26
+ delay: 250,
27
+ tolerance: 5
28
+ } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
29
+ const move = (from, to) => {
30
+ const line = lines[from];
31
+ if (!line || to < 0 || to >= lines.length || from === to) return;
32
+ (onReorder ?? ((next) => onChange(next)))(reorder(lines, from, to), {
33
+ id: line.id,
34
+ from,
35
+ to
36
+ });
37
+ };
38
+ const onDragEnd = ({ active, over }) => {
39
+ if (!over || active.id === over.id) return;
40
+ move(lines.findIndex((line) => line.id === active.id), lines.findIndex((line) => line.id === over.id));
41
+ };
42
+ const addLine = () => {
43
+ const product = productAt(lines.length * 3 + 1);
44
+ onChange([...lines, {
45
+ id: `new-${lines.length}-${product.id}`,
46
+ productId: product.id,
47
+ description: product.name,
48
+ quantity: 1,
49
+ unitPriceCents: product.priceCents
50
+ }]);
51
+ };
52
+ return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("div", {
53
+ className: "overflow-hidden rounded-lg border border-border bg-background",
54
+ children: [
55
+ /* @__PURE__ */ jsxs("div", {
56
+ className: `${lineGrid} border-b border-border bg-surface px-2 py-2`,
57
+ children: [
58
+ /* @__PURE__ */ jsx("span", {}),
59
+ /* @__PURE__ */ jsx("span", {
60
+ className: headerCell,
61
+ children: "Item"
62
+ }),
63
+ /* @__PURE__ */ jsx("span", {
64
+ className: `${headerCell} text-right`,
65
+ children: "Qty"
66
+ }),
67
+ /* @__PURE__ */ jsx("span", {
68
+ className: `${headerCell} text-right`,
69
+ children: "Unit price"
70
+ }),
71
+ /* @__PURE__ */ jsx("span", {
72
+ className: `${headerCell} text-right`,
73
+ children: "Line total"
74
+ }),
75
+ /* @__PURE__ */ jsx("span", {})
76
+ ]
77
+ }),
78
+ /* @__PURE__ */ jsx(DndContext, {
79
+ sensors,
80
+ collisionDetection: closestCenter,
81
+ onDragEnd,
82
+ accessibility: {
83
+ announcements: announcementsFor(lines),
84
+ screenReaderInstructions: { draggable: instructions }
85
+ },
86
+ children: /* @__PURE__ */ jsx(SortableContext, {
87
+ items: lines.map((line) => line.id),
88
+ strategy: verticalListSortingStrategy,
89
+ children: lines.map((line, index) => /* @__PURE__ */ jsx(LineRow, {
90
+ line,
91
+ position: index,
92
+ count: lines.length,
93
+ reorderable,
94
+ editable: !readOnly,
95
+ onChange: (patch) => onChange(lines.map((item) => item.id === line.id ? {
96
+ ...item,
97
+ ...patch
98
+ } : item)),
99
+ onRemove: () => onChange(lines.filter((item) => item.id !== line.id)),
100
+ onMove: (to) => move(index, to)
101
+ }, line.id))
102
+ })
103
+ }),
104
+ !readOnly && /* @__PURE__ */ jsx("div", {
105
+ className: "border-t border-border bg-surface px-2 py-1.5",
106
+ children: /* @__PURE__ */ jsxs(Button, {
107
+ variant: "ghost",
108
+ size: "sm",
109
+ onClick: addLine,
110
+ children: [/* @__PURE__ */ jsx(Plus, { className: "size-3.5" }), "Add line"]
111
+ })
112
+ })
113
+ ]
114
+ }), /* @__PURE__ */ jsx(TotalsFooter, {
115
+ lines,
116
+ vatRate
117
+ })] });
118
+ };
119
+ //#endregion
120
+ export { InlineChildTable };
@@ -0,0 +1,54 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { MoreHorizontal } from "lucide-react";
5
+ //#region src/inline-child-table/line-menu.tsx
6
+ /** The "..." at the end of a line: the keyboard-and-screen-reader way to reorder, besides the drag handle. */
7
+ const LineMenu = ({ items, label }) => {
8
+ const [open, setOpen] = useState(false);
9
+ const root = useRef(null);
10
+ useEffect(() => {
11
+ if (!open) return;
12
+ const close = (event) => {
13
+ if (event instanceof KeyboardEvent ? event.key === "Escape" : !root.current?.contains(event.target)) setOpen(false);
14
+ };
15
+ document.addEventListener("mousedown", close);
16
+ document.addEventListener("keydown", close);
17
+ return () => {
18
+ document.removeEventListener("mousedown", close);
19
+ document.removeEventListener("keydown", close);
20
+ };
21
+ }, [open]);
22
+ return /* @__PURE__ */ jsxs("div", {
23
+ ref: root,
24
+ className: "relative flex justify-center",
25
+ children: [/* @__PURE__ */ jsx("button", {
26
+ type: "button",
27
+ "aria-label": label,
28
+ "aria-haspopup": "menu",
29
+ "aria-expanded": open,
30
+ onClick: () => setOpen(!open),
31
+ className: "inline-flex size-7 items-center justify-center rounded text-faint-foreground hover:bg-muted hover:text-foreground pointer-coarse:size-11",
32
+ children: /* @__PURE__ */ jsx(MoreHorizontal, { className: "size-4" })
33
+ }), open && /* @__PURE__ */ jsx("ul", {
34
+ role: "menu",
35
+ className: "absolute right-0 top-full z-30 mt-1 min-w-36 rounded-md border bg-background py-1 text-[13px] shadow-pop",
36
+ children: items.map((item) => /* @__PURE__ */ jsx("li", {
37
+ role: "none",
38
+ children: /* @__PURE__ */ jsx("button", {
39
+ type: "button",
40
+ role: "menuitem",
41
+ disabled: item.disabled,
42
+ className: cn("block min-h-9 w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-muted disabled:text-faint-foreground disabled:hover:bg-transparent", item.destructive && "text-danger-text"),
43
+ onClick: () => {
44
+ setOpen(false);
45
+ item.onSelect();
46
+ },
47
+ children: item.label
48
+ })
49
+ }, item.label))
50
+ })]
51
+ });
52
+ };
53
+ //#endregion
54
+ export { LineMenu };
@@ -0,0 +1,91 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { lineTotalCents } from "../mocks/invoices.js";
3
+ import { lineGrid } from "./columns.js";
4
+ import { formatMoney } from "../format/money.js";
5
+ import { LineMenu } from "./line-menu.js";
6
+ import { NumberCell } from "./number-cell.js";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ import { GripVertical } from "lucide-react";
9
+ import { useSortable } from "@dnd-kit/sortable";
10
+ //#region src/inline-child-table/line-row.tsx
11
+ const parseEuros = (text) => Math.round(Number(text.replace(",", ".").replace(/[^0-9.]/g, "")) * 100);
12
+ const LineRow = ({ line, position, count, reorderable, editable, onChange, onRemove, onMove }) => {
13
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
14
+ id: line.id,
15
+ disabled: !reorderable
16
+ });
17
+ const menu = [...reorderable ? [{
18
+ label: "Move up",
19
+ disabled: position === 0,
20
+ onSelect: () => onMove(position - 1)
21
+ }, {
22
+ label: "Move down",
23
+ disabled: position === count - 1,
24
+ onSelect: () => onMove(position + 1)
25
+ }] : [], ...editable ? [{
26
+ label: "Remove line",
27
+ destructive: true,
28
+ onSelect: onRemove
29
+ }] : []];
30
+ return /* @__PURE__ */ jsxs("div", {
31
+ ref: setNodeRef,
32
+ "data-line-id": line.id,
33
+ style: {
34
+ transform: transform ? `translate3d(0, ${transform.y}px, 0)` : void 0,
35
+ transition
36
+ },
37
+ className: cn("group relative border-b border-border bg-background px-2 py-1 last:border-b-0 hover:bg-surface", lineGrid, isDragging && "z-10 bg-surface opacity-80 shadow-pop"),
38
+ children: [
39
+ reorderable ? /* @__PURE__ */ jsx("button", {
40
+ ref: setActivatorNodeRef,
41
+ type: "button",
42
+ "aria-label": `Drag to reorder ${line.description}`,
43
+ ...attributes,
44
+ ...listeners,
45
+ style: { touchAction: "none" },
46
+ className: "flex size-7 cursor-grab items-center justify-center rounded text-faint-foreground opacity-60 hover:bg-muted hover:opacity-100 focus-visible:opacity-100 active:cursor-grabbing pointer-coarse:size-11",
47
+ children: /* @__PURE__ */ jsx(GripVertical, { className: "size-4" })
48
+ }) : /* @__PURE__ */ jsx("span", {}),
49
+ /* @__PURE__ */ jsxs("div", {
50
+ className: "min-w-0",
51
+ children: [/* @__PURE__ */ jsx("p", {
52
+ className: "truncate text-[13px] text-foreground",
53
+ children: line.description
54
+ }), /* @__PURE__ */ jsx("p", {
55
+ className: "font-mono text-[11px] text-faint-foreground",
56
+ children: line.productId
57
+ })]
58
+ }),
59
+ editable ? /* @__PURE__ */ jsx(NumberCell, {
60
+ label: "Quantity",
61
+ value: line.quantity,
62
+ format: String,
63
+ parse: (text) => Math.round(Number(text)),
64
+ onCommit: (quantity) => onChange({ quantity })
65
+ }) : /* @__PURE__ */ jsx("span", {
66
+ className: "px-2 text-right text-[13px] tabular-nums",
67
+ children: line.quantity
68
+ }),
69
+ editable ? /* @__PURE__ */ jsx(NumberCell, {
70
+ label: "Unit price",
71
+ value: line.unitPriceCents,
72
+ format: (cents) => (cents / 100).toFixed(2),
73
+ parse: parseEuros,
74
+ onCommit: (unitPriceCents) => onChange({ unitPriceCents })
75
+ }) : /* @__PURE__ */ jsx("span", {
76
+ className: "px-2 text-right text-[13px] tabular-nums",
77
+ children: (line.unitPriceCents / 100).toFixed(2)
78
+ }),
79
+ /* @__PURE__ */ jsx("span", {
80
+ className: "px-2 text-right text-[13px] font-medium tabular-nums text-foreground",
81
+ children: formatMoney(lineTotalCents(line))
82
+ }),
83
+ menu.length > 0 ? /* @__PURE__ */ jsx(LineMenu, {
84
+ label: `Actions for ${line.description}`,
85
+ items: menu
86
+ }) : /* @__PURE__ */ jsx("span", {})
87
+ ]
88
+ });
89
+ };
90
+ //#endregion
91
+ export { LineRow };
@@ -0,0 +1,25 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { useState } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/inline-child-table/number-cell.tsx
5
+ const NumberCell = ({ value, onCommit, format, parse, label, width = "w-full" }) => {
6
+ const [draft, setDraft] = useState(null);
7
+ const commit = () => {
8
+ if (draft === null) return;
9
+ const parsed = parse(draft);
10
+ setDraft(null);
11
+ if (Number.isFinite(parsed) && parsed >= 0) onCommit(parsed);
12
+ };
13
+ return /* @__PURE__ */ jsx("input", {
14
+ "aria-label": label,
15
+ inputMode: "decimal",
16
+ value: draft ?? format(value),
17
+ onFocus: (event) => event.currentTarget.select(),
18
+ onChange: (event) => setDraft(event.target.value),
19
+ onBlur: commit,
20
+ onKeyDown: (event) => event.key === "Enter" && event.currentTarget.blur(),
21
+ className: cn("h-7 rounded-md border border-transparent bg-transparent px-2 text-right text-[13px] tabular-nums text-foreground outline-none hover:border-border-strong focus:border-primary focus:bg-background focus:ring-2 focus:ring-primary/20", width)
22
+ });
23
+ };
24
+ //#endregion
25
+ export { NumberCell };
@@ -0,0 +1,10 @@
1
+ //#region src/inline-child-table/reorder.ts
2
+ const reorder = (items, from, to) => {
3
+ if (from === to || from < 0 || from >= items.length) return [...items];
4
+ const next = [...items];
5
+ const [moved] = next.splice(from, 1);
6
+ next.splice(Math.min(Math.max(to, 0), next.length), 0, moved);
7
+ return next;
8
+ };
9
+ //#endregion
10
+ export { reorder };
@@ -0,0 +1,42 @@
1
+ import { invoiceTotals } from "../mocks/invoices.js";
2
+ import { formatMoney } from "../format/money.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/inline-child-table/totals-footer.tsx
5
+ const TotalsFooter = ({ lines, vatRate }) => {
6
+ const totals = invoiceTotals(lines, vatRate);
7
+ return /* @__PURE__ */ jsxs("dl", {
8
+ className: "ml-auto grid w-64 grid-cols-[1fr_auto] gap-x-6 gap-y-1 px-2 pt-3 text-[13px] tabular-nums",
9
+ children: [
10
+ /* @__PURE__ */ jsx("dt", {
11
+ className: "text-muted-foreground",
12
+ children: "Subtotal"
13
+ }),
14
+ /* @__PURE__ */ jsx("dd", {
15
+ className: "text-right text-foreground",
16
+ children: formatMoney(totals.subtotalCents)
17
+ }),
18
+ /* @__PURE__ */ jsxs("dt", {
19
+ className: "text-muted-foreground",
20
+ children: [
21
+ "VAT ",
22
+ Math.round(vatRate * 100),
23
+ "%"
24
+ ]
25
+ }),
26
+ /* @__PURE__ */ jsx("dd", {
27
+ className: "text-right text-foreground",
28
+ children: formatMoney(totals.vatCents)
29
+ }),
30
+ /* @__PURE__ */ jsx("dt", {
31
+ className: "mt-1 border-t border-border pt-2 font-semibold text-foreground",
32
+ children: "Total"
33
+ }),
34
+ /* @__PURE__ */ jsx("dd", {
35
+ className: "mt-1 border-t border-border pt-2 text-right text-[15px] font-semibold text-foreground",
36
+ children: formatMoney(totals.totalCents)
37
+ })
38
+ ]
39
+ });
40
+ };
41
+ //#endregion
42
+ export { TotalsFooter };
package/dist/lib/cn.js ADDED
@@ -0,0 +1,4 @@
1
+ //#region src/lib/cn.ts
2
+ const cn = (...parts) => parts.filter(Boolean).join(" ");
3
+ //#endregion
4
+ export { cn };
@@ -0,0 +1,12 @@
1
+ //#region src/lib/guarded.ts
2
+ /** Runs a storage access; storage can be unavailable (private windows, blocked site data), which is a DOMException. Anything else is a bug. */
3
+ const guarded = (action, fallback) => {
4
+ try {
5
+ return action();
6
+ } catch (error) {
7
+ if (!(error instanceof DOMException)) throw error;
8
+ return fallback;
9
+ }
10
+ };
11
+ //#endregion
12
+ export { guarded };
@@ -0,0 +1,13 @@
1
+ import { useEffect, useState } from "react";
2
+ //#region src/lib/use-debounced.ts
3
+ /** `value`, once it has stayed the same for `ms`. */
4
+ const useDebounced = (value, ms) => {
5
+ const [settled, setSettled] = useState(value);
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => setSettled(value), ms);
8
+ return () => clearTimeout(timer);
9
+ }, [value, ms]);
10
+ return settled;
11
+ };
12
+ //#endregion
13
+ export { useDebounced };
@@ -0,0 +1,46 @@
1
+ import { useEffect, useRef } from "react";
2
+ //#region src/lib/use-focus-trap.ts
3
+ const focusable = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
4
+ /**
5
+ * While `active`: moves focus into `container`, keeps Tab inside it, locks body scrolling,
6
+ * calls `onEscape` on Escape, and returns focus to the previously focused element afterwards.
7
+ */
8
+ const useFocusTrap = (container, active, onEscape) => {
9
+ const escape = useRef(onEscape);
10
+ escape.current = onEscape;
11
+ useEffect(() => {
12
+ if (!active || !container.current) return;
13
+ const root = container.current;
14
+ const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
15
+ const overflow = document.body.style.overflow;
16
+ document.body.style.overflow = "hidden";
17
+ const items = () => [...root.querySelectorAll(focusable)];
18
+ (items()[0] ?? root).focus();
19
+ const onKey = (event) => {
20
+ if (event.key === "Escape") return escape.current();
21
+ if (event.key !== "Tab") return;
22
+ const list = items();
23
+ if (list.length === 0) return event.preventDefault();
24
+ const first = list[0];
25
+ const last = list[list.length - 1];
26
+ if (event.shiftKey && document.activeElement === first) {
27
+ event.preventDefault();
28
+ last.focus();
29
+ } else if (!event.shiftKey && document.activeElement === last) {
30
+ event.preventDefault();
31
+ first.focus();
32
+ } else if (!root.contains(document.activeElement)) {
33
+ event.preventDefault();
34
+ first.focus();
35
+ }
36
+ };
37
+ document.addEventListener("keydown", onKey);
38
+ return () => {
39
+ document.removeEventListener("keydown", onKey);
40
+ document.body.style.overflow = overflow;
41
+ previous?.focus();
42
+ };
43
+ }, [active, container]);
44
+ };
45
+ //#endregion
46
+ export { useFocusTrap };
@@ -0,0 +1,12 @@
1
+ import { useSyncExternalStore } from "react";
2
+ //#region src/lib/use-media-query.ts
3
+ /** Follows a CSS media query, for layout decisions that cannot be made with classes alone. */
4
+ const useMediaQuery = (query) => useSyncExternalStore((onChange) => {
5
+ const list = window.matchMedia(query);
6
+ list.addEventListener("change", onChange);
7
+ return () => list.removeEventListener("change", onChange);
8
+ }, () => window.matchMedia(query).matches, () => false);
9
+ /** The `md` breakpoint: where the sidebar stops being a drawer and tables stop squeezing. */
10
+ const useIsDesktop = () => useMediaQuery("(min-width: 768px)");
11
+ //#endregion
12
+ export { useIsDesktop, useMediaQuery };
@@ -0,0 +1,93 @@
1
+ import { humanize } from "./naming.js";
2
+ import { enumLabel } from "@protobase/schema";
3
+ //#region src/live/columns-from-model.ts
4
+ const tones = {
5
+ draft: "neutral",
6
+ confirmed: "blue",
7
+ picking: "amber",
8
+ shipped: "violet",
9
+ delivered: "green",
10
+ cancelled: "red",
11
+ sent: "blue",
12
+ paid: "green",
13
+ overdue: "red",
14
+ lead: "blue",
15
+ active: "green",
16
+ dormant: "neutral",
17
+ receipt: "green",
18
+ issue: "blue",
19
+ transfer: "violet",
20
+ adjustment: "amber"
21
+ };
22
+ const toneFor = (value) => tones[value] ?? "neutral";
23
+ const widths = {
24
+ text: 200,
25
+ id: 140,
26
+ money: 120,
27
+ date: 110,
28
+ datetime: 170,
29
+ status: 150,
30
+ relation: 200,
31
+ boolean: 64,
32
+ number: 100,
33
+ signed: 110,
34
+ percent: 110,
35
+ user: 130
36
+ };
37
+ /** Room for a header at 12px plus cell padding, the sort arrow and the column menu button. */
38
+ const headerWidth = (header, sortable) => Math.ceil(header.length * 7.2) + 24 + (sortable ? 16 : 0) + 28;
39
+ /** A status badge: dot, padding and the longest label. */
40
+ const badgeWidth = (labels) => Math.ceil(Math.max(0, ...labels.map((label) => label.length)) * 7.2) + 56;
41
+ const fixedKinds = /* @__PURE__ */ new Set([
42
+ "datetime",
43
+ "date",
44
+ "boolean",
45
+ "percent"
46
+ ]);
47
+ const idLike = /^(number|sku|reference|code)$/;
48
+ const kindOf = (field, view, index) => {
49
+ switch (field.type) {
50
+ case "relation": return "relation";
51
+ case "enum": return "status";
52
+ case "boolean": return "boolean";
53
+ case "timestamp": return "datetime";
54
+ case "date": return "date";
55
+ case "decimal": return view?.format === "percent" ? "percent" : view?.prefix === "€" ? "money" : "number";
56
+ case "integer":
57
+ case "bigint": return /quantity|delta/.test(field.name) ? "signed" : "number";
58
+ default: return idLike.test(field.name) || index === 0 && field.name === "id" ? "id" : "text";
59
+ }
60
+ };
61
+ const currencyCents = (value) => Math.round(Number.parseFloat(String(value)) * 100);
62
+ /** Grid columns for a view's list: kinds follow the field types and the view's format hints. */
63
+ const columnsFromModel = (resource, view, highlight = []) => {
64
+ return (view?.list?.columns ?? Object.keys(resource.fields).slice(0, 6)).flatMap((name, index) => {
65
+ const field = resource.fields[name];
66
+ if (!field) return [];
67
+ const viewField = view?.fields[name];
68
+ const kind = kindOf(field, viewField, index);
69
+ const labels = Object.fromEntries((field.enumValues ?? []).map((value) => [value, enumLabel(value, viewField?.valueLabels)]));
70
+ return [{
71
+ id: name,
72
+ header: viewField?.label ?? humanize(name),
73
+ kind,
74
+ width: name === "name" ? 260 : widths[kind],
75
+ sortable: field.sortable,
76
+ minWidth: Math.max(kind === "id" ? 150 : 0, headerWidth(viewField?.label ?? humanize(name), field.sortable), kind === "status" ? badgeWidth(Object.values(labels)) : 0),
77
+ ...fixedKinds.has(kind) && { fixed: true },
78
+ ...highlight.includes(name) && { highlight: true },
79
+ ...field.type === "enum" && {
80
+ tones: Object.fromEntries((field.enumValues ?? []).map((value) => [value, toneFor(value)])),
81
+ labels
82
+ },
83
+ ...kind === "money" && {
84
+ currency: "EUR",
85
+ value: (row) => row[name] == null ? null : currencyCents(row[name])
86
+ },
87
+ ...(kind === "datetime" || kind === "date") && { value: (row) => row[name] == null ? null : Date.parse(String(row[name])) },
88
+ ...(kind === "percent" || kind === "number" && field.type === "decimal") && { value: (row) => row[name] == null ? null : Number(row[name]) }
89
+ }];
90
+ });
91
+ };
92
+ //#endregion
93
+ export { columnsFromModel, headerWidth, toneFor };
@@ -0,0 +1,24 @@
1
+ import { formatDate, formatDateTime } from "../format/date.js";
2
+ import { enumLabel } from "@protobase/schema";
3
+ //#region src/live/display-value.ts
4
+ /** A read-only rendering of a stored value, following the view's prefix, decimals, format and value label hints. */
5
+ const displayValue = (field, value, hints = {}) => {
6
+ if (value === null || value === void 0 || value === "") return "—";
7
+ switch (field.type) {
8
+ case "boolean": return value ? "Yes" : "No";
9
+ case "enum": return enumLabel(String(value), hints.valueLabels);
10
+ case "timestamp": return formatDateTime(Date.parse(String(value)));
11
+ case "date": return formatDate(Date.parse(String(value)));
12
+ case "decimal": {
13
+ const text = hints.decimals === void 0 ? String(value) : Number(value).toFixed(hints.decimals);
14
+ if (hints.format === "percent") return `${Number(value)}%`;
15
+ return `${hints.prefix ?? ""}${Number(text).toLocaleString("en-IE", {
16
+ minimumFractionDigits: hints.decimals ?? 0,
17
+ maximumFractionDigits: hints.decimals ?? 20
18
+ })}`;
19
+ }
20
+ default: return typeof value === "object" ? JSON.stringify(value) : String(value);
21
+ }
22
+ };
23
+ //#endregion
24
+ export { displayValue };