@pythia-software/query-table-ui 0.1.0 → 0.2.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.
package/dist/index.js CHANGED
@@ -137,7 +137,8 @@ function formatRelative(iso) {
137
137
  }
138
138
 
139
139
  // src/DataTable.tsx
140
- import { useEffect as useEffect2, useMemo, useRef, useState as useState2 } from "react";
140
+ import { useCallback, useEffect as useEffect2, useMemo, useRef, useState as useState2 } from "react";
141
+ import { useVirtualizer } from "@tanstack/react-virtual";
141
142
  import { isSortable, readFieldValue } from "@pythia-software/query-table-core";
142
143
  import { useColumnDrag } from "@pythia-software/query-table-react";
143
144
 
@@ -292,6 +293,9 @@ var AUTOFIT_EXTRA_PX = 16;
292
293
  var MENU_WIDTH2 = 240;
293
294
  var MENU_ROW_HEIGHT = 32;
294
295
  var COPY_FEEDBACK_MS = 900;
296
+ var DEFAULT_MAX_HEIGHT = 600;
297
+ var DEFAULT_ROW_HEIGHT = 37;
298
+ var DEFAULT_OVERSCAN = 8;
295
299
  var SELECTION_COLUMN = "__qt_selection__";
296
300
  function DataTable(props) {
297
301
  const {
@@ -307,27 +311,75 @@ function DataTable(props) {
307
311
  total = null,
308
312
  loading,
309
313
  emptyMessage,
314
+ maxHeight = DEFAULT_MAX_HEIGHT,
315
+ estimateRowHeight = DEFAULT_ROW_HEIGHT,
316
+ overscan = DEFAULT_OVERSCAN,
310
317
  classNames
311
318
  } = props;
312
319
  const [menu, setMenu] = useState2(null);
313
- const [resizingField, setResizingField] = useState2(null);
314
- const [draftWidths, setDraftWidths] = useState2({});
315
320
  const [selectionColumnWidth, setSelectionColumnWidth] = useState2(DEFAULT_SELECTION_WIDTH);
316
321
  const [tableColumnOrder, setTableColumnOrder] = useState2([]);
317
322
  const [copiedCellKey, setCopiedCellKey] = useState2(null);
323
+ const [headerHeight, setHeaderHeight] = useState2(0);
324
+ const [showHorizontalCue, setShowHorizontalCue] = useState2(false);
318
325
  const tableWrapRef = useRef(null);
326
+ const tableHeadRef = useRef(null);
327
+ const tableRef = useRef(null);
328
+ const resizeGuideRef = useRef(null);
319
329
  const resizeCommitRef = useRef(null);
330
+ const resizePreviewRef = useRef(null);
331
+ const resizeFrameRef = useRef(null);
332
+ const cancelResizeRef = useRef(null);
320
333
  const resizingFieldRef = useRef(null);
321
334
  const copiedCellTimerRef = useRef(null);
322
335
  const localColumnDrag = useColumnDrag();
323
336
  const columnDrag = props.columnDrag ?? localColumnDrag;
337
+ const rowIds = useMemo(() => rows.map(rowId), [rowId, rows]);
338
+ const virtualRowKeys = useMemo(
339
+ () => rowIds.map((id, index) => id == null ? `index:${index}` : `${typeof id}:${String(id)}`),
340
+ [rowIds]
341
+ );
342
+ const getVirtualRowKey = useCallback((index) => virtualRowKeys[index] ?? `index:${index}`, [virtualRowKeys]);
343
+ const normalizedEstimate = Number.isFinite(estimateRowHeight) ? Math.max(1, estimateRowHeight) : DEFAULT_ROW_HEIGHT;
344
+ const normalizedOverscan = Number.isFinite(overscan) ? Math.max(0, Math.floor(overscan)) : DEFAULT_OVERSCAN;
345
+ const estimateVirtualRow = useCallback(() => normalizedEstimate, [normalizedEstimate]);
346
+ const initialViewportHeight = typeof maxHeight === "number" && maxHeight > 0 ? maxHeight : DEFAULT_MAX_HEIGHT;
347
+ const initialRect = useMemo(() => ({ width: 0, height: initialViewportHeight }), [initialViewportHeight]);
348
+ const rowVirtualizer = useVirtualizer({
349
+ count: rows.length,
350
+ getScrollElement: () => tableWrapRef.current,
351
+ estimateSize: estimateVirtualRow,
352
+ getItemKey: getVirtualRowKey,
353
+ overscan: normalizedOverscan,
354
+ // The header participates in normal table layout even while it is sticky.
355
+ // Offset row measurements so the virtual range matches the body's origin.
356
+ scrollMargin: headerHeight,
357
+ initialRect
358
+ });
324
359
  useEffect2(() => {
325
360
  return () => {
326
361
  if (copiedCellTimerRef.current) {
327
362
  clearTimeout(copiedCellTimerRef.current);
328
363
  copiedCellTimerRef.current = null;
329
364
  }
365
+ cancelResizeRef.current?.();
366
+ };
367
+ }, []);
368
+ useEffect2(() => {
369
+ const header = tableHeadRef.current;
370
+ if (!header) return;
371
+ const measureHeader = () => {
372
+ const next = header.getBoundingClientRect().height;
373
+ setHeaderHeight((current) => current === next ? current : next);
330
374
  };
375
+ measureHeader();
376
+ if (typeof ResizeObserver === "undefined") {
377
+ window.addEventListener("resize", measureHeader);
378
+ return () => window.removeEventListener("resize", measureHeader);
379
+ }
380
+ const observer = new ResizeObserver(measureHeader);
381
+ observer.observe(header);
382
+ return () => observer.disconnect();
331
383
  }, []);
332
384
  function cellKey(rowIdValue, rowIndex, fieldName) {
333
385
  return rowIdValue == null ? `row:${rowIndex}:${fieldName}` : `id:${rowIdValue}:${fieldName}`;
@@ -361,7 +413,7 @@ function DataTable(props) {
361
413
  }
362
414
  showCopyFeedback(cellKey(rowIdValue, rowIndex, f.name));
363
415
  }
364
- const pageIds = rows.map(rowId).filter((id) => id != null);
416
+ const pageIds = rowIds.filter((id) => id != null);
365
417
  const headerState = selection ? selection.pageState(pageIds) : "none";
366
418
  const showSel = selection != null;
367
419
  function sortKeyFor(f) {
@@ -396,11 +448,11 @@ function DataTable(props) {
396
448
  return col?.width ?? f.select?.width;
397
449
  }
398
450
  function resolvedWidthFor(name, f) {
399
- return draftWidths[name] ?? widthFor(name, f);
451
+ return widthFor(name, f);
400
452
  }
401
453
  function resolvedColumnWidthFor(name, f) {
402
- if (name === SELECTION_COLUMN) return draftWidths[name] ?? selectionColumnWidth;
403
- return f ? resolvedWidthFor(name, f) : draftWidths[name];
454
+ if (name === SELECTION_COLUMN) return selectionColumnWidth;
455
+ return f ? resolvedWidthFor(name, f) : void 0;
404
456
  }
405
457
  function displayColumnWidthFor(name, f) {
406
458
  if (name === SELECTION_COLUMN) return resolvedColumnWidthFor(name) ?? DEFAULT_SELECTION_WIDTH;
@@ -424,16 +476,20 @@ function DataTable(props) {
424
476
  const px = clampWidth(width);
425
477
  onQueryChange((prev) => ({ ...prev, select: writeWidth(prev.select, fields, name, px) }));
426
478
  }
427
- function setDraftWidth(name, width) {
428
- setDraftWidths((prev) => prev[name] === width ? prev : { ...prev, [name]: width });
429
- }
430
- function clearDraftWidth(name) {
431
- setDraftWidths((prev) => {
432
- if (!(name in prev)) return prev;
433
- const next = { ...prev };
434
- delete next[name];
435
- return next;
436
- });
479
+ function applyResizeGuide() {
480
+ const preview = resizePreviewRef.current;
481
+ const guide = resizeGuideRef.current;
482
+ if (!preview || !guide) return;
483
+ guide.style.transform = `translateX(${preview.guideX}px)`;
484
+ }
485
+ function applyCommittedResize() {
486
+ const preview = resizePreviewRef.current;
487
+ const table = tableRef.current;
488
+ if (!preview || !table) return;
489
+ const selector = `col[data-qt-column="${cssAttributeValue(preview.name)}"]`;
490
+ const col = table.querySelector(selector);
491
+ if (col) col.style.width = `${preview.width}px`;
492
+ table.style.minWidth = `${preview.startTableWidth + preview.width - preview.previousWidth}px`;
437
493
  }
438
494
  function startResize(name, startWidth, e) {
439
495
  e.preventDefault();
@@ -442,30 +498,70 @@ function DataTable(props) {
442
498
  const startW = startWidth ?? e.currentTarget.parentElement?.getBoundingClientRect().width ?? DEFAULT_WIDTH;
443
499
  const previousCursor = document.body.style.cursor;
444
500
  const previousUserSelect = document.body.style.userSelect;
501
+ const initialWidth = clampColumnWidth(name, startW);
502
+ const wrap = tableWrapRef.current;
503
+ const wrapRect = wrap?.getBoundingClientRect();
504
+ const header = e.currentTarget.parentElement;
445
505
  resizeCommitRef.current = null;
506
+ resizePreviewRef.current = {
507
+ name,
508
+ width: initialWidth,
509
+ previousWidth: displayColumnWidthFor(name, fieldByName.get(name)),
510
+ startTableWidth: tableWidth,
511
+ guideX: startX - (wrapRect?.left ?? 0) + (wrap?.scrollLeft ?? 0)
512
+ };
446
513
  resizingFieldRef.current = name;
447
- setResizingField(name);
448
- setDraftWidth(name, clampColumnWidth(name, startW));
514
+ tableWrapRef.current?.classList.add("qt-table-wrap--resizing");
515
+ header?.classList.add("qt-th--resizing");
516
+ if (header) header.draggable = false;
517
+ if (resizeGuideRef.current) {
518
+ resizeGuideRef.current.style.display = "block";
519
+ resizeGuideRef.current.style.top = `${wrap?.scrollTop ?? 0}px`;
520
+ resizeGuideRef.current.style.height = `${wrap?.clientHeight ?? 0}px`;
521
+ }
522
+ applyResizeGuide();
449
523
  document.body.style.cursor = "col-resize";
450
524
  document.body.style.userSelect = "none";
451
- const finish = () => {
525
+ const cleanup = (commitWidth) => {
452
526
  window.removeEventListener("pointermove", onMove);
453
527
  window.removeEventListener("pointerup", finish);
454
528
  window.removeEventListener("pointercancel", finish);
529
+ if (resizeFrameRef.current != null) {
530
+ cancelAnimationFrame(resizeFrameRef.current);
531
+ resizeFrameRef.current = null;
532
+ applyResizeGuide();
533
+ }
455
534
  document.body.style.cursor = previousCursor;
456
535
  document.body.style.userSelect = previousUserSelect;
457
- const commit = resizeCommitRef.current;
536
+ tableWrapRef.current?.classList.remove("qt-table-wrap--resizing");
537
+ tableRef.current?.querySelector(`th[data-qt-field="${cssAttributeValue(name)}"]`)?.classList.remove("qt-th--resizing");
538
+ if (header) header.draggable = true;
539
+ const commit = commitWidth ? resizeCommitRef.current : null;
540
+ if (commit) applyCommittedResize();
541
+ if (resizeGuideRef.current) resizeGuideRef.current.style.display = "none";
458
542
  resizeCommitRef.current = null;
543
+ resizePreviewRef.current = null;
459
544
  resizingFieldRef.current = null;
460
- setResizingField(null);
461
- clearDraftWidth(name);
545
+ cancelResizeRef.current = null;
462
546
  if (commit) commitColumnWidth(commit.name, commit.width);
463
547
  };
548
+ const finish = () => cleanup(true);
464
549
  const onMove = (ev) => {
465
550
  const next = clampColumnWidth(name, startW + (ev.clientX - startX));
466
551
  resizeCommitRef.current = { name, width: next };
467
- setDraftWidth(name, next);
552
+ const preview = resizePreviewRef.current;
553
+ if (preview) {
554
+ preview.width = next;
555
+ preview.guideX = startX + (next - startW) - (wrapRect?.left ?? 0) + (wrap?.scrollLeft ?? 0);
556
+ }
557
+ if (resizeFrameRef.current == null) {
558
+ resizeFrameRef.current = requestAnimationFrame(() => {
559
+ resizeFrameRef.current = null;
560
+ applyResizeGuide();
561
+ });
562
+ }
468
563
  };
564
+ cancelResizeRef.current = () => cleanup(false);
469
565
  window.addEventListener("pointermove", onMove);
470
566
  window.addEventListener("pointerup", finish);
471
567
  window.addEventListener("pointercancel", finish);
@@ -573,6 +669,22 @@ function DataTable(props) {
573
669
  const f = fieldByName.get(name);
574
670
  return sum + displayColumnWidthFor(name, f);
575
671
  }, trailing ? 40 : 0);
672
+ useEffect2(() => {
673
+ const wrap = tableWrapRef.current;
674
+ if (!wrap) return;
675
+ const updateCue = () => {
676
+ const hasMoreToRight = wrap.scrollWidth - wrap.clientWidth - wrap.scrollLeft > 2;
677
+ setShowHorizontalCue(hasMoreToRight);
678
+ };
679
+ updateCue();
680
+ window.addEventListener("resize", updateCue);
681
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateCue);
682
+ observer?.observe(wrap);
683
+ return () => {
684
+ window.removeEventListener("resize", updateCue);
685
+ observer?.disconnect();
686
+ };
687
+ }, [renderedColumnNames, tableWidth]);
576
688
  const start = query.offset;
577
689
  const end = total != null ? Math.min(start + query.limit, total) : start + rows.length;
578
690
  const canPrev = start > 0;
@@ -580,7 +692,16 @@ function DataTable(props) {
580
692
  const prevPage = () => onQueryChange((prev) => ({ ...prev, offset: Math.max(0, prev.offset - prev.limit) }));
581
693
  const nextPage = () => onQueryChange((prev) => ({ ...prev, offset: prev.offset + prev.limit }));
582
694
  const summaryText = total == null || total === 0 ? `${rows.length} rows` : `${start + 1}\u2013${end} of ${total}`;
695
+ const virtualRows = rowVirtualizer.getVirtualItems();
696
+ const firstVirtualRow = virtualRows[0];
697
+ const lastVirtualRow = virtualRows[virtualRows.length - 1];
698
+ const paddingTop = firstVirtualRow ? Math.max(0, firstVirtualRow.start - headerHeight) : 0;
699
+ const paddingBottom = lastVirtualRow ? Math.max(0, rowVirtualizer.getTotalSize() - (lastVirtualRow.end - headerHeight)) : 0;
583
700
  return /* @__PURE__ */ jsxs2(Fragment2, { children: [
701
+ showHorizontalCue ? /* @__PURE__ */ jsxs2("div", { className: "qt-table-scroll-cue", "aria-hidden": "true", children: [
702
+ "Swipe to see more ",
703
+ /* @__PURE__ */ jsx2("span", { children: "\u2192" })
704
+ ] }) : null,
584
705
  /* @__PURE__ */ jsxs2(
585
706
  "div",
586
707
  {
@@ -588,24 +709,40 @@ function DataTable(props) {
588
709
  className: cx2(
589
710
  "qt-table-wrap",
590
711
  loading && "qt-table-wrap--loading",
591
- !!resizingField && "qt-table-wrap--resizing",
592
712
  classNames?.wrap
593
713
  ),
594
714
  onWheel: onTableWheel,
715
+ style: { maxHeight, overflowY: "auto" },
716
+ onScroll: () => {
717
+ const wrap = tableWrapRef.current;
718
+ if (!wrap) return;
719
+ setShowHorizontalCue(wrap.scrollWidth - wrap.clientWidth - wrap.scrollLeft > 2);
720
+ },
721
+ role: "region",
722
+ "aria-label": "Scrollable data table",
723
+ tabIndex: 0,
595
724
  children: [
596
725
  loading && /* @__PURE__ */ jsx2("div", { className: cx2("qt-loading-bar", classNames?.loadingBar), role: "progressbar", "aria-label": "loading" }),
597
- /* @__PURE__ */ jsxs2("table", { className: cx2("qt-table", classNames?.table), style: { minWidth: tableWidth }, children: [
726
+ /* @__PURE__ */ jsx2(
727
+ "div",
728
+ {
729
+ ref: resizeGuideRef,
730
+ className: cx2("qt-resize-guide", classNames?.resizeGuide),
731
+ "aria-hidden": "true"
732
+ }
733
+ ),
734
+ /* @__PURE__ */ jsxs2("table", { ref: tableRef, className: cx2("qt-table", classNames?.table), style: { minWidth: tableWidth }, children: [
598
735
  /* @__PURE__ */ jsxs2("colgroup", { children: [
599
736
  renderedColumnNames.map((name) => {
600
737
  const f = fieldByName.get(name);
601
738
  const w = displayColumnWidthFor(name, f);
602
- if (name === SELECTION_COLUMN) return /* @__PURE__ */ jsx2("col", { style: { width: w } }, name);
739
+ if (name === SELECTION_COLUMN) return /* @__PURE__ */ jsx2("col", { "data-qt-column": name, style: { width: w } }, name);
603
740
  if (!f) return null;
604
- return /* @__PURE__ */ jsx2("col", { style: { width: w } }, f.name);
741
+ return /* @__PURE__ */ jsx2("col", { "data-qt-column": f.name, style: { width: w } }, f.name);
605
742
  }),
606
743
  trailing && /* @__PURE__ */ jsx2("col", { style: { width: 40 } })
607
744
  ] }),
608
- /* @__PURE__ */ jsx2("thead", { className: classNames?.thead, children: /* @__PURE__ */ jsxs2("tr", { className: classNames?.headerRow, children: [
745
+ /* @__PURE__ */ jsx2("thead", { ref: tableHeadRef, className: classNames?.thead, children: /* @__PURE__ */ jsxs2("tr", { className: classNames?.headerRow, children: [
609
746
  renderedColumnNames.map((name) => {
610
747
  if (name === SELECTION_COLUMN) {
611
748
  return /* @__PURE__ */ jsxs2(
@@ -616,11 +753,10 @@ function DataTable(props) {
616
753
  "qt-th",
617
754
  "qt-checkbox-cell",
618
755
  dragSource === name && "qt-th--dragging",
619
- resizingField === name && "qt-th--resizing",
620
756
  classNames?.th,
621
757
  classNames?.checkboxCell
622
758
  ),
623
- draggable: resizingField == null,
759
+ draggable: true,
624
760
  onDragStart: (e) => {
625
761
  if (resizingFieldRef.current) {
626
762
  e.preventDefault();
@@ -683,11 +819,10 @@ function DataTable(props) {
683
819
  "qt-th",
684
820
  sortable && "qt-th--sortable",
685
821
  dragSource === f.name && "qt-th--dragging",
686
- resizingField === f.name && "qt-th--resizing",
687
822
  classNames?.th
688
823
  ),
689
824
  style: f.select?.align ? { textAlign: f.select.align } : void 0,
690
- draggable: resizingField == null,
825
+ draggable: true,
691
826
  onDragStart: (e) => {
692
827
  if (resizingFieldRef.current) {
693
828
  e.preventDefault();
@@ -746,12 +881,17 @@ function DataTable(props) {
746
881
  ] }) }),
747
882
  /* @__PURE__ */ jsxs2("tbody", { className: classNames?.tbody, children: [
748
883
  rows.length === 0 && /* @__PURE__ */ jsx2("tr", { children: /* @__PURE__ */ jsx2("td", { colSpan: totalCols, className: cx2("qt-cell", "qt-empty", classNames?.empty), children: emptyMessage ?? "No rows." }) }),
749
- rows.map((row, i) => {
750
- const id = rowId(row);
884
+ paddingTop > 0 && /* @__PURE__ */ jsx2("tr", { className: "qt-virtual-spacer", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("td", { colSpan: totalCols, style: { height: paddingTop, padding: 0, border: 0 } }) }),
885
+ virtualRows.map((virtualRow) => {
886
+ const i = virtualRow.index;
887
+ const row = rows[i];
888
+ const id = rowIds[i] ?? null;
751
889
  const selected = id != null && selection ? selection.isSelected(id) : false;
752
890
  return /* @__PURE__ */ jsxs2(
753
891
  "tr",
754
892
  {
893
+ "data-index": i,
894
+ ref: rowVirtualizer.measureElement,
755
895
  className: cx2("qt-row", selected && "qt-row--selected", classNames?.row, selected && classNames?.rowSelected),
756
896
  children: [
757
897
  renderedColumnNames.map((name) => {
@@ -804,9 +944,10 @@ function DataTable(props) {
804
944
  trailing && /* @__PURE__ */ jsx2("td", { className: cx2("qt-cell", "qt-trailing-cell", classNames?.cell), children: trailing(row) })
805
945
  ]
806
946
  },
807
- id != null ? String(id) : i
947
+ virtualRow.key
808
948
  );
809
- })
949
+ }),
950
+ paddingBottom > 0 && /* @__PURE__ */ jsx2("tr", { className: "qt-virtual-spacer", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("td", { colSpan: totalCols, style: { height: paddingBottom, padding: 0, border: 0 } }) })
810
951
  ] }),
811
952
  /* @__PURE__ */ jsx2("tfoot", { children: /* @__PURE__ */ jsx2("tr", { className: cx2("qt-table-summary-row", classNames?.summaryRow), children: /* @__PURE__ */ jsx2("td", { colSpan: totalCols, className: cx2("qt-cell", "qt-table-summary-cell", classNames?.summaryCell), children: /* @__PURE__ */ jsxs2("div", { className: cx2("qt-table-summary", classNames?.summaryControls), children: [
812
953
  /* @__PURE__ */ jsx2(
@@ -979,6 +1120,7 @@ import {
979
1120
  NULLARY_OPS as NULLARY_OPS2,
980
1121
  aggOpNeedsField,
981
1122
  aggOpsForField,
1123
+ encodeQuery,
982
1124
  filterValues as filterValuesFor,
983
1125
  isGroupable,
984
1126
  isMeasurable,
@@ -1047,7 +1189,7 @@ function FieldPicker({
1047
1189
  disabled: !enabled,
1048
1190
  className: enabled ? "qt-picker-item" : "qt-picker-item qt-picker-item--disabled",
1049
1191
  title: enabled ? f.name : "not useful as a filter (\u22641 distinct value)",
1050
- onMouseDown: (e) => {
1192
+ onPointerDown: (e) => {
1051
1193
  if (!enabled) return;
1052
1194
  e.preventDefault();
1053
1195
  onPick(f);
@@ -1131,68 +1273,78 @@ function SavedQueriesModal({ saved, onClose, onLoad }) {
1131
1273
  window.addEventListener("keydown", onKey);
1132
1274
  return () => window.removeEventListener("keydown", onKey);
1133
1275
  }, [onClose]);
1134
- return /* @__PURE__ */ jsx4("div", { className: "qt-modal-backdrop", onClick: onClose, children: /* @__PURE__ */ jsxs4("div", { className: "qt-modal", onClick: (e) => e.stopPropagation(), role: "dialog", "aria-label": "Saved queries", children: [
1135
- /* @__PURE__ */ jsx4("h3", { className: "qt-modal-title", children: "Saved queries" }),
1136
- saved.loading ? /* @__PURE__ */ jsx4("p", { className: "qt-muted", children: "Loading\u2026" }) : saved.items.length === 0 ? /* @__PURE__ */ jsx4("p", { className: "qt-muted", children: "None saved yet. Save the current query from the toolbar." }) : /* @__PURE__ */ jsx4("ul", { className: "qt-saved-list", children: saved.items.map((s) => {
1137
- const filters = s.query.where.length;
1138
- const isDefault = saved.defaultId === s.id;
1139
- return /* @__PURE__ */ jsxs4("li", { className: "qt-saved-item", children: [
1140
- /* @__PURE__ */ jsxs4(
1141
- "button",
1142
- {
1143
- type: "button",
1144
- className: "qt-saved-pick",
1145
- onClick: () => {
1146
- onLoad?.(s.id);
1147
- saved.load(s.id);
1148
- onClose();
1149
- },
1150
- children: [
1151
- /* @__PURE__ */ jsx4("span", { className: "qt-saved-name", children: s.name }),
1152
- /* @__PURE__ */ jsxs4("span", { className: "qt-saved-meta qt-muted", children: [
1153
- isDefault ? "Default view \xB7 " : "",
1154
- new Date(s.savedAt).toLocaleString(),
1155
- " \xB7 ",
1156
- filters,
1157
- " filter",
1158
- filters === 1 ? "" : "s"
1159
- ] })
1160
- ]
1161
- }
1162
- ),
1163
- isDefault ? /* @__PURE__ */ jsx4(
1164
- "button",
1165
- {
1166
- type: "button",
1167
- className: "qt-btn qt-saved-default",
1168
- title: "Clear default view",
1169
- onClick: () => void saved.clearDefault(),
1170
- children: "default"
1171
- }
1172
- ) : /* @__PURE__ */ jsx4(
1173
- "button",
1174
- {
1175
- type: "button",
1176
- className: "qt-btn qt-saved-default",
1177
- title: "Use as default view",
1178
- onClick: () => void saved.setDefault(s.id),
1179
- children: "make default"
1180
- }
1181
- ),
1182
- /* @__PURE__ */ jsx4(
1183
- "button",
1184
- {
1185
- type: "button",
1186
- className: "qt-btn qt-saved-delete",
1187
- title: "Delete saved query",
1188
- onClick: () => void saved.remove(s.id),
1189
- children: "delete"
1190
- }
1191
- )
1192
- ] }, s.id);
1193
- }) }),
1194
- /* @__PURE__ */ jsx4("div", { className: "qt-modal-footer", children: /* @__PURE__ */ jsx4("button", { type: "button", className: "qt-btn", onClick: onClose, children: "close" }) })
1195
- ] }) });
1276
+ return /* @__PURE__ */ jsx4("div", { className: "qt-modal-backdrop", onClick: onClose, children: /* @__PURE__ */ jsxs4(
1277
+ "div",
1278
+ {
1279
+ className: "qt-modal",
1280
+ onClick: (e) => e.stopPropagation(),
1281
+ role: "dialog",
1282
+ "aria-modal": "true",
1283
+ "aria-label": "Saved queries",
1284
+ children: [
1285
+ /* @__PURE__ */ jsx4("h3", { className: "qt-modal-title", children: "Saved queries" }),
1286
+ saved.loading ? /* @__PURE__ */ jsx4("p", { className: "qt-muted", children: "Loading\u2026" }) : saved.items.length === 0 ? /* @__PURE__ */ jsx4("p", { className: "qt-muted", children: "None saved yet. Save the current query from the toolbar." }) : /* @__PURE__ */ jsx4("ul", { className: "qt-saved-list", children: saved.items.map((s) => {
1287
+ const filters = s.query.where.length;
1288
+ const isDefault = saved.defaultId === s.id;
1289
+ return /* @__PURE__ */ jsxs4("li", { className: "qt-saved-item", children: [
1290
+ /* @__PURE__ */ jsxs4(
1291
+ "button",
1292
+ {
1293
+ type: "button",
1294
+ className: "qt-saved-pick",
1295
+ onClick: () => {
1296
+ onLoad?.(s.id);
1297
+ saved.load(s.id);
1298
+ onClose();
1299
+ },
1300
+ children: [
1301
+ /* @__PURE__ */ jsx4("span", { className: "qt-saved-name", children: s.name }),
1302
+ /* @__PURE__ */ jsxs4("span", { className: "qt-saved-meta qt-muted", children: [
1303
+ isDefault ? "Default view \xB7 " : "",
1304
+ new Date(s.savedAt).toLocaleString(),
1305
+ " \xB7 ",
1306
+ filters,
1307
+ " filter",
1308
+ filters === 1 ? "" : "s"
1309
+ ] })
1310
+ ]
1311
+ }
1312
+ ),
1313
+ isDefault ? /* @__PURE__ */ jsx4(
1314
+ "button",
1315
+ {
1316
+ type: "button",
1317
+ className: "qt-btn qt-saved-default",
1318
+ title: "Clear default view",
1319
+ onClick: () => void saved.clearDefault(),
1320
+ children: "default"
1321
+ }
1322
+ ) : /* @__PURE__ */ jsx4(
1323
+ "button",
1324
+ {
1325
+ type: "button",
1326
+ className: "qt-btn qt-saved-default",
1327
+ title: "Use as default view",
1328
+ onClick: () => void saved.setDefault(s.id),
1329
+ children: "make default"
1330
+ }
1331
+ ),
1332
+ /* @__PURE__ */ jsx4(
1333
+ "button",
1334
+ {
1335
+ type: "button",
1336
+ className: "qt-btn qt-saved-delete",
1337
+ title: "Delete saved query",
1338
+ onClick: () => void saved.remove(s.id),
1339
+ children: "delete"
1340
+ }
1341
+ )
1342
+ ] }, s.id);
1343
+ }) }),
1344
+ /* @__PURE__ */ jsx4("div", { className: "qt-modal-footer", children: /* @__PURE__ */ jsx4("button", { type: "button", className: "qt-btn", onClick: onClose, children: "close" }) })
1345
+ ]
1346
+ }
1347
+ ) });
1196
1348
  }
1197
1349
 
1198
1350
  // src/QueryBuilder.tsx
@@ -1201,6 +1353,7 @@ var cx3 = (...parts) => parts.filter((p) => Boolean(p)).join(" ");
1201
1353
  var MAX_AUTO_REFRESH_POLLS = 1e3;
1202
1354
  var DEFAULT_AUTO_REFRESH_FREQUENCY_MS = 2e4;
1203
1355
  var DEFAULT_AUTO_REFRESH_TURN_OFF_AFTER_MS = 5 * 6e4;
1356
+ var SHARE_FEEDBACK_MS = 2e3;
1204
1357
  var AUTO_REFRESH_FREQUENCIES = [
1205
1358
  { label: "Every 5s", value: 5e3 },
1206
1359
  { label: "Every 10s", value: 1e4 },
@@ -1301,6 +1454,7 @@ function QueryBuilder({
1301
1454
  }) {
1302
1455
  const UNSAVED_QUERY_EDIT_ID = "__qt-unsaved-query__";
1303
1456
  const [showSaved, setShowSaved] = useState4(false);
1457
+ const [shareCopied, setShareCopied] = useState4(false);
1304
1458
  const [showAutoRefresh, setShowAutoRefresh] = useState4(false);
1305
1459
  const [autoRefreshFrequencyMs, setAutoRefreshFrequencyMs] = useState4(DEFAULT_AUTO_REFRESH_FREQUENCY_MS);
1306
1460
  const [autoRefreshTurnOffAfterMs, setAutoRefreshTurnOffAfterMs] = useState4(DEFAULT_AUTO_REFRESH_TURN_OFF_AFTER_MS);
@@ -1359,6 +1513,20 @@ function QueryBuilder({
1359
1513
  const autoRefreshRef = useRef2(null);
1360
1514
  const autoRefreshPopoverRef = useRef2(null);
1361
1515
  const [autoRefreshPopoverAlignRight, setAutoRefreshPopoverAlignRight] = useState4(false);
1516
+ const shareFeedbackTimerRef = useRef2(null);
1517
+ useEffect4(
1518
+ () => () => {
1519
+ if (shareFeedbackTimerRef.current) clearTimeout(shareFeedbackTimerRef.current);
1520
+ },
1521
+ []
1522
+ );
1523
+ useEffect4(() => {
1524
+ setShareCopied(false);
1525
+ if (shareFeedbackTimerRef.current) {
1526
+ clearTimeout(shareFeedbackTimerRef.current);
1527
+ shareFeedbackTimerRef.current = null;
1528
+ }
1529
+ }, [api.query]);
1362
1530
  useEffect4(() => {
1363
1531
  if (activeSavedQuery) {
1364
1532
  setLastSavedId(activeSavedQuery.id);
@@ -1523,6 +1691,22 @@ function QueryBuilder({
1523
1691
  saveFailed(error);
1524
1692
  }
1525
1693
  }
1694
+ async function shareQuery() {
1695
+ if (typeof window === "undefined") return;
1696
+ const url = new URL(window.location.href);
1697
+ url.searchParams.set("q", encodeQuery(api.query));
1698
+ try {
1699
+ await navigator.clipboard.writeText(url.toString());
1700
+ setShareCopied(true);
1701
+ if (shareFeedbackTimerRef.current) clearTimeout(shareFeedbackTimerRef.current);
1702
+ shareFeedbackTimerRef.current = setTimeout(() => {
1703
+ setShareCopied(false);
1704
+ shareFeedbackTimerRef.current = null;
1705
+ }, SHARE_FEEDBACK_MS);
1706
+ } catch {
1707
+ window.prompt("Copy this query URL", url.toString());
1708
+ }
1709
+ }
1526
1710
  return /* @__PURE__ */ jsxs5("div", { className: cx3("qt-qb", classNames?.root), children: [
1527
1711
  /* @__PURE__ */ jsxs5("div", { className: "qt-qb-bar qt-qb-state-bar qt-qb-row", children: [
1528
1712
  /* @__PURE__ */ jsxs5("span", { className: "qt-qb-state-main", children: [
@@ -1606,6 +1790,17 @@ function QueryBuilder({
1606
1790
  children: `Update Query '${lastSavedQuery?.name ?? "query"}'`
1607
1791
  }
1608
1792
  ) : null,
1793
+ /* @__PURE__ */ jsx5(
1794
+ "button",
1795
+ {
1796
+ type: "button",
1797
+ className: cx3("qt-btn", classNames?.button),
1798
+ onClick: () => void shareQuery(),
1799
+ title: "Copy a URL for this query",
1800
+ "aria-live": "polite",
1801
+ children: shareCopied ? "Copied!" : "Share"
1802
+ }
1803
+ ),
1609
1804
  /* @__PURE__ */ jsxs5("button", { type: "button", className: cx3("qt-btn", classNames?.button), onClick: () => setShowSaved(true), children: [
1610
1805
  "Saved",
1611
1806
  api.saved.items.length > 0 ? ` (${api.saved.items.length})` : ""
@@ -1764,6 +1959,41 @@ function QueryBuilder({
1764
1959
  !isCollapsed && showSaved ? /* @__PURE__ */ jsx5(SavedQueriesModal, { saved: api.saved, onClose: () => setShowSaved(false), onLoad: setLastSavedId }) : null
1765
1960
  ] });
1766
1961
  }
1962
+ function MoveButtons({
1963
+ label,
1964
+ position,
1965
+ count,
1966
+ disabled,
1967
+ onMove
1968
+ }) {
1969
+ if (count < 2) return null;
1970
+ return /* @__PURE__ */ jsxs5("span", { className: "qt-chip-move-buttons", children: [
1971
+ /* @__PURE__ */ jsx5(
1972
+ "button",
1973
+ {
1974
+ type: "button",
1975
+ className: "qt-chip-move",
1976
+ disabled: disabled || position === 0,
1977
+ onClick: () => onMove(position - 1),
1978
+ "aria-label": `Move ${label} earlier`,
1979
+ title: "Move earlier",
1980
+ children: "\u2190"
1981
+ }
1982
+ ),
1983
+ /* @__PURE__ */ jsx5(
1984
+ "button",
1985
+ {
1986
+ type: "button",
1987
+ className: "qt-chip-move",
1988
+ disabled: disabled || position === count - 1,
1989
+ onClick: () => onMove(position + 1),
1990
+ "aria-label": `Move ${label} later`,
1991
+ title: "Move later",
1992
+ children: "\u2192"
1993
+ }
1994
+ )
1995
+ ] });
1996
+ }
1767
1997
  function SelectRow({
1768
1998
  api,
1769
1999
  fields,
@@ -1781,7 +2011,7 @@ function SelectRow({
1781
2011
  const rendered = columnDrag.preview(fieldNames);
1782
2012
  return /* @__PURE__ */ jsxs5("div", { className: "qt-qb-row", children: [
1783
2013
  /* @__PURE__ */ jsx5("span", { className: "qt-qb-kw", children: "select" }),
1784
- rendered.map((name) => {
2014
+ rendered.map((name, position) => {
1785
2015
  const label = fieldLabelByName.get(name) ?? name;
1786
2016
  return /* @__PURE__ */ jsxs5(
1787
2017
  "span",
@@ -1824,6 +2054,16 @@ function SelectRow({
1824
2054
  children: [
1825
2055
  /* @__PURE__ */ jsx5("span", { "aria-hidden": true, className: "qt-chip-grip", children: "\u22EE\u22EE" }),
1826
2056
  /* @__PURE__ */ jsx5("span", { className: "qt-chip-field", children: label }),
2057
+ /* @__PURE__ */ jsx5(
2058
+ MoveButtons,
2059
+ {
2060
+ label,
2061
+ position,
2062
+ count: rendered.length,
2063
+ disabled,
2064
+ onMove: (nextPosition) => select.move(name, nextPosition)
2065
+ }
2066
+ ),
1827
2067
  /* @__PURE__ */ jsx5("button", { type: "button", className: "qt-chip-x", onClick: () => select.hide(name), disabled, children: "\u2715" })
1828
2068
  ]
1829
2069
  },
@@ -2058,6 +2298,9 @@ function OrderTermChip({
2058
2298
  disabled,
2059
2299
  updateTerm,
2060
2300
  removeTerm,
2301
+ position,
2302
+ count,
2303
+ moveTerm,
2061
2304
  classNames,
2062
2305
  label
2063
2306
  }) {
@@ -2107,6 +2350,16 @@ function OrderTermChip({
2107
2350
  ]
2108
2351
  }
2109
2352
  ),
2353
+ /* @__PURE__ */ jsx5(
2354
+ MoveButtons,
2355
+ {
2356
+ label,
2357
+ position,
2358
+ count,
2359
+ disabled,
2360
+ onMove: moveTerm
2361
+ }
2362
+ ),
2110
2363
  /* @__PURE__ */ jsx5("button", { type: "button", className: "qt-chip-x", onClick: () => removeTerm(dataIndex), disabled, children: "\u2715" })
2111
2364
  ]
2112
2365
  }
@@ -2207,6 +2460,16 @@ function OrderRow({
2207
2460
  disabled,
2208
2461
  updateTerm,
2209
2462
  removeTerm,
2463
+ position: pos,
2464
+ count: rendered.length,
2465
+ moveTerm: (toIndex) => {
2466
+ const next = [...orderBy];
2467
+ const fromIndex = next.findIndex((candidate) => candidate.field === f);
2468
+ if (fromIndex === -1) return;
2469
+ const [moved] = next.splice(fromIndex, 1);
2470
+ next.splice(toIndex, 0, moved);
2471
+ setOrderBy(next);
2472
+ },
2210
2473
  classNames,
2211
2474
  label: byName.get(f)?.label ?? f
2212
2475
  },
@@ -2237,34 +2500,38 @@ function WindowRow({
2237
2500
  api.setOffset(Math.max(0, Number(offsetDraft) || 0));
2238
2501
  }
2239
2502
  return /* @__PURE__ */ jsxs5("div", { className: "qt-qb-row qt-qb-pagination", children: [
2240
- /* @__PURE__ */ jsx5("span", { className: "qt-qb-kw", children: "limit" }),
2241
- /* @__PURE__ */ jsx5(
2242
- "input",
2243
- {
2244
- className: cx3("qt-qb-num", classNames?.input),
2245
- type: "number",
2246
- min: 1,
2247
- value: limitDraft,
2248
- disabled,
2249
- onChange: (e) => setLimitDraft(e.target.value),
2250
- onBlur: commitLimit,
2251
- onKeyDown: (e) => e.key === "Enter" && commitLimit()
2252
- }
2253
- ),
2254
- /* @__PURE__ */ jsx5("span", { className: "qt-qb-kw", children: "offset" }),
2255
- /* @__PURE__ */ jsx5(
2256
- "input",
2257
- {
2258
- className: cx3("qt-qb-num", classNames?.input),
2259
- type: "number",
2260
- min: 0,
2261
- value: offsetDraft,
2262
- disabled,
2263
- onChange: (e) => setOffsetDraft(e.target.value),
2264
- onBlur: commitOffset,
2265
- onKeyDown: (e) => e.key === "Enter" && commitOffset()
2266
- }
2267
- ),
2503
+ /* @__PURE__ */ jsxs5("label", { className: "qt-qb-window-field", children: [
2504
+ /* @__PURE__ */ jsx5("span", { className: "qt-qb-kw", children: "limit" }),
2505
+ /* @__PURE__ */ jsx5(
2506
+ "input",
2507
+ {
2508
+ className: cx3("qt-qb-num", classNames?.input),
2509
+ type: "number",
2510
+ min: 1,
2511
+ value: limitDraft,
2512
+ disabled,
2513
+ onChange: (e) => setLimitDraft(e.target.value),
2514
+ onBlur: commitLimit,
2515
+ onKeyDown: (e) => e.key === "Enter" && commitLimit()
2516
+ }
2517
+ )
2518
+ ] }),
2519
+ /* @__PURE__ */ jsxs5("label", { className: "qt-qb-window-field", children: [
2520
+ /* @__PURE__ */ jsx5("span", { className: "qt-qb-kw", children: "offset" }),
2521
+ /* @__PURE__ */ jsx5(
2522
+ "input",
2523
+ {
2524
+ className: cx3("qt-qb-num", classNames?.input),
2525
+ type: "number",
2526
+ min: 0,
2527
+ value: offsetDraft,
2528
+ disabled,
2529
+ onChange: (e) => setOffsetDraft(e.target.value),
2530
+ onBlur: commitOffset,
2531
+ onKeyDown: (e) => e.key === "Enter" && commitOffset()
2532
+ }
2533
+ )
2534
+ ] }),
2268
2535
  !hasWindowDefault && /* @__PURE__ */ jsx5(
2269
2536
  "button",
2270
2537
  {
@@ -2331,7 +2598,7 @@ function MetricsRow({
2331
2598
  return /* @__PURE__ */ jsxs5("div", { className: "qt-qb-row qt-qb-row--metrics", children: [
2332
2599
  /* @__PURE__ */ jsx5("span", { className: "qt-qb-kw", children: "metrics" }),
2333
2600
  clauses.length === 0 && /* @__PURE__ */ jsx5("span", { className: "qt-qb-hint", children: "none" }),
2334
- rendered.map((id) => {
2601
+ rendered.map((id, position) => {
2335
2602
  const clause = byId.get(id);
2336
2603
  return /* @__PURE__ */ jsx5(
2337
2604
  MetricChip,
@@ -2350,7 +2617,10 @@ function MetricsRow({
2350
2617
  onChangeField: (field) => aggregations.update(id, { field }),
2351
2618
  onAddGroup: (field) => aggregations.update(id, { groupBy: [...clause.groupBy, field] }),
2352
2619
  onRemoveGroup: (field) => aggregations.update(id, { groupBy: clause.groupBy.filter((g) => g !== field) }),
2353
- onRemove: () => aggregations.remove(id)
2620
+ onRemove: () => aggregations.remove(id),
2621
+ position,
2622
+ count: rendered.length,
2623
+ onMove: (toIndex) => aggregations.move(id, toIndex)
2354
2624
  },
2355
2625
  id
2356
2626
  );
@@ -2380,7 +2650,10 @@ function MetricChip({
2380
2650
  onChangeField,
2381
2651
  onAddGroup,
2382
2652
  onRemoveGroup,
2383
- onRemove
2653
+ onRemove,
2654
+ position,
2655
+ count,
2656
+ onMove
2384
2657
  }) {
2385
2658
  const [addingGroup, setAddingGroup] = useState4(false);
2386
2659
  const byName = useMemo3(() => new Map(groupable.map((f) => [f.name, f])), [groupable]);
@@ -2457,6 +2730,16 @@ function MetricChip({
2457
2730
  }
2458
2731
  )
2459
2732
  ] }),
2733
+ /* @__PURE__ */ jsx5(
2734
+ MoveButtons,
2735
+ {
2736
+ label: clause.label ?? `${AGG_OP_LABELS[clause.op]} metric`,
2737
+ position,
2738
+ count,
2739
+ disabled,
2740
+ onMove
2741
+ }
2742
+ ),
2460
2743
  /* @__PURE__ */ jsx5("button", { type: "button", className: "qt-chip-x", onClick: onRemove, disabled, title: "Remove metric", children: "\u2715" })
2461
2744
  ]
2462
2745
  }