@crispy-seed/data-table 0.6.0 → 0.8.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
@@ -750,21 +750,31 @@ function isFunction$1(value) {
750
750
 
751
751
  const DATA_TABLE_UNIVERSAL_CALC_OPS = [
752
752
  "count",
753
- "countFilled"
753
+ "empty",
754
+ "notEmpty"
754
755
  ];
755
756
  const DATA_TABLE_NUMERIC_CALC_OPS = [
756
757
  "sum",
757
758
  "average",
759
+ "median",
758
760
  "min",
759
- "max"
761
+ "max",
762
+ "range"
763
+ ];
764
+ /** Offered instead of `DATA_TABLE_NUMERIC_CALC_OPS` on a non-numeric column. */ const DATA_TABLE_NON_NUMERIC_CALC_OPS = [
765
+ "countUnique"
760
766
  ];
761
767
  const DATA_TABLE_CALC_LABEL = {
762
768
  count: "Count",
763
- countFilled: "Count filled",
769
+ empty: "Empty",
770
+ notEmpty: "Not empty",
764
771
  sum: "Sum",
765
772
  average: "Average",
773
+ median: "Median",
766
774
  min: "Min",
767
- max: "Max"
775
+ max: "Max",
776
+ range: "Range",
777
+ countUnique: "Count unique"
768
778
  };
769
779
  /** A value counts as empty when it is null/undefined, an empty string, or an empty
770
780
  * array -- crisp's own `isEmptyValue` (columncalc.tsx), reused verbatim. */ function isEmptyValueForCalc(value) {
@@ -773,29 +783,50 @@ const DATA_TABLE_CALC_LABEL = {
773
783
  if (Array.isArray(value)) return value.length === 0;
774
784
  return false;
775
785
  }
786
+ /** The middle value of a SORTED, non-empty numeric array -- the mean of the two
787
+ * middle entries on an even-length array, same convention as every stats library. */ function median(sortedNumbers) {
788
+ const mid = Math.floor(sortedNumbers.length / 2);
789
+ return sortedNumbers.length % 2 === 0 ? (sortedNumbers[mid - 1] + sortedNumbers[mid]) / 2 : sortedNumbers[mid];
790
+ }
776
791
  /**
777
792
  * Pure so the arithmetic is independently (and mutation-) testable without rendering a
778
- * table at all. `sum`/`average`/`min`/`max` skip empty values and non-numeric ones
779
- * (coerced via `Number`, dropping `NaN`); they return `null` -- render nothing, not a
780
- * misleading 0 -- when there is nothing numeric left to reduce over.
793
+ * table at all. The numeric ops (`sum`/`average`/`median`/`min`/`max`/`range`) skip
794
+ * empty values and non-numeric ones (coerced via `Number`, dropping `NaN`); they
795
+ * return `null` -- render nothing, not a misleading 0 -- when there is nothing
796
+ * numeric left to reduce over. `countUnique` counts distinct NON-EMPTY values
797
+ * (`JSON.stringify`-normalized, so two arrays/objects with the same shape count
798
+ * once) -- empties are already their own op (`empty`), so folding them into "one
799
+ * more unique value" here would double-count the same information two different
800
+ * ways in the same menu.
781
801
  */ function computeColumnCalc(values, op) {
782
802
  switch(op){
783
803
  case "count":
784
804
  return values.length;
785
- case "countFilled":
805
+ case "empty":
806
+ return values.filter(isEmptyValueForCalc).length;
807
+ case "notEmpty":
786
808
  return values.filter((value)=>!isEmptyValueForCalc(value)).length;
809
+ case "countUnique":
810
+ {
811
+ const seen = new Set(values.filter((value)=>!isEmptyValueForCalc(value)).map((value)=>JSON.stringify(value)));
812
+ return seen.size;
813
+ }
787
814
  }
788
- const numbers = values.filter((value)=>!isEmptyValueForCalc(value)).map((value)=>typeof value === "number" ? value : Number(value)).filter((value)=>Number.isFinite(value));
815
+ const numbers = values.filter((value)=>!isEmptyValueForCalc(value)).map((value)=>typeof value === "number" ? value : Number(value)).filter((value)=>Number.isFinite(value)).sort((a, b)=>a - b);
789
816
  if (numbers.length === 0) return null;
790
817
  switch(op){
791
818
  case "sum":
792
819
  return numbers.reduce((total, value)=>total + value, 0);
793
820
  case "average":
794
821
  return numbers.reduce((total, value)=>total + value, 0) / numbers.length;
822
+ case "median":
823
+ return median(numbers);
795
824
  case "min":
796
- return Math.min(...numbers);
825
+ return numbers[0];
797
826
  case "max":
798
- return Math.max(...numbers);
827
+ return numbers[numbers.length - 1];
828
+ case "range":
829
+ return numbers[numbers.length - 1] - numbers[0];
799
830
  default:
800
831
  return null;
801
832
  }
@@ -1200,31 +1231,48 @@ const EMPTY_RECORD = {};
1200
1231
  ]);
1201
1232
  const endColumnDrag = useCallback(()=>setDraggingColumnKey(null), []);
1202
1233
  // -- drag-to-resize ------------------------------------------------------------------
1203
- // `resizeStartRef` holds the ONE thing that must survive across pointermove events
1204
- // without triggering a re-render on every pixel (the drag's origin) -- state
1205
- // (`resizingColumnKey`) is only for what the render actually needs: which column's
1206
- // handle should show its active/dragging look.
1234
+ // `resizeStartRef` holds everything that must survive across pointermove events
1235
+ // WITHOUT triggering a re-render on every pixel: the drag's origin, AND (as of the
1236
+ // live-review fix below) the latest computed width, so `endColumnResize` has
1237
+ // something to commit. State (`resizingColumnKey`) is only for what the render
1238
+ // actually needs: which column's handle should show its active/dragging look.
1239
+ //
1240
+ // `updateColumnResize` used to call `setColumnWidth` on every single pointermove --
1241
+ // a `setColumnWidths` state update (and therefore a full DataTable re-render) per
1242
+ // PIXEL of drag, confirmed live as the stutter a column-border drag produced. It now
1243
+ // only computes+returns the clamped width; the styled layer (`resize.tsx`) writes
1244
+ // that directly to the dragged column's own `<col>` element (`col.style.width`,
1245
+ // outside React), which is all a resize needs frame-to-frame -- table layout already
1246
+ // recalculates from a `<col>`'s own inline style with no re-render involved. React
1247
+ // state (and this hook's `onColumnWidthsChange` contract) is only touched ONCE, in
1248
+ // `endColumnResize`, committing the final width exactly as a controlled consumer
1249
+ // already expects.
1207
1250
  const resizeStartRef = useRef(null);
1208
1251
  const [resizingColumnKey, setResizingColumnKey] = useState(null);
1209
1252
  const beginColumnResize = useCallback((key, clientX, currentWidth)=>{
1210
1253
  resizeStartRef.current = {
1211
1254
  key,
1212
1255
  startX: clientX,
1213
- startWidth: currentWidth
1256
+ startWidth: currentWidth,
1257
+ currentWidth
1214
1258
  };
1215
1259
  setResizingColumnKey(key);
1216
1260
  }, []);
1217
1261
  const updateColumnResize = useCallback((clientX)=>{
1218
1262
  const start = resizeStartRef.current;
1219
- if (!start) return;
1220
- setColumnWidth(start.key, start.startWidth + (clientX - start.startX));
1221
- }, [
1222
- setColumnWidth
1223
- ]);
1263
+ if (!start) return undefined;
1264
+ const width = clampColumnWidth(start.startWidth + (clientX - start.startX));
1265
+ start.currentWidth = width;
1266
+ return width;
1267
+ }, []);
1224
1268
  const endColumnResize = useCallback(()=>{
1269
+ const start = resizeStartRef.current;
1270
+ if (start) setColumnWidth(start.key, start.currentWidth);
1225
1271
  resizeStartRef.current = null;
1226
1272
  setResizingColumnKey(null);
1227
- }, []);
1273
+ }, [
1274
+ setColumnWidth
1275
+ ]);
1228
1276
  return {
1229
1277
  columnOrder,
1230
1278
  visibleColumnOrder,
@@ -1574,11 +1622,16 @@ function useDataTableSort(props) {
1574
1622
  const [sort, setSort] = useControllableState$1({
1575
1623
  prop: sortProp,
1576
1624
  defaultProp: defaultSort,
1577
- onChange: (nextSort)=>{
1578
- // `toggleSort` never sets `sort` back to `undefined`, but the underlying state is
1579
- // typed as possibly-undefined so an uncontrolled table can start with no sort at all.
1580
- if (nextSort) onSortChange?.(nextSort);
1581
- }
1625
+ // Live-review fix: this used to only fire for a DEFINED next sort ("`toggleSort`
1626
+ // never clears it, so why would this ever see `undefined`") -- true until
1627
+ // `clearSort` (below) existed. Guarding the callback on truthiness meant a
1628
+ // consumer controlling `sort` had no way to ever learn the column sort was
1629
+ // cleared: `setSort(undefined)` would run, but the controlled VALUE never moves
1630
+ // without the consumer's own state updating from this callback, and the guard
1631
+ // swallowed exactly the call that would have told it to. Every other value this
1632
+ // hook contract carries (`onColumnWidthsChange`, `onColumnCalcOpsChange`, ...)
1633
+ // already passes its "cleared" shape through unconditionally; this now matches.
1634
+ onChange: onSortChange
1582
1635
  });
1583
1636
  const toggleSort = useCallback((key)=>{
1584
1637
  setSort((current)=>{
@@ -1594,6 +1647,12 @@ function useDataTableSort(props) {
1594
1647
  }, [
1595
1648
  setSort
1596
1649
  ]);
1650
+ /** Attio/GRC's "Remove sort" -- the header click cycle (above) only ever toggles
1651
+ * asc/desc once a column has been clicked once; this is the only way back to no
1652
+ * sort at all. Exposed for a column's own header menu (`column-menu.tsx`'s
1653
+ * `ColumnMenu`) to offer as a "Clear sort" item when that column is the sorted one. */ const clearSort = useCallback(()=>setSort(undefined), [
1654
+ setSort
1655
+ ]);
1597
1656
  const getHeaderCellProps = useCallback((columnKey, sortable)=>{
1598
1657
  const isSorted = Boolean(columnKey) && sort?.key === columnKey;
1599
1658
  const canSort = Boolean(sortable && columnKey);
@@ -1623,6 +1682,7 @@ function useDataTableSort(props) {
1623
1682
  return {
1624
1683
  sort,
1625
1684
  toggleSort,
1685
+ clearSort,
1626
1686
  getHeaderCellProps
1627
1687
  };
1628
1688
  }
@@ -4144,7 +4204,7 @@ function formatCalcValue(value) {
4144
4204
  ]);
4145
4205
  const ops = [
4146
4206
  ...DATA_TABLE_UNIVERSAL_CALC_OPS,
4147
- ...numeric ? DATA_TABLE_NUMERIC_CALC_OPS : []
4207
+ ...numeric ? DATA_TABLE_NUMERIC_CALC_OPS : DATA_TABLE_NON_NUMERIC_CALC_OPS
4148
4208
  ];
4149
4209
  return /*#__PURE__*/ jsxs(MenuRoot, {
4150
4210
  children: [
@@ -4210,8 +4270,32 @@ function formatCalcValue(value) {
4210
4270
  * once captured, this SAME element keeps receiving `pointermove`/`pointerup` even
4211
4271
  * after the cursor leaves it, so the drag math can live entirely in this one
4212
4272
  * handler set with no effect/cleanup to get wrong.
4273
+ *
4274
+ * Live-review fix (stutter): `updateColumnResize` used to also commit the new width
4275
+ * to React state on every `pointermove` -- a full DataTable re-render per pixel of
4276
+ * drag, confirmed as the stutter. During the drag this component now writes the
4277
+ * computed width straight to the dragged column's own `<col>` element (found once,
4278
+ * at pointerdown, via its `data-column-key`, per `column-layout`'s `<colgroup>`
4279
+ * comment: a `<col>`'s own inline `width` is the SINGLE place table layout reads a
4280
+ * column's width from, so writing it directly reflows the table with no React
4281
+ * render at all), coalesced to at most one write per animation frame so a fast
4282
+ * mouse doesn't queue more layout work than the screen can show. React state (and
4283
+ * a controlled consumer's `onColumnWidthsChange`) is only touched ONCE more, in
4284
+ * `endColumnResize`, which is unchanged: it commits whatever `updateColumnResize`
4285
+ * last computed.
4213
4286
  */ function ColumnResizeHandle({ columnKey, width }) {
4214
4287
  const { beginColumnResize, updateColumnResize, endColumnResize, isColumnResizing } = useDataTableContext();
4288
+ // Survive across pointermove events without a re-render: the `<col>` this drag
4289
+ // writes to (resolved once, at pointerdown) and the rAF coalescing the writes.
4290
+ const colElementRef = useRef(null);
4291
+ const pendingWidthRef = useRef(null);
4292
+ const rafIdRef = useRef(null);
4293
+ const flushPendingWidth = ()=>{
4294
+ rafIdRef.current = null;
4295
+ const col = colElementRef.current;
4296
+ const pendingWidth = pendingWidthRef.current;
4297
+ if (col && pendingWidth != null) col.style.width = `${pendingWidth}px`;
4298
+ };
4215
4299
  return /*#__PURE__*/ jsx("span", {
4216
4300
  // Mouse/pointer-drag only for now (no keyboard equivalent yet) -- `aria-hidden`
4217
4301
  // rather than `role="separator"` + `aria-label`, which would otherwise fold
@@ -4227,16 +4311,32 @@ function formatCalcValue(value) {
4227
4311
  event.stopPropagation();
4228
4312
  event.preventDefault();
4229
4313
  event.currentTarget.setPointerCapture(event.pointerId);
4230
- const startWidth = width ?? event.currentTarget.closest('[role="columnheader"]')?.getBoundingClientRect().width ?? 150;
4314
+ const headerCell = event.currentTarget.closest('[role="columnheader"]');
4315
+ const startWidth = width ?? headerCell?.getBoundingClientRect().width ?? 150;
4316
+ colElementRef.current = event.currentTarget.closest("table")?.querySelector(`col[data-column-key="${CSS.escape(columnKey)}"]`) ?? null;
4231
4317
  beginColumnResize(columnKey, event.clientX, startWidth);
4232
4318
  },
4233
4319
  onPointerMove: (event)=>{
4234
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
4235
- updateColumnResize(event.clientX);
4320
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
4321
+ const nextWidth = updateColumnResize(event.clientX);
4322
+ if (nextWidth == null) return;
4323
+ pendingWidthRef.current = nextWidth;
4324
+ if (rafIdRef.current == null) {
4325
+ rafIdRef.current = requestAnimationFrame(flushPendingWidth);
4236
4326
  }
4237
4327
  },
4238
4328
  onPointerUp: (event)=>{
4239
4329
  event.currentTarget.releasePointerCapture(event.pointerId);
4330
+ if (rafIdRef.current != null) {
4331
+ cancelAnimationFrame(rafIdRef.current);
4332
+ rafIdRef.current = null;
4333
+ }
4334
+ // Apply any not-yet-flushed width immediately so the `<col>` matches the
4335
+ // exact value `endColumnResize` is about to commit to React state -- with
4336
+ // no more pointermoves coming, nothing else would flush it.
4337
+ flushPendingWidth();
4338
+ pendingWidthRef.current = null;
4339
+ colElementRef.current = null;
4240
4340
  endColumnResize();
4241
4341
  },
4242
4342
  children: /*#__PURE__*/ jsx("span", {
@@ -4427,68 +4527,100 @@ function ColumnRenameInput({ column, initialLabel, onDone }) {
4427
4527
  * permanently, so this one carries its own marker attribute distinct from that
4428
4528
  * slot's OTHER occupants (the sort chevron, the filter trigger), which stay visible
4429
4529
  * under their own separate rules regardless of hover. */ function ColumnMenu({ column, onRename }) {
4430
- const { moveColumn, hideColumn, isColumnPinned, togglePinColumn } = useDataTableContext();
4530
+ const { moveColumn, hideColumn, isColumnPinned, togglePinColumn, sort, clearSort } = useDataTableContext();
4431
4531
  const columnLabel = typeof column.header === "string" ? column.header : column.key;
4432
4532
  const pinned = isColumnPinned(column.key);
4533
+ // Attio/GRC's "Remove sort" (live-review fix D) -- the header's own click-to-sort
4534
+ // (`SortIndicator`/`toggleSort`, above) only cycles asc/desc once a column has been
4535
+ // clicked; this is the only affordance back to no sort at all, so it only makes
4536
+ // sense to show on the column that IS currently sorted.
4537
+ const isSorted = sort?.key === column.key;
4433
4538
  const stopPropagation = (event)=>event.stopPropagation();
4434
- return /*#__PURE__*/ jsxs(MenuRoot, {
4435
- children: [
4436
- /*#__PURE__*/ jsx(DataTableHeaderCellIcon, {
4437
- asChild: true,
4438
- "data-column-menu": "",
4439
- onClick: stopPropagation,
4440
- onKeyDown: stopPropagation,
4441
- draggable: false,
4442
- children: /*#__PURE__*/ jsx(MenuTrigger, {
4443
- "aria-label": `${columnLabel} column menu`,
4444
- children: /*#__PURE__*/ jsx(IconDot3HorizontalLine_default, {
4445
- size: 16
4539
+ return(// Live-review fix (D): a `MenuItem` inside `MenuPositioner` renders through a
4540
+ // `FloatingPortal` -- a REAL React portal (`ReactDOM.createPortal`), which
4541
+ // bubbles a click/keydown up through the REACT TREE (this component, then the
4542
+ // columnheader that renders it), NOT the DOM tree the portal actually appends
4543
+ // to. Confirmed live: clicking ANY item here (not just "Clear sort" -- "Move
4544
+ // left" on a column with no sort at all reproduced it too) also fired the
4545
+ // COLUMNHEADER's own `toggleSort`, because nothing stopped that bubble before
4546
+ // it reached the header's `onClick`/`onKeyDown` (`getHeaderCellProps`). The
4547
+ // trigger's own `stopPropagation` (on `DataTableHeaderCellIcon` below) only
4548
+ // covers clicks that start on the trigger itself -- a SIBLING of
4549
+ // `MenuPositioner` in this tree, not an ancestor of it, so it never sees a
4550
+ // menu-item event at all. This wrapper is a genuine DOM node sitting between
4551
+ // BOTH of them and the header, so it catches the bubble from either one;
4552
+ // `display: contents` keeps it invisible to `DataTableHeaderCellInner`'s own
4553
+ // flex layout.
4554
+ // biome-ignore lint/a11y/noStaticElementInteractions: not a widget -- this span never responds to interaction on ITSELF, only stops propagation of events that already occurred on a real interactive descendant (the trigger button, the portaled menu items). No role fits it because it isn't a control.
4555
+ /*#__PURE__*/ jsx("span", {
4556
+ onClick: stopPropagation,
4557
+ onKeyDown: stopPropagation,
4558
+ style: {
4559
+ display: "contents"
4560
+ },
4561
+ children: /*#__PURE__*/ jsxs(MenuRoot, {
4562
+ children: [
4563
+ /*#__PURE__*/ jsx(DataTableHeaderCellIcon, {
4564
+ asChild: true,
4565
+ "data-column-menu": "",
4566
+ draggable: false,
4567
+ children: /*#__PURE__*/ jsx(MenuTrigger, {
4568
+ "aria-label": `${columnLabel} column menu`,
4569
+ children: /*#__PURE__*/ jsx(IconDot3HorizontalLine_default, {
4570
+ size: 16
4571
+ })
4446
4572
  })
4447
- })
4448
- }),
4449
- /*#__PURE__*/ jsx(MenuPositioner, {
4450
- children: /*#__PURE__*/ jsx(MenuContent, {
4451
- style: {
4452
- minWidth: 160
4453
- },
4454
- children: /*#__PURE__*/ jsxs(MenuScrollArea, {
4455
- children: [
4456
- /*#__PURE__*/ jsx(MenuItem, {
4457
- onClick: onRename,
4458
- children: /*#__PURE__*/ jsx(MenuItemLabel, {
4459
- children: "Rename"
4460
- })
4461
- }),
4462
- /*#__PURE__*/ jsx(MenuItem, {
4463
- onClick: ()=>moveColumn(column.key, "left"),
4464
- children: /*#__PURE__*/ jsx(MenuItemLabel, {
4465
- children: "Move left"
4466
- })
4467
- }),
4468
- /*#__PURE__*/ jsx(MenuItem, {
4469
- onClick: ()=>moveColumn(column.key, "right"),
4470
- children: /*#__PURE__*/ jsx(MenuItemLabel, {
4471
- children: "Move right"
4472
- })
4473
- }),
4474
- /*#__PURE__*/ jsx(MenuItem, {
4475
- onClick: ()=>togglePinColumn(column.key),
4476
- children: /*#__PURE__*/ jsx(MenuItemLabel, {
4477
- children: pinned ? "Unpin column" : "Pin column"
4478
- })
4479
- }),
4480
- /*#__PURE__*/ jsx(MenuItem, {
4481
- onClick: ()=>hideColumn(column.key),
4482
- children: /*#__PURE__*/ jsx(MenuItemLabel, {
4483
- children: "Hide column"
4573
+ }),
4574
+ /*#__PURE__*/ jsx(MenuPositioner, {
4575
+ children: /*#__PURE__*/ jsx(MenuContent, {
4576
+ style: {
4577
+ minWidth: 160
4578
+ },
4579
+ children: /*#__PURE__*/ jsxs(MenuScrollArea, {
4580
+ children: [
4581
+ /*#__PURE__*/ jsx(MenuItem, {
4582
+ onClick: onRename,
4583
+ children: /*#__PURE__*/ jsx(MenuItemLabel, {
4584
+ children: "Rename"
4585
+ })
4586
+ }),
4587
+ /*#__PURE__*/ jsx(MenuItem, {
4588
+ onClick: ()=>moveColumn(column.key, "left"),
4589
+ children: /*#__PURE__*/ jsx(MenuItemLabel, {
4590
+ children: "Move left"
4591
+ })
4592
+ }),
4593
+ /*#__PURE__*/ jsx(MenuItem, {
4594
+ onClick: ()=>moveColumn(column.key, "right"),
4595
+ children: /*#__PURE__*/ jsx(MenuItemLabel, {
4596
+ children: "Move right"
4597
+ })
4598
+ }),
4599
+ isSorted && /*#__PURE__*/ jsx(MenuItem, {
4600
+ onClick: clearSort,
4601
+ children: /*#__PURE__*/ jsx(MenuItemLabel, {
4602
+ children: "Clear sort"
4603
+ })
4604
+ }),
4605
+ /*#__PURE__*/ jsx(MenuItem, {
4606
+ onClick: ()=>togglePinColumn(column.key),
4607
+ children: /*#__PURE__*/ jsx(MenuItemLabel, {
4608
+ children: pinned ? "Unpin column" : "Pin column"
4609
+ })
4610
+ }),
4611
+ /*#__PURE__*/ jsx(MenuItem, {
4612
+ onClick: ()=>hideColumn(column.key),
4613
+ children: /*#__PURE__*/ jsx(MenuItemLabel, {
4614
+ children: "Hide column"
4615
+ })
4484
4616
  })
4485
- })
4486
- ]
4617
+ ]
4618
+ })
4487
4619
  })
4488
4620
  })
4489
- })
4490
- ]
4491
- });
4621
+ ]
4622
+ })
4623
+ }));
4492
4624
  }
4493
4625
 
4494
4626
  /**
@@ -10205,6 +10337,25 @@ function DataTableInner(props, ref) {
10205
10337
  // column/direction is active. A consumer that pre-sorts server-side can pass
10206
10338
  // a controlled `sort`; this still sorts the rows it's given, which for
10207
10339
  // already-sorted data is a no-op.
10340
+ //
10341
+ // Live-review fix (D, "Clear sort"): the JSX below passes the RAW `sort` prop
10342
+ // (not `resolvedSort`) on to `DataTablePrimitiveRoot`, plus `defaultSort` --
10343
+ // passing `resolvedSort` used to feed the headless `useDataTableSort`'s own
10344
+ // `useControllableState` a value that starts `undefined` (uncontrolled) and
10345
+ // BECOMES defined the instant a header is first clicked (once `internalSort`
10346
+ // picks it up), flipping that inner hook from uncontrolled to controlled
10347
+ // mid-lifecycle -- `@radix-ui/react-use-controllable-state` warns about
10348
+ // exactly this and, worse, silently misbehaves on it: its own internal
10349
+ // uncontrolled state stops updating the moment it's controlled, so when
10350
+ // `clearSort` later drove `resolvedSort` back to `undefined` (flipping BACK
10351
+ // to uncontrolled), the hook fell through to that stale, never-updated
10352
+ // internal state instead -- still the pre-clear value, confirmed live
10353
+ // (`aria-sort` stayed "ascending" after "Clear sort"). The RAW `sort` prop
10354
+ // never has this problem: for the common uncontrolled case it's simply
10355
+ // always `undefined`, so the headless hook is uncontrolled for its entire
10356
+ // lifetime and owns its state correctly; a consumer that genuinely controls
10357
+ // `sort` behaves exactly as before (`resolvedSort === sort` whenever `sort`
10358
+ // is defined, so nothing changes for that path).
10208
10359
  const [internalSort, setInternalSort] = React.useState(defaultSort);
10209
10360
  const resolvedSort = sort !== undefined ? sort : internalSort;
10210
10361
  const handleSortChange = React.useCallback((next)=>{
@@ -10310,7 +10461,8 @@ function DataTableInner(props, ref) {
10310
10461
  selectedIds: selectedIds,
10311
10462
  defaultSelectedIds: defaultSelectedIds,
10312
10463
  onSelectionChange: onSelectionChange,
10313
- sort: resolvedSort,
10464
+ sort: sort,
10465
+ defaultSort: defaultSort,
10314
10466
  onSortChange: handleSortChange,
10315
10467
  query: resolvedQuery,
10316
10468
  onQueryChange: handleQueryChange,
@@ -10573,6 +10725,10 @@ const DataTableTable = withContext$1(DataTableTable$1, "table");
10573
10725
  }
10574
10726
  }),
10575
10727
  effective.map(({ column, width })=>/*#__PURE__*/ jsx("col", {
10728
+ // Read by `resize.tsx`'s own pointermove handler (`data-column-key`,
10729
+ // not a ref) to write the dragged width straight to this element
10730
+ // during a drag -- see that file's own comment for why.
10731
+ "data-column-key": column.key,
10576
10732
  style: width === undefined ? undefined : {
10577
10733
  width
10578
10734
  }
@@ -11001,7 +11157,7 @@ DataTableViewsControl.displayName = "DataTableViewsControl";
11001
11157
  });
11002
11158
  }
11003
11159
 
11004
- __insertCSS(".seed-resizable-panel__root{position:relative;display:flex;height:100%}.seed-resizable-panel__spacer{flex:0 0 auto;height:100%;transition:width .14s}.seed-resizable-panel__panel{position:absolute;top:0;bottom:0;box-sizing:border-box;overflow:hidden;background:var(--seed-color-bg-layer-default)}.seed-resizable-panel__panel[data-side=left]{left:0}.seed-resizable-panel__panel[data-side=right]{right:0}.seed-resizable-panel__panel[data-floating=false]{border-radius:0;box-shadow:none}.seed-resizable-panel__panel[data-floating=false][data-side=left]{border-inline-end:1px solid var(--seed-color-stroke-neutral-muted)}.seed-resizable-panel__panel[data-floating=false][data-side=right]{border-inline-start:1px solid var(--seed-color-stroke-neutral-muted)}.seed-resizable-panel__panel[data-floating=true]{position:fixed;top:8px;bottom:8px;border-inline-end:none;border-inline-start:none;border-radius:10px;box-shadow:inset 0 0 0 1px transparent,0 0 0 1px rgba(28,40,64,.04),0 4px 8px -4px rgba(28,40,64,.12),0 4px 12px -2px rgba(24,41,75,.16);transform:none;transition:transform .14s,padding .14s}.seed-resizable-panel__panel[data-floating=true][data-side=left]{padding:8px 0 8px 8px}[data-floating=true]>.seed-resizable-panel__inner{transition:opacity .14s}.seed-resizable-panel__handle[data-side=left]{right:0}.seed-resizable-panel__handle[data-side=right]{left:0}.seed-resizable-panel__handle::before{content:\"\";position:absolute;top:0;width:1px;height:100%;background:var(--seed-color-stroke-neutral-muted);transition:background-color .16s,height .16s}.seed-resizable-panel__handle[data-side=left]::before{right:0}.seed-resizable-panel__handle[data-side=right]::before{left:0}.seed-resizable-panel__handle:hover::before,.seed-resizable-panel__handle[data-dragging]::before{width:2px;background:#0298d9}.seed-resizable-panel__handle.crisp-seed-resizable-panel-handle--column{display:flex;align-items:center;justify-content:center;width:7px;left:-3px;right:-3px;z-index:1;cursor:col-resize}.seed-resizable-panel__handle.crisp-seed-resizable-panel-handle--column::before{content:none}.seed-resizable-panel__handleInner{width:3px;height:100%;border-radius:3px;background:#0298d9;opacity:0;transition:opacity .1s .1s}.seed-resizable-panel__handle:hover .seed-resizable-panel__handleInner,.seed-resizable-panel__handle[data-dragging] .seed-resizable-panel__handleInner{opacity:1}");
11160
+ __insertCSS(".seed-resizable-panel__root{position:relative;display:flex;height:100%}.seed-resizable-panel__spacer{flex:0 0 auto;height:100%;transition:width .14s}.seed-resizable-panel__panel{position:absolute;top:0;bottom:0;box-sizing:border-box;overflow:hidden;background:var(--seed-color-bg-layer-default)}.seed-resizable-panel__panel[data-side=left]{left:0}.seed-resizable-panel__panel[data-side=right]{right:0}.seed-resizable-panel__panel[data-floating=false]{border-radius:0;box-shadow:none}.seed-resizable-panel__panel[data-floating=false][data-side=left]{border-inline-end:1px solid var(--seed-color-stroke-neutral-muted)}.seed-resizable-panel__panel[data-floating=false][data-side=right]{border-inline-start:1px solid var(--seed-color-stroke-neutral-muted)}.seed-resizable-panel__panel[data-floating=true]{position:absolute;top:8px;bottom:8px;z-index:20;border-inline-end:none;border-inline-start:none;border-radius:12px;box-shadow:0 0 0 1px rgba(28,40,64,.04),0 4px 8px -4px rgba(28,40,64,.12),0 4px 12px -2px rgba(24,41,75,.16);transform:none;opacity:1}.seed-resizable-panel__panel[data-floating=true][data-side=left]{left:8px}.seed-resizable-panel__panel[data-floating=true][data-side=right]{right:8px}.seed-resizable-panel__panel[data-floating=true][data-side=left][data-state=open]{animation:seed-enter;animation-timing-function:cubic-bezier(0.16,1,0.3,1);animation-duration:.18s;--seed-enter-translate-x:calc(-100% - 8px);--seed-enter-translate-y:0;--seed-enter-opacity:0;--seed-enter-scale:1}@media (prefers-reduced-motion:reduce){.seed-resizable-panel__panel[data-floating=true][data-side=left][data-state=open]{--seed-enter-translate-x:0}}.seed-resizable-panel__panel[data-floating=true][data-side=left][data-state=closed]{animation:seed-exit;animation-timing-function:cubic-bezier(0.4,0,1,1);animation-duration:.14s;animation-fill-mode:forwards;--seed-exit-translate-x:calc(-100% - 8px);--seed-exit-translate-y:0;--seed-exit-opacity:0;--seed-exit-scale:1}@media (prefers-reduced-motion:reduce){.seed-resizable-panel__panel[data-floating=true][data-side=left][data-state=closed]{--seed-exit-translate-x:0}}.seed-resizable-panel__panel[data-floating=true][data-side=right][data-state=open]{animation:seed-enter;animation-timing-function:cubic-bezier(0.16,1,0.3,1);animation-duration:.18s;--seed-enter-translate-x:calc(100% + 8px);--seed-enter-translate-y:0;--seed-enter-opacity:0;--seed-enter-scale:1}@media (prefers-reduced-motion:reduce){.seed-resizable-panel__panel[data-floating=true][data-side=right][data-state=open]{--seed-enter-translate-x:0}}.seed-resizable-panel__panel[data-floating=true][data-side=right][data-state=closed]{animation:seed-exit;animation-timing-function:cubic-bezier(0.4,0,1,1);animation-duration:.14s;animation-fill-mode:forwards;--seed-exit-translate-x:calc(100% + 8px);--seed-exit-translate-y:0;--seed-exit-opacity:0;--seed-exit-scale:1}@media (prefers-reduced-motion:reduce){.seed-resizable-panel__panel[data-floating=true][data-side=right][data-state=closed]{--seed-exit-translate-x:0}}.seed-resizable-panel__inner{height:100%}.seed-resizable-panel__handle{position:absolute;top:0;width:10px;height:100%;z-index:2;cursor:col-resize;background:0 0;touch-action:none}.seed-resizable-panel__handle[data-side=left]{right:0}.seed-resizable-panel__handle[data-side=right]{left:0}.seed-resizable-panel__handle::before{content:\"\";position:absolute;top:0;width:1px;height:100%;background:var(--seed-color-stroke-neutral-muted);transition:background-color .16s,height .16s}.seed-resizable-panel__handle[data-side=left]::before{right:0}.seed-resizable-panel__handle[data-side=right]::before{left:0}.seed-resizable-panel__handle:hover::before,.seed-resizable-panel__handle[data-dragging]::before{width:2px;background:#0298d9}.seed-resizable-panel__handle.crisp-seed-resizable-panel-handle--column{display:flex;align-items:center;justify-content:center;width:7px;left:-3px;right:-3px;z-index:1;cursor:col-resize}.seed-resizable-panel__handle.crisp-seed-resizable-panel-handle--column::before{content:none}.seed-resizable-panel__handleInner{width:3px;height:100%;border-radius:3px;background:#0298d9;opacity:0;transition:opacity .1s .1s}.seed-resizable-panel__handle:hover .seed-resizable-panel__handleInner,.seed-resizable-panel__handle[data-dragging] .seed-resizable-panel__handleInner{opacity:1}");
11005
11161
 
11006
11162
  const resizablePanelSlotNames = [
11007
11163
  [
@@ -11205,16 +11361,26 @@ Object.assign(resizablePanel, {
11205
11361
  // selector), so this component and that recipe stay in lockstep without a
11206
11362
  // second source of truth for the string.
11207
11363
  const COLUMN_HANDLE_CLASS = "crisp-seed-resizable-panel-handle--column";
11364
+ // Kept in lock-step with `resizable-panel.ts`'s own `FLOATING_GUTTER` (a
11365
+ // literal there too, for the same reason) -- the floating/inset-card variant
11366
+ // needs an outward-facing gutter that DOCKED never reserves. Read on at
11367
+ // `spacer`'s own render below for why this can't just be one more CSS rule.
11368
+ const FLOATING_GUTTER_PX = 8;
11208
11369
  /**
11209
11370
  * An adjustable-panel primitive: a spacer that reserves layout width plus a
11210
- * panel that overlays it, so the panel can move between docked (in-flow) and
11211
- * floating (`position: fixed`, an overlay) without a reflow glitch. Drag the
11212
- * edge handle to resize; constraints are CSS `min-width`/`max-width`, never
11213
- * re-clamped in JS. Ported from your-moon/crisp's `ResizablePanel`
11214
- * (`components/resizablepanel/`); state/drag behavior lives in
11215
- * `useResizablePanel` (`@seed-design/react-resizable-panel`), this component
11216
- * owns the DOM + recipe classes on top of it.
11217
- */ const ResizablePanel = /*#__PURE__*/ forwardRef(({ side = "left", width: sizeProp, defaultSize = 280, onWidthChange, min = 200, max, floating = false, handleVariant = "panel", resizable = true, storageKey, className, children, ...rest }, forwardedRef)=>{
11371
+ * panel that overlays it, so it can resize (or animate open/closed) without
11372
+ * a reflow glitch on the rest of the layout -- the spacer always reserves
11373
+ * the panel's real width, in BOTH the docked and floating visual variants
11374
+ * (floating changes only the panel's own look -- gutter, radius, shadow --
11375
+ * never its `position`, so a consumer laying this out as a row-flex sibling
11376
+ * of e.g. a table sees that table genuinely shrink to make room, matching
11377
+ * Attio's own measured peek). Drag the edge handle to resize; constraints
11378
+ * are CSS `min-width`/`max-width`, never re-clamped in JS. Ported from
11379
+ * your-moon/crisp's `ResizablePanel` (`components/resizablepanel/`);
11380
+ * state/drag behavior lives in `useResizablePanel`
11381
+ * (`@seed-design/react-resizable-panel`), this component owns the DOM +
11382
+ * recipe classes on top of it.
11383
+ */ const ResizablePanel = /*#__PURE__*/ forwardRef(({ side = "left", width: sizeProp, defaultSize = 280, onWidthChange, min = 200, max, floating = false, handleVariant = "panel", resizable = true, storageKey, animationState, panelElementRef, className, children, ...rest }, forwardedRef)=>{
11218
11384
  const { width, dragging, containerRef, panelRef, handleHandlers } = useResizablePanel({
11219
11385
  side,
11220
11386
  size: sizeProp,
@@ -11222,9 +11388,29 @@ const COLUMN_HANDLE_CLASS = "crisp-seed-resizable-panel-handle--column";
11222
11388
  onSizeChange: onWidthChange,
11223
11389
  storageKey
11224
11390
  });
11391
+ const composedPanelRef = useComposedRefs(panelRef, panelElementRef);
11225
11392
  const classNames = resizablePanel();
11393
+ // The floating/inset-card variant sits INSET from `root`'s own outward
11394
+ // edge by `FLOATING_GUTTER_PX`, flush against the inward edge (Attio's
11395
+ // own peek: no gap on the side facing the table it shrinks). `width`
11396
+ // (the prop a consumer sets, e.g. `RecordPreview`'s own 497px default)
11397
+ // is the RESERVED FOOTPRINT -- what the table beside it actually gives
11398
+ // up -- so the panel's own rendered card shrinks by the gutter INSIDE
11399
+ // that footprint, rather than the footprint growing past it: reserving
11400
+ // the panel's full width AND adding an outward gutter on top of it
11401
+ // over-claims space the surrounding layout was never asked for --
11402
+ // confirmed live, it pushed the whole page 8px into horizontal
11403
+ // overflow. Docked (no gutter) keeps `width` as both the reservation
11404
+ // and the render, as before.
11405
+ const panelGutter = floating ? FLOATING_GUTTER_PX : 0;
11226
11406
  const maxStyle = max == null ? undefined : typeof max === "number" ? `${max}px` : max;
11407
+ const panelMaxStyle = panelGutter === 0 || maxStyle == null ? maxStyle : `calc(${maxStyle} - ${panelGutter}px)`;
11227
11408
  const panelSizeStyle = {
11409
+ width: width - panelGutter,
11410
+ minWidth: min - panelGutter,
11411
+ maxWidth: panelMaxStyle
11412
+ };
11413
+ const spacerSizeStyle = {
11228
11414
  width,
11229
11415
  minWidth: min,
11230
11416
  maxWidth: maxStyle
@@ -11240,19 +11426,14 @@ const COLUMN_HANDLE_CLASS = "crisp-seed-resizable-panel-handle--column";
11240
11426
  children: [
11241
11427
  /*#__PURE__*/ jsx(Primitive.div, {
11242
11428
  className: classNames.spacer,
11243
- style: floating ? {
11244
- width: 0
11245
- } : {
11246
- width,
11247
- minWidth: min,
11248
- maxWidth: maxStyle
11249
- }
11429
+ style: spacerSizeStyle
11250
11430
  }),
11251
11431
  /*#__PURE__*/ jsxs(Primitive.div, {
11252
- ref: panelRef,
11432
+ ref: composedPanelRef,
11253
11433
  className: classNames.panel,
11254
11434
  "data-side": side,
11255
11435
  "data-floating": floating ? "true" : "false",
11436
+ "data-state": animationState,
11256
11437
  style: panelSizeStyle,
11257
11438
  children: [
11258
11439
  /*#__PURE__*/ jsx(Primitive.div, {
@@ -11277,132 +11458,276 @@ const COLUMN_HANDLE_CLASS = "crisp-seed-resizable-panel-handle--column";
11277
11458
  });
11278
11459
  ResizablePanel.displayName = "ResizablePanel";
11279
11460
 
11280
- __insertCSS(".seed-record-panel__root{display:flex;flex-direction:column;height:100%;overflow:hidden;background:var(--seed-color-bg-layer-default);font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}.seed-record-panel__toolbar{display:flex;align-items:center;height:49px;flex-shrink:0;padding:8px 10px;border-bottom:1px solid var(--seed-color-stroke-neutral-muted)}.seed-record-panel__toolbarSpacer{flex:1}.seed-record-panel__pager{display:flex;align-items:center;gap:6px;margin-inline-start:12px}.seed-record-panel__counter{margin-inline-start:12px;font-size:12px;font-weight:500;color:var(--seed-color-fg-neutral-subtle);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.seed-record-panel__iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;flex-shrink:0;padding:0;border:none;border-radius:6px;background:0 0;color:var(--seed-color-fg-neutral-subtle);font-family:inherit;cursor:pointer;transition:background-color .12s}.seed-record-panel__iconbtn:hover{background:var(--seed-color-bg-layer-fill);color:var(--seed-color-fg-neutral)}.seed-record-panel__navbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;flex-shrink:0;padding:0;border:none;border-radius:6px;background:var(--seed-color-bg-layer-default);box-shadow:inset 0 0 0 1px rgba(0,0,0,.06),0 0 2px rgba(28,40,64,.18),0 1px 3px rgba(0,0,0,.04);color:var(--seed-color-fg-neutral);font-family:inherit;cursor:pointer}.seed-record-panel__navbtn:hover:not(:disabled){background:var(--seed-color-bg-layer-fill)}.seed-record-panel__navbtn:disabled{opacity:.4;cursor:not-allowed}.seed-record-panel__header{display:flex;align-items:center;gap:8px;height:52px;flex-shrink:0;padding:0 12px}.seed-record-panel__fav{width:22px;height:22px;border-radius:4px;flex-shrink:0;object-fit:cover;background:var(--seed-color-bg-layer-fill)}.seed-record-panel__title{flex:1;min-width:0;font-size:16px;font-weight:600;letter-spacing:-.2px;color:var(--seed-color-fg-neutral);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.seed-record-panel__body{flex:1;min-height:0;overflow-y:auto;padding:8px 12px}.seed-record-panel__section{padding:8px 4px 0}.seed-record-panel__sectionHead{display:flex;align-items:center;justify-content:space-between;gap:8px;height:20px;margin-bottom:8px}.seed-record-panel__sectionLabel{font-size:14px;font-weight:500;color:var(--seed-color-fg-neutral-subtle)}.seed-record-panel__sectionAction{border:none;background:0 0;padding:0;font-family:inherit;font-size:10px;font-weight:400;color:color-mix(in srgb,var(--seed-color-fg-neutral) 55%,transparent);cursor:pointer}.seed-record-panel__sectionAction:hover{color:var(--seed-color-fg-neutral)}.seed-record-panel__highlights{display:grid;grid-template-columns:1fr 1fr;gap:8px}.seed-record-panel__highlight{display:flex;flex-direction:column;gap:6px;min-width:0}.seed-record-panel__highlightLabel{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:500;color:var(--seed-color-fg-neutral-subtle)}.seed-record-panel__highlightLabel span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.seed-record-panel__highlightLabel svg{flex-shrink:0;color:var(--seed-color-fg-neutral-subtle)}.seed-record-panel__highlightValue{font-size:14px;font-weight:500;color:var(--seed-color-fg-neutral);overflow:hidden;text-overflow:ellipsis}.seed-record-panel__activity{font-size:14px}.seed-record-panel__sections{display:flex;flex-direction:column;padding-top:8px}.seed-record-panel__sectionTrigger{display:flex;align-items:center;gap:4px;width:100%;height:20px;padding:0 8px 0 12px;border:none;background:0 0;font-family:inherit;text-align:start;cursor:pointer}.seed-record-panel__sectionTrigger[data-inert]{cursor:default}.seed-record-panel__sectionRowLabel{font-size:14px;font-weight:500;color:var(--seed-color-fg-neutral)}.seed-record-panel__sectionRowCount{font-size:14px;color:var(--seed-color-fg-neutral-subtle)}.seed-record-panel__sectionChevron{margin-inline-start:auto;flex-shrink:0;color:var(--seed-color-fg-neutral-subtle);transition:transform .15s ease}.seed-record-panel__sectionChevron[data-open]{transform:rotate(180deg)}.seed-record-panel__sectionContent{padding:8px 8px 8px 12px}.seed-record-panel__footer{flex-shrink:0;display:flex;align-items:center;gap:8px;padding:8px 12px;border-top:1px solid var(--seed-color-stroke-neutral-muted)}");
11461
+ __insertCSS(".seed-record-preview__root{display:flex;flex-direction:column;height:100%;overflow:hidden;background:var(--seed-color-bg-layer-default);font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}.seed-record-preview__toolbar{display:flex;align-items:center;height:49px;flex-shrink:0;padding:8px 10px;border-bottom:1px solid var(--seed-color-stroke-neutral-muted)}.seed-record-preview__toolbarSpacer{flex:1}.seed-record-preview__pager{display:flex;align-items:center;gap:6px;margin-inline-start:12px}.seed-record-preview__counter{margin-inline-start:12px;font-size:12px;font-weight:500;color:var(--seed-color-fg-neutral-subtle);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.seed-record-preview__iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;flex-shrink:0;padding:0;border:none;border-radius:6px;background:0 0;color:var(--seed-color-fg-neutral-subtle);font-family:inherit;cursor:pointer;transition:background-color .12s}.seed-record-preview__iconbtn:hover{background:var(--seed-color-bg-layer-fill);color:var(--seed-color-fg-neutral)}.seed-record-preview__navbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;flex-shrink:0;padding:0;border:none;border-radius:6px;background:var(--seed-color-bg-layer-default);box-shadow:inset 0 0 0 1px rgba(0,0,0,.06),0 0 2px rgba(28,40,64,.18),0 1px 3px rgba(0,0,0,.04);color:var(--seed-color-fg-neutral);font-family:inherit;cursor:pointer}.seed-record-preview__navbtn:hover:not(:disabled){background:var(--seed-color-bg-layer-fill)}.seed-record-preview__navbtn:disabled{opacity:.4;cursor:not-allowed}.seed-record-preview__header{display:flex;align-items:center;gap:8px;height:52px;flex-shrink:0;padding:0 12px}.seed-record-preview__fav{width:22px;height:22px;border-radius:4px;flex-shrink:0;object-fit:cover;background:var(--seed-color-bg-layer-fill)}.seed-record-preview__title{flex:1;min-width:0;font-size:16px;font-weight:600;letter-spacing:-.2px;color:var(--seed-color-fg-neutral);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.seed-record-preview__body{flex:1;min-height:0;overflow-y:auto;padding:8px 12px}.seed-record-preview__section{padding:8px 4px 0}.seed-record-preview__sectionHead{display:flex;align-items:center;justify-content:space-between;gap:8px;height:20px;margin-bottom:8px}.seed-record-preview__sectionLabel{font-size:14px;font-weight:500;color:var(--seed-color-fg-neutral-subtle)}.seed-record-preview__sectionAction{border:none;background:0 0;padding:0;font-family:inherit;font-size:10px;font-weight:400;color:color-mix(in srgb,var(--seed-color-fg-neutral) 55%,transparent);cursor:pointer}.seed-record-preview__sectionAction:hover{color:var(--seed-color-fg-neutral)}.seed-record-preview__highlights{display:grid;grid-template-columns:1fr 1fr;gap:8px}.seed-record-preview__highlight{display:flex;flex-direction:column;gap:6px;min-width:0}.seed-record-preview__highlightLabel{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:500;color:var(--seed-color-fg-neutral-subtle)}.seed-record-preview__highlightLabel span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.seed-record-preview__highlightLabel svg{flex-shrink:0;color:var(--seed-color-fg-neutral-subtle)}.seed-record-preview__highlightValue{font-size:14px;font-weight:500;color:var(--seed-color-fg-neutral);overflow:hidden;text-overflow:ellipsis}.seed-record-preview__activity{font-size:14px}.seed-record-preview__sections{display:flex;flex-direction:column;padding-top:8px}.seed-record-preview__sectionTrigger{display:flex;align-items:center;gap:4px;width:100%;height:20px;padding:0 8px 0 12px;border:none;background:0 0;font-family:inherit;text-align:start;cursor:pointer}.seed-record-preview__sectionTrigger[data-inert]{cursor:default}.seed-record-preview__sectionRowLabel{font-size:14px;font-weight:500;color:var(--seed-color-fg-neutral)}.seed-record-preview__sectionRowCount{font-size:14px;color:var(--seed-color-fg-neutral-subtle)}.seed-record-preview__sectionChevron{margin-inline-start:auto;flex-shrink:0;color:var(--seed-color-fg-neutral-subtle);transition:transform .15s ease}.seed-record-preview__sectionChevron[data-open]{transform:rotate(180deg)}.seed-record-preview__sectionContent{padding:8px 8px 8px 12px}.seed-record-preview__footer{flex-shrink:0;display:flex;align-items:center;gap:8px;padding:8px 12px;border-top:1px solid var(--seed-color-stroke-neutral-muted)}");
11281
11462
 
11282
- const recordPanelSlotNames = [
11463
+ const recordPreviewSlotNames = [
11283
11464
  [
11284
11465
  "root",
11285
- "seed-record-panel__root"
11466
+ "seed-record-preview__root"
11286
11467
  ],
11287
11468
  [
11288
11469
  "toolbar",
11289
- "seed-record-panel__toolbar"
11470
+ "seed-record-preview__toolbar"
11290
11471
  ],
11291
11472
  [
11292
11473
  "toolbarSpacer",
11293
- "seed-record-panel__toolbarSpacer"
11474
+ "seed-record-preview__toolbarSpacer"
11294
11475
  ],
11295
11476
  [
11296
11477
  "pager",
11297
- "seed-record-panel__pager"
11478
+ "seed-record-preview__pager"
11298
11479
  ],
11299
11480
  [
11300
11481
  "counter",
11301
- "seed-record-panel__counter"
11482
+ "seed-record-preview__counter"
11302
11483
  ],
11303
11484
  [
11304
11485
  "iconbtn",
11305
- "seed-record-panel__iconbtn"
11486
+ "seed-record-preview__iconbtn"
11306
11487
  ],
11307
11488
  [
11308
11489
  "navbtn",
11309
- "seed-record-panel__navbtn"
11490
+ "seed-record-preview__navbtn"
11310
11491
  ],
11311
11492
  [
11312
11493
  "header",
11313
- "seed-record-panel__header"
11494
+ "seed-record-preview__header"
11314
11495
  ],
11315
11496
  [
11316
11497
  "fav",
11317
- "seed-record-panel__fav"
11498
+ "seed-record-preview__fav"
11318
11499
  ],
11319
11500
  [
11320
11501
  "title",
11321
- "seed-record-panel__title"
11502
+ "seed-record-preview__title"
11322
11503
  ],
11323
11504
  [
11324
11505
  "body",
11325
- "seed-record-panel__body"
11506
+ "seed-record-preview__body"
11326
11507
  ],
11327
11508
  [
11328
11509
  "section",
11329
- "seed-record-panel__section"
11510
+ "seed-record-preview__section"
11330
11511
  ],
11331
11512
  [
11332
11513
  "sectionHead",
11333
- "seed-record-panel__sectionHead"
11514
+ "seed-record-preview__sectionHead"
11334
11515
  ],
11335
11516
  [
11336
11517
  "sectionLabel",
11337
- "seed-record-panel__sectionLabel"
11518
+ "seed-record-preview__sectionLabel"
11338
11519
  ],
11339
11520
  [
11340
11521
  "sectionAction",
11341
- "seed-record-panel__sectionAction"
11522
+ "seed-record-preview__sectionAction"
11342
11523
  ],
11343
11524
  [
11344
11525
  "highlights",
11345
- "seed-record-panel__highlights"
11526
+ "seed-record-preview__highlights"
11346
11527
  ],
11347
11528
  [
11348
11529
  "highlight",
11349
- "seed-record-panel__highlight"
11530
+ "seed-record-preview__highlight"
11350
11531
  ],
11351
11532
  [
11352
11533
  "highlightLabel",
11353
- "seed-record-panel__highlightLabel"
11534
+ "seed-record-preview__highlightLabel"
11354
11535
  ],
11355
11536
  [
11356
11537
  "highlightValue",
11357
- "seed-record-panel__highlightValue"
11538
+ "seed-record-preview__highlightValue"
11358
11539
  ],
11359
11540
  [
11360
11541
  "activity",
11361
- "seed-record-panel__activity"
11542
+ "seed-record-preview__activity"
11362
11543
  ],
11363
11544
  [
11364
11545
  "sections",
11365
- "seed-record-panel__sections"
11546
+ "seed-record-preview__sections"
11366
11547
  ],
11367
11548
  [
11368
11549
  "sectionTrigger",
11369
- "seed-record-panel__sectionTrigger"
11550
+ "seed-record-preview__sectionTrigger"
11370
11551
  ],
11371
11552
  [
11372
11553
  "sectionRowLabel",
11373
- "seed-record-panel__sectionRowLabel"
11554
+ "seed-record-preview__sectionRowLabel"
11374
11555
  ],
11375
11556
  [
11376
11557
  "sectionRowCount",
11377
- "seed-record-panel__sectionRowCount"
11558
+ "seed-record-preview__sectionRowCount"
11378
11559
  ],
11379
11560
  [
11380
11561
  "sectionChevron",
11381
- "seed-record-panel__sectionChevron"
11562
+ "seed-record-preview__sectionChevron"
11382
11563
  ],
11383
11564
  [
11384
11565
  "sectionContent",
11385
- "seed-record-panel__sectionContent"
11566
+ "seed-record-preview__sectionContent"
11386
11567
  ],
11387
11568
  [
11388
11569
  "footer",
11389
- "seed-record-panel__footer"
11570
+ "seed-record-preview__footer"
11390
11571
  ]
11391
11572
  ];
11392
11573
  const defaultVariant = {};
11393
11574
  const compoundVariants = [];
11394
- const recordPanelVariantMap = {};
11395
- function recordPanel(props) {
11396
- return Object.fromEntries(recordPanelSlotNames.map(([slot, className])=>{
11575
+ const recordPreviewVariantMap = {};
11576
+ function recordPreview(props) {
11577
+ return Object.fromEntries(recordPreviewSlotNames.map(([slot, className])=>{
11397
11578
  return [
11398
11579
  slot,
11399
11580
  createClassName(className, mergeVariants(defaultVariant, props), compoundVariants)
11400
11581
  ];
11401
11582
  }));
11402
11583
  }
11403
- Object.assign(recordPanel, {
11404
- splitVariantProps: (props)=>splitVariantProps(props, recordPanelVariantMap)
11405
- }); // @recipe(seed): record-panel
11584
+ Object.assign(recordPreview, {
11585
+ splitVariantProps: (props)=>splitVariantProps(props, recordPreviewVariantMap)
11586
+ }); // @recipe(seed): record-preview
11587
+
11588
+ // This code includes portions derived from radix-ui/primitives (https://github.com/radix-ui/primitives)
11589
+ // Used under the MIT License: https://opensource.org/licenses/MIT
11590
+ function usePresence(present) {
11591
+ const [node, setNode] = React.useState();
11592
+ const stylesRef = React.useRef(null);
11593
+ const prevPresentRef = React.useRef(present);
11594
+ const prevAnimationNameRef = React.useRef("none");
11595
+ const initialState = present ? "mounted" : "unmounted";
11596
+ const [state, send] = useStateMachine(initialState, {
11597
+ mounted: {
11598
+ UNMOUNT: "unmounted",
11599
+ ANIMATION_OUT: "unmountSuspended"
11600
+ },
11601
+ unmountSuspended: {
11602
+ MOUNT: "mounted",
11603
+ ANIMATION_END: "unmounted"
11604
+ },
11605
+ unmounted: {
11606
+ MOUNT: "mounted"
11607
+ }
11608
+ });
11609
+ React.useEffect(()=>{
11610
+ const currentAnimationName = getAnimationName(stylesRef.current);
11611
+ prevAnimationNameRef.current = state === "mounted" ? currentAnimationName : "none";
11612
+ }, [
11613
+ state
11614
+ ]);
11615
+ useLayoutEffect2(()=>{
11616
+ const styles = stylesRef.current;
11617
+ const wasPresent = prevPresentRef.current;
11618
+ const hasPresentChanged = wasPresent !== present;
11619
+ if (hasPresentChanged) {
11620
+ const prevAnimationName = prevAnimationNameRef.current;
11621
+ const currentAnimationName = getAnimationName(styles);
11622
+ if (present) {
11623
+ send("MOUNT");
11624
+ } else if (currentAnimationName === "none" || styles?.display === "none") {
11625
+ // If there is no exit animation or the element is hidden, animations won't run
11626
+ // so we unmount instantly
11627
+ send("UNMOUNT");
11628
+ } else {
11629
+ /**
11630
+ * When `present` changes to `false`, we check changes to animation-name to
11631
+ * determine whether an animation has started. We chose this approach (reading
11632
+ * computed styles) because there is no `animationrun` event and `animationstart`
11633
+ * fires after `animation-delay` has expired which would be too late.
11634
+ */ const isAnimating = prevAnimationName !== currentAnimationName;
11635
+ if (wasPresent && isAnimating) {
11636
+ send("ANIMATION_OUT");
11637
+ } else {
11638
+ send("UNMOUNT");
11639
+ }
11640
+ }
11641
+ prevPresentRef.current = present;
11642
+ }
11643
+ }, [
11644
+ present,
11645
+ send
11646
+ ]);
11647
+ useLayoutEffect2(()=>{
11648
+ if (node) {
11649
+ let timeoutId;
11650
+ const ownerWindow = node.ownerDocument.defaultView ?? window;
11651
+ /**
11652
+ * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel`
11653
+ * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we
11654
+ * make sure we only trigger ANIMATION_END for the currently active animation.
11655
+ */ const handleAnimationEnd = (event)=>{
11656
+ const currentAnimationName = getAnimationName(stylesRef.current);
11657
+ const isCurrentAnimation = currentAnimationName.includes(event.animationName);
11658
+ if (event.target === node && isCurrentAnimation) {
11659
+ // With React 18 concurrency this update is applied a frame after the
11660
+ // animation ends, creating a flash of visible content. By setting the
11661
+ // animation fill mode to "forwards", we force the node to keep the
11662
+ // styles of the last keyframe, removing the flash.
11663
+ //
11664
+ // Previously we flushed the update via ReactDom.flushSync, but with
11665
+ // exit animations this resulted in the node being removed from the
11666
+ // DOM before the synthetic animationEnd event was dispatched, meaning
11667
+ // user-provided event handlers would not be called.
11668
+ // https://github.com/radix-ui/primitives/pull/1849
11669
+ send("ANIMATION_END");
11670
+ if (!prevPresentRef.current) {
11671
+ const currentFillMode = node.style.animationFillMode;
11672
+ node.style.animationFillMode = "forwards";
11673
+ // Reset the style after the node had time to unmount (for cases
11674
+ // where the component chooses not to unmount). Doing this any
11675
+ // sooner than `setTimeout` (e.g. with `requestAnimationFrame`)
11676
+ // still causes a flash.
11677
+ timeoutId = ownerWindow.setTimeout(()=>{
11678
+ if (node.style.animationFillMode === "forwards") {
11679
+ node.style.animationFillMode = currentFillMode;
11680
+ }
11681
+ });
11682
+ }
11683
+ }
11684
+ };
11685
+ const handleAnimationStart = (event)=>{
11686
+ if (event.target === node) {
11687
+ // if animation occurred, store its name as the previous animation.
11688
+ prevAnimationNameRef.current = getAnimationName(stylesRef.current);
11689
+ }
11690
+ };
11691
+ node.addEventListener("animationstart", handleAnimationStart);
11692
+ node.addEventListener("animationcancel", handleAnimationEnd);
11693
+ node.addEventListener("animationend", handleAnimationEnd);
11694
+ return ()=>{
11695
+ ownerWindow.clearTimeout(timeoutId);
11696
+ node.removeEventListener("animationstart", handleAnimationStart);
11697
+ node.removeEventListener("animationcancel", handleAnimationEnd);
11698
+ node.removeEventListener("animationend", handleAnimationEnd);
11699
+ };
11700
+ } else {
11701
+ // Transition to the unmounted state if the node is removed prematurely.
11702
+ // We avoid doing so during cleanup as the node may change but still exist.
11703
+ send("ANIMATION_END");
11704
+ }
11705
+ }, [
11706
+ node,
11707
+ send
11708
+ ]);
11709
+ return {
11710
+ isPresent: [
11711
+ "mounted",
11712
+ "unmountSuspended"
11713
+ ].includes(state),
11714
+ ref: React.useCallback((node)=>{
11715
+ if (node) stylesRef.current = getComputedStyle(node);
11716
+ setNode(node ?? undefined);
11717
+ }, [])
11718
+ };
11719
+ }
11720
+ /* -----------------------------------------------------------------------------------------------*/ function getAnimationName(styles) {
11721
+ return styles?.animationName || "none";
11722
+ }
11723
+ function useStateMachine(initialState, machine) {
11724
+ return React.useReducer((state, event)=>{
11725
+ const nextState = machine[state][event];
11726
+ return nextState ?? state;
11727
+ }, initialState);
11728
+ }
11729
+ const PresenceContext = /*#__PURE__*/ createContext(null);
11730
+ PresenceContext.Provider;
11406
11731
 
11407
11732
  const RESIZE_MIN_WIDTH = 360;
11408
11733
  const RESIZE_MAX_WIDTH = 720;
@@ -11412,12 +11737,35 @@ const RESIZE_MAX_WIDTH = 720;
11412
11737
  * `RecordPanel` (`product/recordpanel/`), built ON `ResizablePanel`
11413
11738
  * (`side="right" floating`) rather than crisp's own duplicate absolute-
11414
11739
  * positioning/slide-in/resize-handle implementation -- the outer shell (the
11415
- * `@starting-style` slide-in, the floating elevation, the drag handle
11740
+ * enter/exit keyframe animation, the floating elevation, the drag handle
11416
11741
  * itself) all come from that primitive; this component only renders the
11417
11742
  * CONTENT inside it.
11418
- */ function RecordPanel({ open, record, onClose, className, closeLabel = "Close panel", onOpenRecord, openRecordLabel = "Open record", onPrevious, previousRecordLabel = "Previous record", onNext, nextRecordLabel = "Next record", title, avatar, highlightsLabel = "Highlights", highlights, counterLabel, resizable = false, width, defaultWidth = 420, onWidthChange, minWidth = RESIZE_MIN_WIDTH, maxWidth = RESIZE_MAX_WIDTH, activityLabel = "Activity", viewAllActivityLabel = "View all", onViewAllActivity, activity, sections, footer, ...rest }) {
11743
+ *
11744
+ * Enter/exit: closing this panel is a state change (`open` -> false) that
11745
+ * typically clears `record` in the SAME update (see the DataTable story's
11746
+ * own `onClose={() => setPreviewRow(null)}`), so by the time an exit
11747
+ * animation should play, the values this component would render are
11748
+ * already gone. `usePresence` (`@seed-design/react-presence`, the same
11749
+ * idiom Dialog/Drawer already use for their own floating layers) keeps the
11750
+ * tree mounted for exactly the exit animation's duration; the snapshot
11751
+ * below keeps the LAST visible props around for that same window, since
11752
+ * without it the panel would animate out empty.
11753
+ */ function RecordPreview(props) {
11754
+ const shouldRender = props.open && props.record != null;
11755
+ const { isPresent, ref: panelElementRef } = usePresence(shouldRender);
11756
+ const lastVisiblePropsRef = useRef(props);
11757
+ if (shouldRender) lastVisiblePropsRef.current = props;
11758
+ if (!isPresent) return null;
11759
+ return /*#__PURE__*/ jsx(RecordPreviewPanel, {
11760
+ ...shouldRender ? props : lastVisiblePropsRef.current,
11761
+ animationState: shouldRender ? "open" : "closed",
11762
+ panelElementRef: panelElementRef
11763
+ });
11764
+ }
11765
+ RecordPreview.displayName = "RecordPreview";
11766
+ function RecordPreviewPanel({ open: _open, record, onClose, className, animationState, panelElementRef, closeLabel = "Close panel", onOpenRecord, openRecordLabel = "Open record", onPrevious, previousRecordLabel = "Previous record", onNext, nextRecordLabel = "Next record", title, avatar, highlightsLabel = "Highlights", highlights, counterLabel, resizable = false, width, defaultWidth = 497, onWidthChange, minWidth = RESIZE_MIN_WIDTH, maxWidth = RESIZE_MAX_WIDTH, activityLabel = "Activity", viewAllActivityLabel = "View all", onViewAllActivity, activity, sections, footer, ...rest }) {
11419
11767
  const titleId = useId();
11420
- const classNames = recordPanel();
11768
+ const classNames = recordPreview();
11421
11769
  // Which sections (your-moon/crisp#413) are expanded -- uncontrolled and
11422
11770
  // independent (Attio's own Emails/Notes/Tasks open without closing one
11423
11771
  // another): a plain `Set`, reset whenever the record itself changes so a
@@ -11435,28 +11783,31 @@ const RESIZE_MAX_WIDTH = 720;
11435
11783
  else next.add(key);
11436
11784
  return next;
11437
11785
  });
11438
- // Close on Escape while open. Simpler than crisp's own version (crisp
11439
- // additionally checks `hasOpenFloatingLayer()` so an Escape meant for a
11440
- // nested Menu/Select/Popover doesn't also close this panel) -- crispy-seed
11441
- // has no equivalent floating-stack registry to read, so a consumer nesting
11442
- // its own floating layers inside `footer`/`sections` content should stop
11443
- // propagation on its own Escape handler.
11786
+ // Close on Escape. Simpler than crisp's own version (crisp additionally
11787
+ // checks `hasOpenFloatingLayer()` so an Escape meant for a nested Menu/
11788
+ // Select/Popover doesn't also close this panel) -- crispy-seed has no
11789
+ // equivalent floating-stack registry to read, so a consumer nesting its
11790
+ // own floating layers inside `footer`/`sections` content should stop
11791
+ // propagation on its own Escape handler. This component is only ever
11792
+ // mounted (by `RecordPreview`, above) while genuinely open or animating
11793
+ // out, so there's no separate `open` flag to gate on here.
11444
11794
  useEffect(()=>{
11445
- if (!open) return;
11446
11795
  const onKey = (event)=>{
11447
11796
  if (event.key === "Escape") onClose();
11448
11797
  };
11449
11798
  document.addEventListener("keydown", onKey);
11450
11799
  return ()=>document.removeEventListener("keydown", onKey);
11451
11800
  }, [
11452
- open,
11453
11801
  onClose
11454
11802
  ]);
11455
- if (!open || !record) return null;
11803
+ // Defensive only -- `RecordPreview` never mounts this without a record.
11804
+ if (!record) return null;
11456
11805
  const heading = title ?? record.name ?? "Record";
11457
11806
  return /*#__PURE__*/ jsx(ResizablePanel, {
11458
11807
  side: "right",
11459
11808
  floating: true,
11809
+ animationState: animationState,
11810
+ panelElementRef: panelElementRef,
11460
11811
  resizable: resizable,
11461
11812
  width: width,
11462
11813
  defaultSize: defaultWidth,
@@ -11668,6 +12019,6 @@ const RESIZE_MAX_WIDTH = 720;
11668
12019
  })
11669
12020
  });
11670
12021
  }
11671
- RecordPanel.displayName = "RecordPanel";
12022
+ RecordPreviewPanel.displayName = "RecordPreviewPanel";
11672
12023
 
11673
- export { DataTable, DataTableViewsControl, RecordPanel, ResizablePanel };
12024
+ export { DataTable, DataTableViewsControl, RecordPreview, ResizablePanel };