@protobase/ui 0.0.0-stage → 0.1.1

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 (232) 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 +123 -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 +30 -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/assistant-dock/assistant-dock.d.ts +15 -0
  104. package/dist/assistant-dock/assistant-dock.js +81 -0
  105. package/dist/assistant-dock/chat-message.js +9 -0
  106. package/dist/assistant-dock/composer.js +39 -0
  107. package/dist/assistant-dock/dock-item.d.ts +4 -0
  108. package/dist/assistant-dock/dock-item.js +37 -0
  109. package/dist/assistant-dock/model.d.ts +84 -0
  110. package/dist/assistant-dock/model.js +26 -0
  111. package/dist/assistant-dock/plan-card.d.ts +13 -0
  112. package/dist/assistant-dock/plan-card.js +133 -0
  113. package/dist/assistant-dock/progress-card.js +18 -0
  114. package/dist/assistant-dock/query-card.js +52 -0
  115. package/dist/assistant-dock/queue-card.js +31 -0
  116. package/dist/assistant-dock/result-card.js +77 -0
  117. package/dist/assistant-panel/content.d.ts +7 -0
  118. package/dist/assistant-panel/step-list.js +30 -0
  119. package/dist/auth/auth-gate.js +70 -0
  120. package/dist/auth/auth-layout.js +29 -0
  121. package/dist/auth/auth-provider.js +71 -0
  122. package/dist/auth/first-run-page.js +51 -0
  123. package/dist/auth/forgot-password-form.js +75 -0
  124. package/dist/auth/forgot-password-page.js +35 -0
  125. package/dist/auth/reset-link.js +29 -0
  126. package/dist/auth/reset-password-form.js +88 -0
  127. package/dist/auth/reset-password-page.js +43 -0
  128. package/dist/auth/sign-in-form.js +90 -0
  129. package/dist/auth/sign-in-page.js +39 -0
  130. package/dist/auth/user-to-shell.js +18 -0
  131. package/dist/data/api-provider.d.ts +54 -0
  132. package/dist/data/api-provider.js +53 -0
  133. package/dist/data/meta-data.d.ts +14 -0
  134. package/dist/data/meta-data.js +9 -0
  135. package/dist/data/query-keys.js +95 -0
  136. package/dist/data/use-aggregates.d.ts +8 -0
  137. package/dist/data/use-aggregates.js +43 -0
  138. package/dist/data/use-list.d.ts +231 -0
  139. package/dist/data/use-list.js +31 -0
  140. package/dist/data/use-meta.d.ts +8 -0
  141. package/dist/data/use-meta.js +35 -0
  142. package/dist/data/use-record.d.ts +6 -0
  143. package/dist/data/use-record.js +16 -0
  144. package/dist/data/use-update-record.d.ts +15 -0
  145. package/dist/data/use-update-record.js +26 -0
  146. package/dist/data-grid/cells.js +85 -0
  147. package/dist/data-grid/column-spec.js +4 -0
  148. package/dist/data-grid/columns.js +16 -0
  149. package/dist/data-grid/data-grid.js +238 -0
  150. package/dist/data-grid/geometry.js +32 -0
  151. package/dist/data-grid/grid-footer.js +33 -0
  152. package/dist/data-grid/grid-header.js +69 -0
  153. package/dist/data-grid/grid-row.js +83 -0
  154. package/dist/data-grid/grid-template.js +12 -0
  155. package/dist/data-grid/header-menu.js +52 -0
  156. package/dist/data-grid/page-cache.js +27 -0
  157. package/dist/data-grid/pinned-storage.js +13 -0
  158. package/dist/data-grid/row-menu.js +88 -0
  159. package/dist/data-grid/use-grid-scroll.js +80 -0
  160. package/dist/data-grid/use-horizontal-scroll.js +38 -0
  161. package/dist/data-grid/use-paged-rows.js +50 -0
  162. package/dist/data-grid/use-pinned-columns.js +16 -0
  163. package/dist/filter-panel/active-filter-chips.js +34 -0
  164. package/dist/filter-panel/date-presets.js +20 -0
  165. package/dist/filter-panel/facet-group.js +48 -0
  166. package/dist/filter-panel/filter-config.js +18 -0
  167. package/dist/filter-panel/filter-menu.js +41 -0
  168. package/dist/filter-panel/filter-panel.js +112 -0
  169. package/dist/filter-panel/filter-section.js +11 -0
  170. package/dist/filter-panel/filter-state.js +63 -0
  171. package/dist/filter-panel/range-filter.js +54 -0
  172. package/dist/filter-panel/range-math.js +11 -0
  173. package/dist/filter-panel/range-slider.css +32 -0
  174. package/dist/filter-panel/toggle-row.js +13 -0
  175. package/dist/filter-panel/use-filter-state.js +15 -0
  176. package/dist/format/date.js +37 -0
  177. package/dist/format/money.js +15 -0
  178. package/dist/format/number.js +12 -0
  179. package/dist/index.d.ts +14 -0
  180. package/dist/index.js +11 -0
  181. package/dist/inline-child-table/columns.js +4 -0
  182. package/dist/inline-child-table/inline-child-table.js +120 -0
  183. package/dist/inline-child-table/line-menu.js +54 -0
  184. package/dist/inline-child-table/line-row.js +91 -0
  185. package/dist/inline-child-table/number-cell.js +25 -0
  186. package/dist/inline-child-table/reorder.js +10 -0
  187. package/dist/inline-child-table/totals-footer.js +42 -0
  188. package/dist/lib/cn.js +4 -0
  189. package/dist/lib/guarded.js +12 -0
  190. package/dist/lib/use-debounced.js +13 -0
  191. package/dist/lib/use-focus-trap.js +46 -0
  192. package/dist/lib/use-media-query.js +12 -0
  193. package/dist/live/columns-from-model.js +93 -0
  194. package/dist/live/display-value.js +24 -0
  195. package/dist/live/field-values.js +32 -0
  196. package/dist/live/filter-string.js +135 -0
  197. package/dist/live/grid-loader.js +63 -0
  198. package/dist/live/model-helpers.js +30 -0
  199. package/dist/live/naming.js +9 -0
  200. package/dist/live/relation-labels.js +53 -0
  201. package/dist/live/use-filter-config.js +88 -0
  202. package/dist/live/use-live-rows.js +45 -0
  203. package/dist/live/view-fields.js +35 -0
  204. package/dist/mocks/activity.js +24 -0
  205. package/dist/mocks/invoices.js +13 -0
  206. package/dist/mocks/products.js +167 -0
  207. package/dist/mocks/rng.js +20 -0
  208. package/dist/mocks/users.js +63 -0
  209. package/dist/primitives/avatar.js +22 -0
  210. package/dist/primitives/badge.d.ts +1 -0
  211. package/dist/primitives/badge.js +25 -0
  212. package/dist/primitives/button.js +23 -0
  213. package/dist/primitives/checkbox.js +21 -0
  214. package/dist/primitives/dialog.js +41 -0
  215. package/dist/primitives/input.js +16 -0
  216. package/dist/primitives/kbd.js +8 -0
  217. package/dist/primitives/segmented.js +14 -0
  218. package/dist/primitives/sheet.js +44 -0
  219. package/dist/primitives/skeleton.js +6 -0
  220. package/dist/primitives/spinner.js +10 -0
  221. package/dist/primitives/switch.js +14 -0
  222. package/dist/primitives/tooltip.js +13 -0
  223. package/dist/record-view/action-rail.js +28 -0
  224. package/dist/record-view/field.js +62 -0
  225. package/dist/record-view/record-header.js +50 -0
  226. package/dist/record-view/record-sidebar.js +58 -0
  227. package/dist/record-view/section.js +21 -0
  228. package/dist/styles.css +132 -0
  229. package/dist/toasts/toast-provider.js +69 -0
  230. package/dist/toasts/toast.js +45 -0
  231. package/package.json +45 -3
  232. package/README.md +0 -3
@@ -0,0 +1,80 @@
1
+ import { clampTop, gridGeometry, scrollFromTop, topFromScroll } from "./geometry.js";
2
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ //#region src/data-grid/use-grid-scroll.ts
4
+ /**
5
+ * Owns the scroll position of a grid whose true height may exceed what browsers can scroll.
6
+ * The native scroller is a proxy: its position is mapped onto a virtual pixel offset `top`.
7
+ */
8
+ const useGridScroll = (total, rowHeight, initialIndex) => {
9
+ const ref = useRef(null);
10
+ const [viewport, setViewport] = useState(0);
11
+ const [top, setTop] = useState(initialIndex * rowHeight);
12
+ const geometry = useMemo(() => gridGeometry(total, rowHeight, viewport), [
13
+ total,
14
+ rowHeight,
15
+ viewport
16
+ ]);
17
+ const latest = useRef({
18
+ geometry,
19
+ top
20
+ });
21
+ latest.current = {
22
+ geometry,
23
+ top
24
+ };
25
+ const ignoreScrollUntil = useRef(0);
26
+ const positioned = useRef(false);
27
+ const syncNative = useCallback((nextTop) => {
28
+ const element = ref.current;
29
+ if (!element) return;
30
+ element.scrollTop = scrollFromTop(latest.current.geometry, nextTop);
31
+ ignoreScrollUntil.current = performance.now() + 150;
32
+ }, []);
33
+ useLayoutEffect(() => {
34
+ const element = ref.current;
35
+ if (!element) return;
36
+ const observer = new ResizeObserver(() => setViewport(element.clientHeight));
37
+ observer.observe(element);
38
+ setViewport(element.clientHeight);
39
+ return () => observer.disconnect();
40
+ }, []);
41
+ useLayoutEffect(() => {
42
+ if (viewport === 0 || positioned.current) return;
43
+ positioned.current = true;
44
+ const next = clampTop(geometry, top);
45
+ setTop(next);
46
+ syncNative(next);
47
+ }, [
48
+ viewport,
49
+ geometry,
50
+ top,
51
+ syncNative
52
+ ]);
53
+ useEffect(() => {
54
+ const element = ref.current;
55
+ if (!element) return;
56
+ const onWheel = (event) => {
57
+ const { geometry: current, top: currentTop } = latest.current;
58
+ if (!current.scaled || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
59
+ event.preventDefault();
60
+ const next = clampTop(current, currentTop + event.deltaY);
61
+ setTop(next);
62
+ syncNative(next);
63
+ };
64
+ element.addEventListener("wheel", onWheel, { passive: false });
65
+ return () => element.removeEventListener("wheel", onWheel);
66
+ }, [syncNative]);
67
+ return {
68
+ ref,
69
+ onScroll: useCallback(() => {
70
+ const element = ref.current;
71
+ if (!element || performance.now() < ignoreScrollUntil.current) return;
72
+ setTop(topFromScroll(latest.current.geometry, element.scrollTop));
73
+ }, []),
74
+ viewport,
75
+ top,
76
+ geometry
77
+ };
78
+ };
79
+ //#endregion
80
+ export { useGridScroll };
@@ -0,0 +1,38 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ //#region src/data-grid/use-horizontal-scroll.ts
3
+ /** Tracks whether the grid is scrolled sideways (`scrolled`) and whether more lies to the right (`more`). */
4
+ const useHorizontalScroll = () => {
5
+ const ref = useRef(null);
6
+ const observer = useRef(null);
7
+ const [edges, setEdges] = useState({
8
+ scrolled: false,
9
+ more: false
10
+ });
11
+ const measure = useCallback(() => {
12
+ const element = ref.current;
13
+ if (!element) return;
14
+ const next = {
15
+ scrolled: element.scrollLeft > 1,
16
+ more: element.scrollLeft + element.clientWidth < element.scrollWidth - 1
17
+ };
18
+ setEdges((current) => current.scrolled === next.scrolled && current.more === next.more ? current : next);
19
+ }, []);
20
+ return {
21
+ ref,
22
+ attach: useCallback((element) => {
23
+ observer.current?.disconnect();
24
+ observer.current = null;
25
+ ref.current = element;
26
+ if (!element) return;
27
+ const next = new ResizeObserver(measure);
28
+ next.observe(element);
29
+ for (const child of Array.from(element.children)) next.observe(child);
30
+ observer.current = next;
31
+ measure();
32
+ }, [measure]),
33
+ onScroll: measure,
34
+ ...edges
35
+ };
36
+ };
37
+ //#endregion
38
+ export { useHorizontalScroll };
@@ -0,0 +1,50 @@
1
+ import { createPageCache, pagesForRange } from "./page-cache.js";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ /**
4
+ * Serves rows by absolute index. Mock sources compute rows (after `latencyMs`, or at once when 0);
5
+ * live sources load pages through `loadRows`. Unloaded rows read as undefined, so fast scrolling shows skeletons.
6
+ */
7
+ const usePagedRows = ({ total, rowAt, loadRows, latencyMs }, start, end) => {
8
+ const cache = useMemo(() => createPageCache(40, 60), [rowAt, loadRows]);
9
+ const [version, setVersion] = useState(0);
10
+ const firstPage = Math.floor(start / 40);
11
+ const lastPage = Math.floor(end / 40);
12
+ const synchronous = !loadRows && latencyMs === 0;
13
+ useEffect(() => {
14
+ if (synchronous) return;
15
+ const pages = pagesForRange(firstPage * 40, lastPage * 40, 40).filter((page) => !cache.hasPage(page));
16
+ const length = (page) => Math.max(0, Math.min(40, total - page * 40));
17
+ const store = (page, rows) => {
18
+ cache.setPage(page, rows);
19
+ setVersion((current) => current + 1);
20
+ };
21
+ if (loadRows) {
22
+ let cancelled = false;
23
+ for (const page of pages) loadRows(page * 40, length(page)).then((rows) => {
24
+ if (!cancelled) store(page, rows);
25
+ });
26
+ return () => {
27
+ cancelled = true;
28
+ };
29
+ }
30
+ const timers = pages.map((page) => setTimeout(() => store(page, Array.from({ length: length(page) }, (_, offset) => rowAt(page * 40 + offset))), latencyMs));
31
+ return () => timers.forEach(clearTimeout);
32
+ }, [
33
+ cache,
34
+ firstPage,
35
+ lastPage,
36
+ latencyMs,
37
+ total,
38
+ rowAt,
39
+ loadRows,
40
+ synchronous
41
+ ]);
42
+ return useCallback((index) => synchronous ? rowAt(index) : cache.get(index), [
43
+ cache,
44
+ synchronous,
45
+ rowAt,
46
+ version
47
+ ]);
48
+ };
49
+ //#endregion
50
+ export { usePagedRows };
@@ -0,0 +1,16 @@
1
+ import { readPinned, writePinned } from "./pinned-storage.js";
2
+ import { useCallback, useState } from "react";
3
+ //#region src/data-grid/use-pinned-columns.ts
4
+ /**
5
+ * The ids of the columns pinned to the left, in pin order. With a `storageKey` the choice is kept in local storage
6
+ * (per user and resource); `initial` applies until the user has chosen.
7
+ */
8
+ const usePinnedColumns = (storageKey, initial = []) => {
9
+ const [pinned, setPinned] = useState(() => (storageKey ? readPinned(localStorage, storageKey) : void 0) ?? initial);
10
+ return [pinned, useCallback((next) => {
11
+ setPinned(next);
12
+ if (storageKey) writePinned(localStorage, storageKey, next);
13
+ }, [storageKey])];
14
+ };
15
+ //#endregion
16
+ export { usePinnedColumns };
@@ -0,0 +1,34 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Lock, X } from "lucide-react";
3
+ //#region src/filter-panel/active-filter-chips.tsx
4
+ const chipClass = "inline-flex h-6 items-center gap-1 rounded-full border border-primary-border bg-primary-soft pl-2.5 pr-1.5 text-xs font-medium text-primary-text";
5
+ const ActiveFilterChips = ({ chips, lockedLabels = [], onRemove, onClear, scroll }) => {
6
+ if (chips.length === 0 && lockedLabels.length === 0) return null;
7
+ return /* @__PURE__ */ jsxs("div", {
8
+ className: scroll ? "flex flex-nowrap items-center gap-1.5 overflow-x-auto pb-1 [&>*]:shrink-0" : "flex flex-wrap items-center gap-1.5",
9
+ children: [
10
+ lockedLabels.map((label) => /* @__PURE__ */ jsxs("span", {
11
+ className: "inline-flex h-6 items-center gap-1.5 rounded-full border border-border-strong bg-muted px-2.5 text-xs font-medium text-foreground",
12
+ children: [/* @__PURE__ */ jsx(Lock, { className: "size-3 text-muted-foreground" }), label]
13
+ }, label)),
14
+ chips.map((chip) => /* @__PURE__ */ jsxs("span", {
15
+ className: chipClass,
16
+ children: [chip.label, /* @__PURE__ */ jsx("button", {
17
+ type: "button",
18
+ "aria-label": `Remove ${chip.label}`,
19
+ onClick: () => onRemove(chip.key),
20
+ className: "rounded-full p-0.5 hover:bg-primary-border/60",
21
+ children: /* @__PURE__ */ jsx(X, { className: "size-3" })
22
+ })]
23
+ }, chip.key)),
24
+ chips.length > 0 && /* @__PURE__ */ jsx("button", {
25
+ type: "button",
26
+ onClick: onClear,
27
+ className: "ml-1 text-xs font-medium text-muted-foreground hover:text-foreground",
28
+ children: "Clear all"
29
+ })
30
+ ]
31
+ });
32
+ };
33
+ //#endregion
34
+ export { ActiveFilterChips };
@@ -0,0 +1,20 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { datePresetLabels, defaultDatePresets } from "./filter-config.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/filter-panel/date-presets.tsx
5
+ const DatePresets = ({ value, presets = defaultDatePresets, onChange }) => /* @__PURE__ */ jsx("div", {
6
+ className: "flex flex-wrap gap-1.5",
7
+ children: presets.map((id) => {
8
+ const label = datePresetLabels[id];
9
+ return /* @__PURE__ */ jsx("button", {
10
+ type: "button",
11
+ disabled: !label,
12
+ title: label ? void 0 : `The server offers the preset "${id}", which this UI does not know`,
13
+ onClick: () => onChange(value === id ? void 0 : id),
14
+ className: cn("h-7 rounded-md border px-2.5 text-xs font-medium transition-colors", !label && "border-dashed text-danger-text", value === id ? "border-primary-border bg-primary-soft text-primary-text" : "border-border-strong bg-background text-muted-foreground hover:bg-muted"),
15
+ children: label ?? `${id} (unsupported)`
16
+ }, id);
17
+ })
18
+ });
19
+ //#endregion
20
+ export { DatePresets };
@@ -0,0 +1,48 @@
1
+ import { Input } from "../primitives/input.js";
2
+ import { formatInt } from "../format/number.js";
3
+ import { Checkbox } from "../primitives/checkbox.js";
4
+ import { matchesSearch } from "./filter-state.js";
5
+ import { useState } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ import { Search } from "lucide-react";
8
+ //#region src/filter-panel/facet-group.tsx
9
+ const FacetGroup = ({ config, selected, onToggle }) => {
10
+ const [query, setQuery] = useState("");
11
+ const options = config.options.filter((option) => matchesSearch(option.label, query));
12
+ return /* @__PURE__ */ jsxs("div", {
13
+ className: "flex flex-col gap-1",
14
+ children: [
15
+ config.searchable && /* @__PURE__ */ jsx(Input, {
16
+ wrapperClassName: "mb-1 h-7",
17
+ leading: /* @__PURE__ */ jsx(Search, { className: "size-3.5" }),
18
+ placeholder: `Search ${config.label.toLowerCase()}`,
19
+ value: query,
20
+ onChange: (event) => setQuery(event.target.value)
21
+ }),
22
+ options.map((option) => /* @__PURE__ */ jsxs("label", {
23
+ className: "flex cursor-pointer items-center gap-2 rounded px-1 py-1 hover:bg-muted",
24
+ children: [
25
+ /* @__PURE__ */ jsx(Checkbox, {
26
+ checked: selected.includes(option.value),
27
+ onChange: () => onToggle(option.value),
28
+ label: option.label
29
+ }),
30
+ /* @__PURE__ */ jsx("span", {
31
+ className: "flex-1 truncate",
32
+ children: option.label
33
+ }),
34
+ /* @__PURE__ */ jsx("span", {
35
+ className: "text-xs tabular-nums text-faint-foreground",
36
+ children: formatInt(option.count)
37
+ })
38
+ ]
39
+ }, option.value)),
40
+ options.length === 0 && /* @__PURE__ */ jsx("span", {
41
+ className: "px-1 py-1 text-xs text-faint-foreground",
42
+ children: "No matches"
43
+ })
44
+ ]
45
+ });
46
+ };
47
+ //#endregion
48
+ export { FacetGroup };
@@ -0,0 +1,18 @@
1
+ //#region src/filter-panel/filter-config.ts
2
+ const defaultDatePresets = [
3
+ "7d",
4
+ "30d",
5
+ "year"
6
+ ];
7
+ const datePresetLabels = {
8
+ today: "Today",
9
+ yesterday: "Yesterday",
10
+ "7d": "Last 7 days",
11
+ "30d": "Last 30 days",
12
+ "90d": "Last 90 days",
13
+ month: "This month",
14
+ quarter: "This quarter",
15
+ year: "This year"
16
+ };
17
+ //#endregion
18
+ export { datePresetLabels, defaultDatePresets };
@@ -0,0 +1,41 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { ChevronDown } from "lucide-react";
5
+ //#region src/filter-panel/filter-menu.tsx
6
+ const FilterMenu = ({ label, activeCount, width = 240, defaultOpen = false, children }) => {
7
+ const [open, setOpen] = useState(defaultOpen);
8
+ const root = useRef(null);
9
+ useEffect(() => {
10
+ if (!open) return;
11
+ const close = (event) => {
12
+ if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false);
13
+ };
14
+ document.addEventListener("pointerdown", close);
15
+ return () => document.removeEventListener("pointerdown", close);
16
+ }, [open]);
17
+ return /* @__PURE__ */ jsxs("div", {
18
+ ref: root,
19
+ className: "relative",
20
+ children: [/* @__PURE__ */ jsxs("button", {
21
+ type: "button",
22
+ "aria-expanded": open,
23
+ onClick: () => setOpen(!open),
24
+ className: cn("inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2.5 text-[13px] font-medium shadow-sm transition-colors hover:bg-muted", activeCount > 0 ? "border-primary-border text-primary-text" : "border-border-strong text-foreground"),
25
+ children: [
26
+ label,
27
+ activeCount > 0 && /* @__PURE__ */ jsx("span", {
28
+ className: "rounded-full bg-primary px-1.5 text-[11px] leading-4 text-primary-foreground",
29
+ children: activeCount
30
+ }),
31
+ /* @__PURE__ */ jsx(ChevronDown, { className: "size-3.5 text-muted-foreground" })
32
+ ]
33
+ }), open && /* @__PURE__ */ jsx("div", {
34
+ className: "absolute left-0 top-full z-30 mt-1.5 rounded-lg border bg-background p-3 shadow-pop",
35
+ style: { width },
36
+ children
37
+ })]
38
+ });
39
+ };
40
+ //#endregion
41
+ export { FilterMenu };
@@ -0,0 +1,112 @@
1
+ import { ActiveFilterChips } from "./active-filter-chips.js";
2
+ import { DatePresets } from "./date-presets.js";
3
+ import { activeChips, emptyFilterState, removeChip, toggleFacetValue, toggleFlag } from "./filter-state.js";
4
+ import { FacetGroup } from "./facet-group.js";
5
+ import { FilterMenu } from "./filter-menu.js";
6
+ import { FilterSection } from "./filter-section.js";
7
+ import { RangeFilter } from "./range-filter.js";
8
+ import { ToggleRow } from "./toggle-row.js";
9
+ import { useFilterState } from "./use-filter-state.js";
10
+ import { jsx, jsxs } from "react/jsx-runtime";
11
+ //#region src/filter-panel/filter-panel.tsx
12
+ const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabels, defaultOpenMenu, fullWidth }) => {
13
+ const [state, setState] = useFilterState(value, defaultValue, onChange);
14
+ const chips = activeChips(config, state);
15
+ const chipsView = /* @__PURE__ */ jsx(ActiveFilterChips, {
16
+ chips,
17
+ lockedLabels,
18
+ onRemove: (key) => setState(removeChip(state, key)),
19
+ onClear: () => setState(emptyFilterState)
20
+ });
21
+ const facetView = (group) => /* @__PURE__ */ jsx(FacetGroup, {
22
+ config: group,
23
+ selected: state.facets[group.id] ?? [],
24
+ onToggle: (option) => setState(toggleFacetValue(state, group.id, option))
25
+ });
26
+ const rangeView = config.range && /* @__PURE__ */ jsx(RangeFilter, {
27
+ config: config.range,
28
+ value: state.range,
29
+ onChange: (range) => setState({
30
+ ...state,
31
+ range
32
+ })
33
+ });
34
+ const dateView = config.datePresets && /* @__PURE__ */ jsx(DatePresets, {
35
+ value: state.date,
36
+ presets: Array.isArray(config.datePresets) ? config.datePresets : void 0,
37
+ onChange: (date) => setState({
38
+ ...state,
39
+ date
40
+ })
41
+ });
42
+ const toggleViews = (config.toggles ?? []).map((toggle) => /* @__PURE__ */ jsx(ToggleRow, {
43
+ label: toggle.label,
44
+ checked: state.toggles.includes(toggle.id),
45
+ onChange: () => setState(toggleFlag(state, toggle.id))
46
+ }, toggle.id));
47
+ if (layout === "panel") return /* @__PURE__ */ jsxs("aside", {
48
+ className: fullWidth ? "flex w-full flex-col gap-1" : "flex w-64 shrink-0 flex-col gap-1 overflow-y-auto rounded-lg border bg-background px-4 py-3",
49
+ children: [
50
+ /* @__PURE__ */ jsx("div", {
51
+ className: "flex items-center justify-between pb-1",
52
+ children: /* @__PURE__ */ jsx("h2", {
53
+ className: "text-[13px] font-semibold",
54
+ children: "Filters"
55
+ })
56
+ }),
57
+ (chips.length > 0 || (lockedLabels?.length ?? 0) > 0) && /* @__PURE__ */ jsx("div", {
58
+ className: "border-b pb-3",
59
+ children: chipsView
60
+ }),
61
+ config.facets.map((group) => /* @__PURE__ */ jsx(FilterSection, {
62
+ title: group.label,
63
+ children: facetView(group)
64
+ }, group.id)),
65
+ config.range && /* @__PURE__ */ jsx(FilterSection, {
66
+ title: config.range.label,
67
+ children: rangeView
68
+ }),
69
+ dateView && /* @__PURE__ */ jsx(FilterSection, {
70
+ title: "Date",
71
+ children: dateView
72
+ }),
73
+ toggleViews.length > 0 && /* @__PURE__ */ jsx(FilterSection, {
74
+ title: "Show only",
75
+ children: toggleViews
76
+ })
77
+ ]
78
+ });
79
+ return /* @__PURE__ */ jsxs("div", {
80
+ className: "flex flex-col gap-2",
81
+ children: [/* @__PURE__ */ jsxs("div", {
82
+ className: "flex flex-wrap items-center gap-2",
83
+ children: [
84
+ config.facets.map((group) => /* @__PURE__ */ jsx(FilterMenu, {
85
+ label: group.label,
86
+ activeCount: (state.facets[group.id] ?? []).length,
87
+ defaultOpen: defaultOpenMenu === group.id,
88
+ children: facetView(group)
89
+ }, group.id)),
90
+ config.range && /* @__PURE__ */ jsx(FilterMenu, {
91
+ label: config.range.label,
92
+ activeCount: state.range ? 1 : 0,
93
+ width: 280,
94
+ defaultOpen: defaultOpenMenu === config.range.id,
95
+ children: rangeView
96
+ }),
97
+ dateView,
98
+ config.toggles && /* @__PURE__ */ jsx("span", { className: "mx-1 h-5 w-px bg-border" }),
99
+ config.toggles?.map((toggle) => /* @__PURE__ */ jsx("div", {
100
+ className: "flex items-center gap-2 text-[13px] text-muted-foreground",
101
+ children: /* @__PURE__ */ jsx(ToggleRow, {
102
+ label: toggle.label,
103
+ checked: state.toggles.includes(toggle.id),
104
+ onChange: () => setState(toggleFlag(state, toggle.id))
105
+ })
106
+ }, toggle.id))
107
+ ]
108
+ }), chipsView]
109
+ });
110
+ };
111
+ //#endregion
112
+ export { FilterPanel };
@@ -0,0 +1,11 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ //#region src/filter-panel/filter-section.tsx
3
+ const FilterSection = ({ title, children }) => /* @__PURE__ */ jsxs("section", {
4
+ className: "flex flex-col gap-2 border-b py-3 last:border-b-0",
5
+ children: [/* @__PURE__ */ jsx("h3", {
6
+ className: "text-xs font-semibold uppercase tracking-wide text-muted-foreground",
7
+ children: title
8
+ }), children]
9
+ });
10
+ //#endregion
11
+ export { FilterSection };
@@ -0,0 +1,63 @@
1
+ import { datePresetLabels } from "./filter-config.js";
2
+ //#region src/filter-panel/filter-state.ts
3
+ const emptyFilterState = {
4
+ facets: {},
5
+ toggles: []
6
+ };
7
+ const toggleFacetValue = (state, group, value) => {
8
+ const current = state.facets[group] ?? [];
9
+ const next = current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value];
10
+ return {
11
+ ...state,
12
+ facets: {
13
+ ...state.facets,
14
+ [group]: next
15
+ }
16
+ };
17
+ };
18
+ const toggleFlag = (state, id) => ({
19
+ ...state,
20
+ toggles: state.toggles.includes(id) ? state.toggles.filter((entry) => entry !== id) : [...state.toggles, id]
21
+ });
22
+ const activeChips = (config, state) => {
23
+ const facetChips = config.facets.flatMap((group) => (state.facets[group.id] ?? []).map((value) => ({
24
+ key: `facet:${group.id}:${value}`,
25
+ label: `${group.label}: ${group.options.find((option) => option.value === value)?.label ?? value}`
26
+ })));
27
+ const range = config.range;
28
+ const rangeChips = range && state.range ? [{
29
+ key: "range",
30
+ label: `${range.label}: ${range.format(state.range[0])} – ${range.format(state.range[1])}`
31
+ }] : [];
32
+ const dateChips = state.date ? [{
33
+ key: "date",
34
+ label: datePresetLabels[state.date] ?? state.date
35
+ }] : [];
36
+ const toggleChips = state.toggles.map((id) => ({
37
+ key: `toggle:${id}`,
38
+ label: config.toggles?.find((toggle) => toggle.id === id)?.label ?? id
39
+ }));
40
+ return [
41
+ ...facetChips,
42
+ ...rangeChips,
43
+ ...dateChips,
44
+ ...toggleChips
45
+ ];
46
+ };
47
+ const removeChip = (state, key) => {
48
+ if (key === "range") return {
49
+ ...state,
50
+ range: void 0
51
+ };
52
+ if (key === "date") return {
53
+ ...state,
54
+ date: void 0
55
+ };
56
+ const [kind, group, value] = key.split(":");
57
+ if (kind === "toggle") return toggleFlag(state, group);
58
+ if (kind === "facet") return toggleFacetValue(state, group, value);
59
+ throw new Error(`Unknown filter chip key: ${key}`);
60
+ };
61
+ const matchesSearch = (label, query) => label.toLowerCase().includes(query.trim().toLowerCase());
62
+ //#endregion
63
+ export { activeChips, emptyFilterState, matchesSearch, removeChip, toggleFacetValue, toggleFlag };
@@ -0,0 +1,54 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { barInRange, fraction, moveThumb } from "./range-math.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import "./range-slider.css";
5
+ //#region src/filter-panel/range-filter.tsx
6
+ const RangeFilter = ({ config, value, onChange }) => {
7
+ const { min, max, step, histogram, format } = config;
8
+ const selected = value ?? [min, max];
9
+ const peak = Math.max(...histogram);
10
+ const commit = (next) => onChange(next[0] === min && next[1] === max ? void 0 : next);
11
+ return /* @__PURE__ */ jsxs("div", {
12
+ className: "w-full",
13
+ children: [
14
+ /* @__PURE__ */ jsxs("div", {
15
+ className: "px-[7px]",
16
+ children: [/* @__PURE__ */ jsx("div", {
17
+ className: "flex h-12 items-end gap-0.5",
18
+ children: histogram.map((count, index) => /* @__PURE__ */ jsx("span", {
19
+ className: cn("flex-1 rounded-t-sm", barInRange(index, histogram.length, min, max, selected) ? "bg-primary/70" : "bg-border"),
20
+ style: { height: `${Math.max(6, count / peak * 100)}%` }
21
+ }, index))
22
+ }), /* @__PURE__ */ jsxs("div", {
23
+ className: "relative mt-1 h-4",
24
+ children: [/* @__PURE__ */ jsx("span", { className: "absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-border" }), /* @__PURE__ */ jsx("span", {
25
+ className: "absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-primary",
26
+ style: {
27
+ left: `${fraction(selected[0], min, max) * 100}%`,
28
+ right: `${100 - fraction(selected[1], min, max) * 100}%`
29
+ }
30
+ })]
31
+ })]
32
+ }),
33
+ /* @__PURE__ */ jsx("div", {
34
+ className: "relative -mt-4 h-4",
35
+ children: [0, 1].map((thumb) => /* @__PURE__ */ jsx("input", {
36
+ type: "range",
37
+ className: "pb-range",
38
+ "aria-label": `${config.label} ${thumb === 0 ? "minimum" : "maximum"}`,
39
+ min,
40
+ max,
41
+ step,
42
+ value: selected[thumb],
43
+ onChange: (event) => commit(moveThumb(selected, thumb, Number(event.target.value)))
44
+ }, thumb))
45
+ }),
46
+ /* @__PURE__ */ jsxs("div", {
47
+ className: "mt-2 flex justify-between text-xs tabular-nums text-muted-foreground",
48
+ children: [/* @__PURE__ */ jsx("span", { children: format(selected[0]) }), /* @__PURE__ */ jsx("span", { children: format(selected[1]) })]
49
+ })
50
+ ]
51
+ });
52
+ };
53
+ //#endregion
54
+ export { RangeFilter };
@@ -0,0 +1,11 @@
1
+ //#region src/filter-panel/range-math.ts
2
+ /** Which histogram bars lie inside the selected range, for tinting. */
3
+ const barInRange = (barIndex, bars, min, max, selected) => {
4
+ const barStart = min + (max - min) * barIndex / bars;
5
+ return min + (max - min) * (barIndex + 1) / bars > selected[0] && barStart < selected[1];
6
+ };
7
+ const fraction = (value, min, max) => max === min ? 0 : (value - min) / (max - min);
8
+ /** Keeps the two thumbs ordered, moving the one being dragged. */
9
+ const moveThumb = (range, thumb, value) => thumb === 0 ? [Math.min(value, range[1]), range[1]] : [range[0], Math.max(value, range[0])];
10
+ //#endregion
11
+ export { barInRange, fraction, moveThumb };
@@ -0,0 +1,32 @@
1
+ .pb-range {
2
+ position: absolute;
3
+ inset: 0;
4
+ width: 100%;
5
+ height: 100%;
6
+ margin: 0;
7
+ appearance: none;
8
+ background: transparent;
9
+ pointer-events: none;
10
+ }
11
+ .pb-range::-webkit-slider-thumb {
12
+ appearance: none;
13
+ pointer-events: auto;
14
+ width: 14px;
15
+ height: 14px;
16
+ border-radius: 9999px;
17
+ background: var(--background);
18
+ border: 2px solid var(--primary);
19
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
20
+ cursor: grab;
21
+ }
22
+ .pb-range::-moz-range-thumb {
23
+ pointer-events: auto;
24
+ width: 10px;
25
+ height: 10px;
26
+ border-radius: 9999px;
27
+ background: var(--background);
28
+ border: 2px solid var(--primary);
29
+ cursor: grab;
30
+ }
31
+ .pb-range::-webkit-slider-runnable-track { appearance: none; background: transparent; }
32
+ .pb-range::-moz-range-track { background: transparent; }
@@ -0,0 +1,13 @@
1
+ import { Switch } from "../primitives/switch.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/filter-panel/toggle-row.tsx
4
+ const ToggleRow = ({ label, checked, onChange }) => /* @__PURE__ */ jsxs("label", {
5
+ className: "flex cursor-pointer items-center justify-between gap-3 py-1",
6
+ children: [/* @__PURE__ */ jsx("span", { children: label }), /* @__PURE__ */ jsx(Switch, {
7
+ checked,
8
+ onChange,
9
+ label
10
+ })]
11
+ });
12
+ //#endregion
13
+ export { ToggleRow };
@@ -0,0 +1,15 @@
1
+ import { emptyFilterState } from "./filter-state.js";
2
+ import { useState } from "react";
3
+ //#region src/filter-panel/use-filter-state.ts
4
+ /** Controlled when `value` is given, otherwise keeps its own state. */
5
+ const useFilterState = (value, defaultValue, onChange) => {
6
+ const [local, setLocal] = useState(defaultValue ?? emptyFilterState);
7
+ const state = value ?? local;
8
+ const update = (next) => {
9
+ setLocal(next);
10
+ onChange?.(next);
11
+ };
12
+ return [state, update];
13
+ };
14
+ //#endregion
15
+ export { useFilterState };