@crisp-ui-kit/crisp 0.54.8 → 0.54.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/board.css +32 -8
  2. package/dist/board.layered.css +32 -8
  3. package/dist/cjs/components/contextmenu/contextmenu.js +2 -2
  4. package/dist/cjs/components/contextmenu/contextmenu.recipe.js +24 -0
  5. package/dist/cjs/components/datatable/datatable.js +593 -51
  6. package/dist/cjs/components/datatable/datatable.recipe.js +67 -3
  7. package/dist/cjs/components/dialog/dialog.js +65 -1
  8. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +37 -2
  9. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +218 -39
  10. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +48 -1
  11. package/dist/cjs/components/text/text.recipe.js +17 -7
  12. package/dist/cjs/product/board/board.js +62 -28
  13. package/dist/cjs/product/board/board.recipe.js +42 -2
  14. package/dist/cjs/product/comments/comments.js +11 -2
  15. package/dist/cjs/product/comments/comments.recipe.js +14 -4
  16. package/dist/cjs/product/recordstable/recordstable.js +1 -17
  17. package/dist/cjs/product/savedviews/savedviews.js +55 -24
  18. package/dist/cjs/product/savedviews/savedviews.recipe.js +27 -0
  19. package/dist/comments.css +14 -4
  20. package/dist/comments.layered.css +14 -4
  21. package/dist/contextmenu.css +24 -0
  22. package/dist/contextmenu.layered.css +24 -0
  23. package/dist/datatable.css +106 -77
  24. package/dist/datatable.layered.css +106 -77
  25. package/dist/esm/components/contextmenu/contextmenu.js +2 -2
  26. package/dist/esm/components/contextmenu/contextmenu.recipe.js +24 -0
  27. package/dist/esm/components/datatable/datatable.js +593 -52
  28. package/dist/esm/components/datatable/datatable.recipe.js +68 -4
  29. package/dist/esm/components/dialog/dialog.js +65 -1
  30. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +37 -2
  31. package/dist/esm/components/tabletoolbar/tabletoolbar.js +219 -40
  32. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +50 -3
  33. package/dist/esm/components/text/text.recipe.js +17 -7
  34. package/dist/esm/product/board/board.js +63 -29
  35. package/dist/esm/product/board/board.recipe.js +42 -2
  36. package/dist/esm/product/comments/comments.js +12 -3
  37. package/dist/esm/product/comments/comments.recipe.js +14 -4
  38. package/dist/esm/product/recordstable/recordstable.js +2 -18
  39. package/dist/esm/product/savedviews/savedviews.js +57 -26
  40. package/dist/esm/product/savedviews/savedviews.recipe.js +27 -0
  41. package/dist/recordstable.css +33 -31
  42. package/dist/recordstable.layered.css +33 -31
  43. package/dist/savedviews.css +27 -0
  44. package/dist/savedviews.layered.css +27 -0
  45. package/dist/styles.css +282 -125
  46. package/dist/styles.layered.css +282 -125
  47. package/dist/tabletoolbar.css +45 -4
  48. package/dist/tabletoolbar.layered.css +45 -4
  49. package/dist/text.css +1 -1
  50. package/dist/text.layered.css +1 -1
  51. package/dist/types/components/contextmenu/contextmenu.d.ts +8 -1
  52. package/dist/types/components/datatable/datatable.d.ts +98 -2
  53. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +72 -2
  54. package/dist/types/components/text/text.recipe.d.ts +1 -1
  55. package/dist/types/product/board/board.d.ts +48 -2
  56. package/dist/types/product/comments/comments.d.ts +6 -1
  57. package/dist/types/product/recordstable/recordstable.d.ts +0 -1
  58. package/dist/types/product/savedviews/savedviews.d.ts +45 -8
  59. package/package.json +1 -1
@@ -1,11 +1,19 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide, Check, CircleHelp, GripVertical, ListFilter, MoreVertical, Plus, SlidersHorizontal, Trash2, X, } from "lucide-react";
2
+ import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide, Check, ChevronDown, ChevronLeft, ChevronRight, CircleHelp, Download, GripVertical, ListFilter, MoreVertical, Plus, SlidersHorizontal, Trash2, Upload, X, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { isInsideLaterFloatingLayer, useCrispMessage, useCrispMessages, useFocusAfterRemoval, } from "../../core/index.js";
5
5
  import { DatePicker, } from "../datepicker/index.js";
6
6
  import { MenuCheckboxItem, useMenuKeyboard } from "../menu/index.js";
7
7
  import { Select } from "../select/index.js";
8
8
  import { tabletoolbarRecipe } from "./tabletoolbar.recipe.js";
9
+ // #592: shared by every direction-bearing ChevronRight usage below (the
10
+ // field picker's own disclosure chevron, a flattened search result's
11
+ // breadcrumb separator, and a filter chip's own breadcrumb separator) so
12
+ // the RTL mirror attribute lives in exactly one JSX tag — build/spec-check
13
+ // .mjs's icon sweep counts that attribute per icon NAME across the whole
14
+ // file, not per call site, and three independently attributed tags for
15
+ // the same name over-counts against that (it expects one per name).
16
+ const RtlChevronRight = ({ size, className, }) => (_jsx(ChevronRight, { "data-rtl-flip": "", size: size, strokeWidth: 1.75, className: className }));
9
17
  const OP_LABELS = {
10
18
  contains: "contains",
11
19
  doesnotcontain: "does not contain",
@@ -66,18 +74,53 @@ const DATE_OP_ORDER = [
66
74
  "empty",
67
75
  "notempty",
68
76
  ];
77
+ // #715: a closed-vocabulary column (GRC Audit's Action/Entity, e.g.) gets
78
+ // its own shorter set too — the text set's `contains`/`starts with`/… mean
79
+ // nothing against a fixed value list. Reuses the already-measured `is`/
80
+ // `is not`/`empty`/`not empty` labels and the same "is/isnot before the
81
+ // type-specific pair, empty/notempty last" ordering every other set here
82
+ // follows — not an independent pixel measurement the way #593's number/date
83
+ // sets were (a live re-check of Attio's own select-column filter menu is
84
+ // still open, see the PR).
85
+ const SELECT_OP_ORDER = ["is", "isnot", "empty", "notempty"];
69
86
  // "isanyof" is appended only for a column that supplies `values` — there is
70
87
  // nothing to check off otherwise. A column's `type` picks the base set
71
- // first; "isanyof" can still follow any of the three (a column's own
72
- // declared values aren't tied to its underlying number/date/text kind).
88
+ // first; "isanyof" can still follow any of the four (a column's own
89
+ // declared values aren't tied to its underlying number/date/text/select
90
+ // kind).
73
91
  const opsFor = (col) => {
74
92
  const base = col?.type === "number"
75
93
  ? NUMBER_OP_ORDER
76
94
  : col?.type === "date"
77
95
  ? DATE_OP_ORDER
78
- : OP_ORDER;
96
+ : col?.type === "select"
97
+ ? SELECT_OP_ORDER
98
+ : OP_ORDER;
79
99
  return col?.values?.length ? [...base, "isanyof"] : base;
80
100
  };
101
+ // #592: a condition's `key` is either a plain top-level column key, or a
102
+ // dot-joined path into `nestedColumns` ("team.twitterFollowerCount"). Walks
103
+ // the path one segment at a time, descending into each match's own
104
+ // `nestedColumns` for the next — returns the resolved column at each step,
105
+ // in order, so a caller has both the LEAF (its own `type`/`values`/`icon`
106
+ // govern the operator set and value editor, unchanged from a plain column)
107
+ // and the FULL PATH (for the breadcrumb the field segment/picker render).
108
+ // Empty when any segment fails to resolve, rather than a partial path — a
109
+ // stale key (its column was removed upstream) should render as "unknown",
110
+ // not as whatever prefix still happens to match.
111
+ const resolveColumnPath = (cols, key) => {
112
+ const segments = key.split(".");
113
+ const path = [];
114
+ let list = cols;
115
+ for (const seg of segments) {
116
+ const found = list.find((c) => c.key === seg);
117
+ if (!found)
118
+ return [];
119
+ path.push(found);
120
+ list = found.nestedColumns ?? [];
121
+ }
122
+ return path;
123
+ };
81
124
  const opNeedsValue = (op) => op === "contains" ||
82
125
  op === "doesnotcontain" ||
83
126
  op === "startswith" ||
@@ -256,7 +299,7 @@ export function applySorts(rows, sorts, get = (row, key) => row[key]) {
256
299
  * this (Sort: Due date/Assignee/Creation date; Filter: Assignee/Record/
257
300
  * Created by), which one shared list can't reproduce.
258
301
  */
259
- export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: filterColumnsProp, sorts: sortsProp, defaultSorts, onSortsChange, filters: filtersProp, defaultFilters, onFiltersChange, count, children, helpHref, className, sortLabel: sortLabelProp, filterLabel: filterLabelProp, reorderSortLabel: reorderSortLabelProp, noSortsLabel: noSortsLabelProp, addSortLabel: addSortLabelProp, learnSortingLabel: learnSortingLabelProp, selectConditionLabel: selectConditionLabelProp, sortFieldLabel: sortFieldLabelProp, sortDirectionLabel: sortDirectionLabelProp, removeSortLabel: removeSortLabelProp, filterFieldLabel: filterFieldLabelProp, filterFieldPlaceholder: filterFieldPlaceholderProp, filterValuePlaceholder: filterValuePlaceholderProp, filterOptionsLabel: filterOptionsLabelProp, deleteFilterLabel: deleteFilterLabelProp, ascendingLabel: ascendingLabelProp, descendingLabel: descendingLabelProp, sortTriggerLabel, filterTriggerLabel, inline, showSort = true, showViewSettings = true, viewColumns: viewColumnsProp, shownKeys: shownKeysProp, defaultShownKeys, onShownKeysChange, viewSettingsLabel: viewSettingsLabelProp, reorderAttributeLabel: reorderAttributeLabelProp, hideAttributeLabel, addAttributeToViewLabel: addAttributeToViewLabelProp, searchAttributesPlaceholder: searchAttributesPlaceholderProp, ...rest }) {
302
+ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: filterColumnsProp, sorts: sortsProp, defaultSorts, onSortsChange, filters: filtersProp, defaultFilters, onFiltersChange, count, children, helpHref, className, sortLabel: sortLabelProp, filterLabel: filterLabelProp, reorderSortLabel: reorderSortLabelProp, noSortsLabel: noSortsLabelProp, addSortLabel: addSortLabelProp, learnSortingLabel: learnSortingLabelProp, selectConditionLabel: selectConditionLabelProp, sortFieldLabel: sortFieldLabelProp, sortDirectionLabel: sortDirectionLabelProp, removeSortLabel: removeSortLabelProp, filterFieldLabel: filterFieldLabelProp, filterFieldPlaceholder: filterFieldPlaceholderProp, filterValuePlaceholder: filterValuePlaceholderProp, filterOptionsLabel: filterOptionsLabelProp, deleteFilterLabel: deleteFilterLabelProp, ascendingLabel: ascendingLabelProp, descendingLabel: descendingLabelProp, sortTriggerLabel, filterTriggerLabel, inline, showSort = true, showViewSettings = true, viewColumns: viewColumnsProp, shownKeys: shownKeysProp, defaultShownKeys, onShownKeysChange, viewSettingsLabel: viewSettingsLabelProp, reorderAttributeLabel: reorderAttributeLabelProp, hideAttributeLabel, addAttributeToViewLabel: addAttributeToViewLabelProp, searchAttributesPlaceholder: searchAttributesPlaceholderProp, importExportLabel: importExportLabelProp, onImportCsv, onExportCsv, onExportExcel, importExportHelpHref, importCsvLabel: importCsvLabelProp, exportCsvLabel: exportCsvLabelProp, exportExcelLabel: exportExcelLabelProp, learnImportExportLabel: learnImportExportLabelProp, backLabel: backLabelProp, nestedFieldPlaceholder, ...rest }) {
260
303
  // crisp/#471: an explicit prop always wins; missing, it reads the nearest
261
304
  // CrispMessagesProvider's translation of the English default below, or
262
305
  // that default itself with no provider — unchanged from before this
@@ -283,6 +326,12 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
283
326
  const reorderAttributeLabel = useCrispMessage(reorderAttributeLabelProp, "Reorder attribute");
284
327
  const addAttributeToViewLabel = useCrispMessage(addAttributeToViewLabelProp, "Add attribute to view");
285
328
  const searchAttributesPlaceholder = useCrispMessage(searchAttributesPlaceholderProp, "Search attributes…");
329
+ const importExportLabel = useCrispMessage(importExportLabelProp, "Import / Export");
330
+ const importCsvLabel = useCrispMessage(importCsvLabelProp, "Import CSV");
331
+ const exportCsvLabel = useCrispMessage(exportCsvLabelProp, "Export view as CSV");
332
+ const exportExcelLabel = useCrispMessage(exportExcelLabelProp, "Export view as Excel");
333
+ const learnImportExportLabel = useCrispMessage(learnImportExportLabelProp, "Learn about imports and exports");
334
+ const backLabel = useCrispMessage(backLabelProp, "Back");
286
335
  // OP_LABELS' own words ("contains", "is", "is any of", …) have no prop of
287
336
  // their own — a whole lookup table, not one string — so they translate
288
337
  // straight from the provider's dictionary at the two spots they render
@@ -323,6 +372,17 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
323
372
  const [openMenu, setOpenMenu] = React.useState(null);
324
373
  const [dragSort, setDragSort] = React.useState(null);
325
374
  const [filterQuery, setFilterQuery] = React.useState("");
375
+ // #592: which nested scope the Filter field picker is currently drilled
376
+ // into -- [] is the top level, [team] is inside Team's own attributes,
377
+ // [team, company] is Team -> Company, etc. Reset to [] wherever
378
+ // `filterQuery` already resets (opening the picker fresh), same lifetime.
379
+ const [fieldPickerPath, setFieldPickerPath] = React.useState([]);
380
+ // #754: which sort row's own field picker is open (its index into
381
+ // `sorts`), or `null` for the normal sort-row list — the same
382
+ // "one sub-screen behind a flag" split View settings' own
383
+ // `addingAttribute` already uses, parameterized by index since more than
384
+ // one sort row exists.
385
+ const [sortFieldPickerIndex, setSortFieldPickerIndex] = React.useState(null);
326
386
  // filters the "isanyof" checkbox list, same lifetime as filterQuery above
327
387
  const [anyOfQuery, setAnyOfQuery] = React.useState("");
328
388
  const [dragAttr, setDragAttr] = React.useState(null);
@@ -336,7 +396,6 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
336
396
  // built here -- hiding an attribute is a trailing control instead, see
337
397
  // the hide button below).
338
398
  const [addingAttribute, setAddingAttribute] = React.useState(false);
339
- const [addAttrQuery, setAddAttrQuery] = React.useState("");
340
399
  // close any open menu on outside click (anything not inside a menu or its
341
400
  // trigger) or Escape
342
401
  React.useEffect(() => {
@@ -419,10 +478,27 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
419
478
  };
420
479
  // #590: shown attributes, in `shownKeys`' order -- a key with no matching
421
480
  // `viewColumns` entry (stale state after a column is removed upstream)
422
- // drops out rather than rendering a blank row.
423
- const shownAttrs = shownKeys
424
- .map((k) => viewColumns.find((c) => c.key === k))
425
- .filter((c) => c != null);
481
+ // drops out rather than rendering a blank row. #754: `shownKeys` can now
482
+ // carry a dot-joined nested path ("team.jobTitle") from the add-attribute
483
+ // screen's own nested picker, so each key resolves via the same
484
+ // `resolveColumnPath` the filter chip uses -- the LEAF column's icon
485
+ // drives the row's own icon, and the label is the full breadcrumb
486
+ // ("Team › Job title"), joined as plain text rather than the chip's own
487
+ // muted/chevron treatment, which was measured specifically for the
488
+ // filter chip and wasn't re-verified for this row shape.
489
+ const shownAttrs = shownKeys.flatMap((k) => {
490
+ const path = resolveColumnPath(viewColumns, k);
491
+ const leaf = path.at(-1);
492
+ if (!leaf)
493
+ return [];
494
+ return [
495
+ {
496
+ key: k,
497
+ icon: leaf.icon,
498
+ label: path.length > 1 ? path.map((c) => c.label).join(" › ") : leaf.label,
499
+ },
500
+ ];
501
+ });
426
502
  const hiddenAttrs = viewColumns.filter((c) => !shownKeys.includes(c.key));
427
503
  const moveAttr = (from, to) => {
428
504
  if (from === to)
@@ -436,7 +512,8 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
436
512
  const showAttr = (key) => {
437
513
  putShownKeys([...shownKeys, key]);
438
514
  setAddingAttribute(false);
439
- setAddAttrQuery("");
515
+ setFieldPickerPath([]);
516
+ setFilterQuery("");
440
517
  };
441
518
  // #594: the filter chip's kebab is a real role="menu" (crisp/#188's shared
442
519
  // model — arrows, Home/End, typeahead, Escape), not a hand-rolled div like
@@ -460,14 +537,62 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
460
537
  close: () => setOpenMenu(null),
461
538
  contentRef: opMenuRef,
462
539
  });
463
- // Shared between the "Filter" trigger's own add-condition picker and each
464
- // chip's field segment (#462 follow-up) — both search the same
465
- // `filterColumns` list and differ only in what a pick does.
466
- const filterFieldPicker = (onSelect) => (_jsxs(_Fragment, { children: [_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterFieldPlaceholder, "aria-label": filterFieldLabel,
467
- // biome-ignore lint/a11y/noAutofocus: focus the picker on open
468
- autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) }), filterColumns
469
- .filter((col) => col.label.toLowerCase().includes(filterQuery.trim().toLowerCase()))
470
- .map((col) => (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => onSelect(col.key), children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), col.label] }, col.key)))] }));
540
+ // The Import/Export menu is `role="menu"` too — confirmed live, 2026-09-25
541
+ // (an `<a>`/`<div>` mix, each `role="menuitem"`, two `role="separator"`
542
+ // dividers between the 3 groups) — same shared keyboard model.
543
+ const importExportMenuRef = React.useRef(null);
544
+ const { onKeyDown: onImportExportKeyDown } = useMenuKeyboard({
545
+ open: openMenu === "importexport",
546
+ close: () => setOpenMenu(null),
547
+ contentRef: importExportMenuRef,
548
+ });
549
+ // #754: generalized from the Filter-only `filterFieldPicker` (#592) to
550
+ // also drive Sort's own field picker and View settings' add-attribute
551
+ // screen — all three offer the identical nested-drill-down construct on
552
+ // live Attio (confirmed: "Team" with a count badge appears, and drills
553
+ // into the same People-attribute screen, in all three pickers). Shared
554
+ // `fieldPickerPath`/`filterQuery` state across all three call sites is
555
+ // safe: `openMenu` already only ever names ONE open popover at a time,
556
+ // so only one of Filter/Sort/View-settings' own picker can be showing.
557
+ // `fieldPickerPath` tracks the current scope ([] is the top level),
558
+ // reset to [] wherever a picker opens fresh (every trigger's own
559
+ // onClick).
560
+ const nestedFieldPicker = (columns, onSelect, topLevelPlaceholder, topLevelLabel) => {
561
+ const scope = fieldPickerPath.at(-1);
562
+ const scopeColumns = scope ? (scope.nestedColumns ?? []) : columns;
563
+ const query = filterQuery.trim().toLowerCase();
564
+ const direct = scopeColumns.filter((c) => c.label.toLowerCase().includes(query));
565
+ // #592: one level of flattening from wherever the picker is currently
566
+ // scoped — a nested column's own children get searched too, and a
567
+ // match renders breadcrumbed, alongside this level's direct matches.
568
+ // Matches the one case measured live at the TOP level ("Twitter
569
+ // follower" surfaces both the direct field and "Team › Twitter
570
+ // follower count"); applying the identical rule recursively from an
571
+ // already-drilled-into scope (so it also reaches Team → Company → X
572
+ // while already browsing inside Team) is the same construct, one level
573
+ // relative to wherever you are, but wasn't independently re-verified
574
+ // live from inside a nested scope — flagged on the PR.
575
+ const nested = query
576
+ ? scopeColumns.flatMap((c) => (c.nestedColumns ?? [])
577
+ .filter((n) => n.label.toLowerCase().includes(query))
578
+ .map((n) => ({ parent: c, col: n })))
579
+ : [];
580
+ const selectPath = (path) => onSelect(path.map((c) => c.key).join("."));
581
+ const drillIn = (col) => {
582
+ setFieldPickerPath([...fieldPickerPath, col]);
583
+ setFilterQuery("");
584
+ };
585
+ return (_jsxs(_Fragment, { children: [scope ? (_jsxs("div", { className: "crisp-tabletoolbar-menu-nesthead", children: [_jsx("button", { type: "button", className: "crisp-tabletoolbar-menu-back", "aria-label": backLabel, onClick: () => {
586
+ setFieldPickerPath(fieldPickerPath.slice(0, -1));
587
+ setFilterQuery("");
588
+ }, children: _jsx(ChevronLeft, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }) }), _jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: nestedFieldPlaceholder
589
+ ? nestedFieldPlaceholder(scope.nestedObjectLabel ?? scope.label.toLowerCase())
590
+ : `Search ${scope.nestedObjectLabel ?? scope.label.toLowerCase()} attributes…`, "aria-label": topLevelLabel,
591
+ // biome-ignore lint/a11y/noAutofocus: focus the picker on open
592
+ autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) })] })) : (_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: topLevelPlaceholder, "aria-label": topLevelLabel,
593
+ // biome-ignore lint/a11y/noAutofocus: focus the picker on open
594
+ autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) })), scope && (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => selectPath(fieldPickerPath), children: [scope.icon ? (_jsx(scope.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), scope.label] })), direct.map((col) => col.nestedColumns?.length ? (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item crisp-tabletoolbar-menu-item--expand", onClick: () => drillIn(col), children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), col.label, _jsx(RtlChevronRight, { size: 15, className: "crisp-tabletoolbar-menu-item-chevron" })] }, col.key)) : (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => selectPath([...fieldPickerPath, col]), children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), col.label] }, col.key))), nested.map(({ parent, col }) => (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => selectPath([...fieldPickerPath, parent, col]), children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), _jsxs("span", { className: "crisp-tabletoolbar-menu-item-path", children: [_jsx("span", { className: "crisp-tabletoolbar-menu-item-path-parent", children: parent.label }), _jsx(RtlChevronRight, { size: 15, className: "crisp-tabletoolbar-menu-item-path-sep" }), _jsx("span", { children: col.label })] })] }, `${parent.key}.${col.key}`)))] }));
595
+ };
471
596
  const activeFilters = filters.length;
472
597
  // The default phrase only works in English word order, so a caller can
473
598
  // replace the whole thing rather than fill a template — crisp/#471's
@@ -480,7 +605,11 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
480
605
  const sortedLabel = sortTriggerLabel
481
606
  ? sortTriggerLabel(sorts, sortColumns)
482
607
  : sorts.length
483
- ? `${crispMessages["Sorted by"] ?? "Sorted by"} ${sortColumns.find((c) => c.key === sorts[0].key)?.label}${sorts.length > 1 ? ` +${sorts.length - 1}` : ""}`
608
+ ? `${crispMessages["Sorted by"] ?? "Sorted by"} ${
609
+ // #754: sorts[0].key may be a dot-joined nested path
610
+ // ("team.jobTitle") — resolve it the same way the chip and sort
611
+ // row do, rather than a flat find that fails on a nested key.
612
+ resolveColumnPath(sortColumns, sorts[0].key).at(-1)?.label}${sorts.length > 1 ? ` +${sorts.length - 1}` : ""}`
484
613
  : sortLabel;
485
614
  return (_jsx("div", { className: tabletoolbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-tabletoolbar-toolbar", "data-inline": inline ? "" : undefined, children: [showSort && (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `crisp-tabletoolbar-tool${sorts.length ? " crisp-tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [_jsx(ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortedLabel] }), openMenu === "sort" && (_jsxs("div", { ref: sortListRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? (_jsx("div", { className: "crisp-tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => (_jsxs("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
486
615
  if (dragSort !== null)
@@ -489,11 +618,26 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
489
618
  if (dragSort !== null)
490
619
  moveSort(dragSort, i);
491
620
  setDragSort(null);
492
- }, children: [_jsx("button", { type: "button", className: "crisp-tabletoolbar-sort-grip", "aria-label": reorderSortLabel, draggable: true, onDragStart: () => setDragSort(i), onDragEnd: () => setDragSort(null), children: _jsx(GripVertical, { size: 15, strokeWidth: 1.75 }) }), _jsx(Select, { className: "crisp-tabletoolbar-sort-field", searchable: true, value: s.key, onValueChange: (k) => setSortField(i, k), options: sortColumns.map((c) => ({
493
- value: c.key,
494
- label: c.icon ? (_jsxs("span", { className: "crisp-tabletoolbar-sort-optlabel", children: [_jsx(c.icon, { size: 15, strokeWidth: 1.75 }), c.label] })) : (c.label),
495
- keywords: c.label,
496
- })), "aria-label": sortFieldLabel }), _jsx(Select, { className: "crisp-tabletoolbar-sort-dir", value: s.dir, onValueChange: (d) => setSortDir(i, d), options: [
621
+ }, children: [_jsx("button", { type: "button", className: "crisp-tabletoolbar-sort-grip", "aria-label": reorderSortLabel, draggable: true, onDragStart: () => setDragSort(i), onDragEnd: () => setDragSort(null), children: _jsx(GripVertical, { size: 15, strokeWidth: 1.75 }) }), (() => {
622
+ // #754: the sort row's own field picker -- the
623
+ // same nested-drill-down construct Filter's field
624
+ // picker already has (#592), measured live:
625
+ // clicking a sort row's field (or "Add sort") opens
626
+ // an identical picker, "Team" (a relationship
627
+ // field) drills into the same People-attribute
628
+ // screen Filter's own "Team" does.
629
+ const sortColPath = resolveColumnPath(sortColumns, s.key);
630
+ const sortCol = sortColPath.at(-1);
631
+ const pickerOpen = sortFieldPickerIndex === i;
632
+ return (_jsxs("span", { className: "crisp-tabletoolbar-sort-field-picker", children: [_jsxs("button", { type: "button", className: "crisp-tabletoolbar-sort-field-trigger", "aria-haspopup": "dialog", "aria-expanded": pickerOpen, "aria-label": sortFieldLabel, onClick: () => {
633
+ setFieldPickerPath([]);
634
+ setFilterQuery("");
635
+ setSortFieldPickerIndex(pickerOpen ? null : i);
636
+ }, children: [sortCol?.icon ? (_jsx(sortCol.icon, { size: 15, strokeWidth: 1.75 })) : null, sortColPath.length > 1 ? (_jsx("span", { className: "crisp-tabletoolbar-fcond-path", children: sortColPath.map((c, pi) => (_jsxs(React.Fragment, { children: [pi > 0 && (_jsx(RtlChevronRight, { size: 14, className: "crisp-tabletoolbar-fcond-path-sep" })), _jsx("span", { className: "crisp-tabletoolbar-fcond-path-seg", children: c.label })] }, c.key))) })) : (_jsx("span", { className: "crisp-tabletoolbar-sort-field-label", children: sortCol?.label }))] }), pickerOpen && (_jsx("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": sortFieldLabel, children: nestedFieldPicker(sortColumns, (key) => {
637
+ setSortField(i, key);
638
+ setSortFieldPickerIndex(null);
639
+ }, searchAttributesPlaceholder, sortFieldLabel) }))] }));
640
+ })(), _jsx(Select, { className: "crisp-tabletoolbar-sort-dir", value: s.dir, onValueChange: (d) => setSortDir(i, d), options: [
497
641
  {
498
642
  value: "asc",
499
643
  label: (_jsxs("span", { className: "crisp-tabletoolbar-sort-optlabel", children: [_jsx(ArrowUpNarrowWide, { size: 15, strokeWidth: 1.75 }), ascendingLabel] })),
@@ -504,12 +648,21 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
504
648
  label: (_jsxs("span", { className: "crisp-tabletoolbar-sort-optlabel", children: [_jsx(ArrowDownWideNarrow, { size: 15, strokeWidth: 1.75 }), descendingLabel] })),
505
649
  keywords: descendingLabel,
506
650
  },
507
- ], "aria-label": sortDirectionLabel }), _jsx("button", { type: "button", className: "crisp-tabletoolbar-sort-remove", onClick: () => removeSort(i), "aria-label": removeSortLabel, children: _jsx(X, { size: 15, strokeWidth: 1.75 }) })] }, s.key)))), _jsx("div", { className: "crisp-tabletoolbar-sort-addrow", children: _jsxs("button", { ref: addSortRef, type: "button", className: "crisp-tabletoolbar-sort-add", onClick: addSort, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), addSortLabel] }) }), helpHref && (_jsx("div", { className: "crisp-tabletoolbar-sort-foot", children: _jsxs("a", { className: "crisp-tabletoolbar-sort-learn", href: helpHref, target: "_blank", rel: "noreferrer", children: [_jsx(CircleHelp, { size: 14, strokeWidth: 1.75 }), learnSortingLabel] }) }))] }))] })), showSort && (_jsx("span", { className: "crisp-tabletoolbar-divider", "aria-hidden": "true" })), _jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `crisp-tabletoolbar-tool crisp-tabletoolbar-tool--ghost${activeFilters ? " crisp-tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "filter" ? null : "filter"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "filter", "data-menu-trigger": true, children: [_jsx(ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), filterTriggerLabel
651
+ ], "aria-label": sortDirectionLabel }), _jsx("button", { type: "button", className: "crisp-tabletoolbar-sort-remove", onClick: () => removeSort(i), "aria-label": removeSortLabel, children: _jsx(X, { size: 15, strokeWidth: 1.75 }) })] }, s.key)))), _jsx("div", { className: "crisp-tabletoolbar-sort-addrow", children: _jsxs("button", { ref: addSortRef, type: "button", className: "crisp-tabletoolbar-sort-add", onClick: addSort, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), addSortLabel] }) }), helpHref && (_jsx("div", { className: "crisp-tabletoolbar-sort-foot", children: _jsxs("a", { className: "crisp-tabletoolbar-sort-learn", href: helpHref, target: "_blank", rel: "noreferrer", children: [_jsx(CircleHelp, { size: 14, strokeWidth: 1.75 }), learnSortingLabel] }) }))] }))] })), showSort && (_jsx("span", { className: "crisp-tabletoolbar-divider", "aria-hidden": "true" })), _jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `crisp-tabletoolbar-tool crisp-tabletoolbar-tool--ghost${activeFilters ? " crisp-tabletoolbar-tool--active" : ""}`, onClick: () => {
652
+ setFieldPickerPath([]);
653
+ setOpenMenu(openMenu === "filter" ? null : "filter");
654
+ }, "aria-haspopup": "dialog", "aria-expanded": openMenu === "filter", "data-menu-trigger": true, children: [_jsx(ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), filterTriggerLabel
508
655
  ? filterTriggerLabel(activeFilters)
509
656
  : activeFilters
510
657
  ? `${filterLabel} · ${activeFilters}`
511
- : filterLabel] }), openMenu === "filter" && (_jsx("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": filterFieldLabel, children: filterFieldPicker(addFilter) }))] }), filters.map((cond) => {
512
- const col = filterColumns.find((c) => c.key === cond.key);
658
+ : filterLabel] }), openMenu === "filter" && (_jsx("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": filterFieldLabel, children: nestedFieldPicker(filterColumns, addFilter, filterFieldPlaceholder, filterFieldLabel) }))] }), filters.map((cond) => {
659
+ // #592: cond.key may be a dot-joined nested path
660
+ // ("team.twitterFollowerCount") — resolve the full path so the
661
+ // field segment can render its breadcrumb; `col` (the leaf) still
662
+ // drives the operator set and value editor exactly as before, for
663
+ // both a plain and a nested key.
664
+ const colPath = resolveColumnPath(filterColumns, cond.key);
665
+ const col = colPath.at(-1);
513
666
  const fieldOpen = openMenu === `ffield:${cond.id}`;
514
667
  const opOpen = openMenu === `fop:${cond.id}`;
515
668
  const valOpen = openMenu === `fval:${cond.id}`;
@@ -525,7 +678,10 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
525
678
  // which never renders a third box in either case.
526
679
  const showValueSeg = cond.op != null &&
527
680
  (opNeedsValue(cond.op) || opNeedsValues(cond.op));
528
- return (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("span", { className: "crisp-tabletoolbar-fcond", "data-complete": complete ? "" : undefined, children: [_jsxs("button", { type: "button", className: "crisp-tabletoolbar-fcond-seg crisp-tabletoolbar-fcond-seg--field", "data-menu-trigger": true, "aria-haspopup": "dialog", "aria-expanded": fieldOpen, onClick: () => setOpenMenu(fieldOpen ? null : `ffield:${cond.id}`), children: [col?.icon ? _jsx(col.icon, { size: 14, strokeWidth: 1.75 }) : null, col?.label] }), _jsx("button", { type: "button", className: "crisp-tabletoolbar-fcond-seg crisp-tabletoolbar-fcond-seg--op", "data-menu-trigger": true, "aria-haspopup": "menu", "aria-expanded": opOpen, onClick: () => setOpenMenu(opOpen ? null : `fop:${cond.id}`), children: cond.op
681
+ return (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("span", { className: "crisp-tabletoolbar-fcond", "data-complete": complete ? "" : undefined, children: [_jsxs("button", { type: "button", className: "crisp-tabletoolbar-fcond-seg crisp-tabletoolbar-fcond-seg--field", "data-menu-trigger": true, "aria-haspopup": "dialog", "aria-expanded": fieldOpen, onClick: () => {
682
+ setFieldPickerPath([]);
683
+ setOpenMenu(fieldOpen ? null : `ffield:${cond.id}`);
684
+ }, children: [col?.icon ? _jsx(col.icon, { size: 14, strokeWidth: 1.75 }) : null, colPath.length > 1 ? (_jsx("span", { className: "crisp-tabletoolbar-fcond-path", children: colPath.map((c, i) => (_jsxs(React.Fragment, { children: [i > 0 && (_jsx(RtlChevronRight, { size: 14, className: "crisp-tabletoolbar-fcond-path-sep" })), _jsx("span", { className: "crisp-tabletoolbar-fcond-path-seg", children: c.label })] }, c.key))) })) : (col?.label)] }), _jsx("button", { type: "button", className: "crisp-tabletoolbar-fcond-seg crisp-tabletoolbar-fcond-seg--op", "data-menu-trigger": true, "aria-haspopup": "menu", "aria-expanded": opOpen, onClick: () => setOpenMenu(opOpen ? null : `fop:${cond.id}`), children: cond.op
529
685
  ? (crispMessages[OP_LABELS[cond.op]] ?? OP_LABELS[cond.op])
530
686
  : selectConditionLabel }), showValueSeg && (_jsx("button", { type: "button", className: "crisp-tabletoolbar-fcond-seg crisp-tabletoolbar-fcond-seg--val", "data-menu-trigger": true, "aria-haspopup": "dialog", "aria-expanded": valOpen, onClick: () => setOpenMenu(valOpen ? null : `fval:${cond.id}`), children: cond.op === "isanyof"
531
687
  ? cond.values?.length
@@ -535,7 +691,7 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
535
691
  ? col?.type === "date"
536
692
  ? formatFilterDate(cond.value)
537
693
  : cond.value
538
- : filterValuePlaceholder })), _jsx("button", { type: "button", className: "crisp-tabletoolbar-fcond-seg crisp-tabletoolbar-fcond-seg--kebab", "data-menu-trigger": true, "aria-haspopup": "menu", "aria-expanded": kebabOpen, "aria-label": `${col?.label} ${filterOptionsLabel}`, onClick: () => setOpenMenu(kebabOpen ? null : `fkebab:${cond.id}`), children: _jsx(MoreVertical, { size: 13, strokeWidth: 2 }) })] }), kebabOpen && (_jsx("div", { ref: kebabMenuRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-menu--right crisp-tabletoolbar-menu--hdr", role: "menu", "aria-label": `${col?.label} ${filterOptionsLabel}`, onKeyDown: onKebabKeyDown, children: _jsxs("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item crisp-tabletoolbar-menu-item--danger", onClick: () => removeFilter(cond.id), children: [_jsx(Trash2, { size: 16, strokeWidth: 1.75 }), deleteFilterLabel] }) })), fieldOpen && (_jsx("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": filterFieldLabel, children: filterFieldPicker((key) => changeFilterField(cond.id, key)) })), opOpen && (_jsx("div", { ref: opMenuRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-menu--op", role: "menu", "aria-label": `${col?.label} ${crispMessages.condition ?? "condition"}`, onKeyDown: onOpKeyDown, children: opsFor(col).map((op) => (_jsxs("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", "data-on": cond.op === op ? "" : undefined, onClick: () => pickOp(cond.id, op), children: [crispMessages[OP_LABELS[op]] ?? OP_LABELS[op], cond.op === op ? (_jsx(Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))) })), valOpen && showValueSeg && (_jsx("div", { className: "crisp-tabletoolbar-menu crisp-tabletoolbar-menu--val", role: "dialog", "aria-label": `${col?.label} filter value`, children: cond.op === "isanyof" && col?.values ? (_jsxs(_Fragment, { children: [_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter values`,
694
+ : filterValuePlaceholder })), _jsx("button", { type: "button", className: "crisp-tabletoolbar-fcond-seg crisp-tabletoolbar-fcond-seg--kebab", "data-menu-trigger": true, "aria-haspopup": "menu", "aria-expanded": kebabOpen, "aria-label": `${col?.label} ${filterOptionsLabel}`, onClick: () => setOpenMenu(kebabOpen ? null : `fkebab:${cond.id}`), children: _jsx(MoreVertical, { size: 13, strokeWidth: 2 }) })] }), kebabOpen && (_jsx("div", { ref: kebabMenuRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-menu--right crisp-tabletoolbar-menu--hdr", role: "menu", "aria-label": `${col?.label} ${filterOptionsLabel}`, onKeyDown: onKebabKeyDown, children: _jsxs("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item crisp-tabletoolbar-menu-item--danger", onClick: () => removeFilter(cond.id), children: [_jsx(Trash2, { size: 16, strokeWidth: 1.75 }), deleteFilterLabel] }) })), fieldOpen && (_jsx("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": filterFieldLabel, children: nestedFieldPicker(filterColumns, (key) => changeFilterField(cond.id, key), filterFieldPlaceholder, filterFieldLabel) })), opOpen && (_jsx("div", { ref: opMenuRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-menu--op", role: "menu", "aria-label": `${col?.label} ${crispMessages.condition ?? "condition"}`, onKeyDown: onOpKeyDown, children: opsFor(col).map((op) => (_jsxs("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", "data-on": cond.op === op ? "" : undefined, onClick: () => pickOp(cond.id, op), children: [crispMessages[OP_LABELS[op]] ?? OP_LABELS[op], cond.op === op ? (_jsx(Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))) })), valOpen && showValueSeg && (_jsx("div", { className: "crisp-tabletoolbar-menu crisp-tabletoolbar-menu--val", role: "dialog", "aria-label": `${col?.label} filter value`, children: cond.op === "isanyof" && col?.values ? (_jsxs(_Fragment, { children: [_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter values`,
539
695
  // biome-ignore lint/a11y/noAutofocus: focus the search field
540
696
  autoFocus: true, value: anyOfQuery, onChange: (e) => setAnyOfQuery(e.target.value) }), col.values
541
697
  .filter((v) => v
@@ -555,7 +711,18 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
555
711
  values: col.values?.filter((x) => set.has(x)),
556
712
  });
557
713
  }, children: v }, v));
558
- })] })) : col?.type === "date" ? (_jsxs("div", { className: "crisp-tabletoolbar-datepop", children: [_jsx(DatePicker, { ...{
714
+ })] })) : col?.type === "select" &&
715
+ col?.values &&
716
+ (cond.op === "is" || cond.op === "isnot") ? (_jsxs(_Fragment, { children: [_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter values`,
717
+ // biome-ignore lint/a11y/noAutofocus: focus the search field
718
+ autoFocus: true, value: anyOfQuery, onChange: (e) => setAnyOfQuery(e.target.value) }), col.values
719
+ .filter((v) => v
720
+ .toLowerCase()
721
+ .includes(anyOfQuery.trim().toLowerCase()))
722
+ .map((v) => (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", "data-on": cond.value === v ? "" : undefined, onClick: () => {
723
+ patchFilter(cond.id, { value: v });
724
+ setOpenMenu(null);
725
+ }, children: [v, cond.value === v ? (_jsx(Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, v)))] })) : col?.type === "date" ? (_jsxs("div", { className: "crisp-tabletoolbar-datepop", children: [_jsx(DatePicker, { ...{
559
726
  selectionMode: "single",
560
727
  value: isoToDate(cond.value)
561
728
  ? toDatePickerDate(isoToDate(cond.value))
@@ -586,17 +753,12 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
586
753
  }
587
754
  else {
588
755
  setAddingAttribute(false);
589
- setAddAttrQuery("");
756
+ setFieldPickerPath([]);
757
+ setFilterQuery("");
590
758
  setOpenMenu("viewsettings");
591
759
  }
592
760
  }, "aria-haspopup": "dialog", "aria-expanded": openMenu === "viewsettings", "data-menu-trigger": true, children: [_jsx(SlidersHorizontal, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), viewSettingsLabel] }), openMenu === "viewsettings" &&
593
- (addingAttribute ? (_jsxs("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": addAttributeToViewLabel, children: [_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: searchAttributesPlaceholder, "aria-label": searchAttributesPlaceholder,
594
- // biome-ignore lint/a11y/noAutofocus: focus the search box on open, same as Filter's field picker
595
- autoFocus: true, value: addAttrQuery, onChange: (e) => setAddAttrQuery(e.target.value) }), hiddenAttrs
596
- .filter((col) => col.label
597
- .toLowerCase()
598
- .includes(addAttrQuery.trim().toLowerCase()))
599
- .map((col) => (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => showAttr(col.key), children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), col.label] }, col.key)))] })) : (_jsxs("div", { className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": viewSettingsLabel, children: [shownAttrs.map((col, i) => (_jsxs("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
761
+ (addingAttribute ? (_jsx("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": addAttributeToViewLabel, children: nestedFieldPicker(hiddenAttrs, showAttr, searchAttributesPlaceholder, searchAttributesPlaceholder) })) : (_jsxs("div", { className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": viewSettingsLabel, children: [shownAttrs.map((col, i) => (_jsxs("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
600
762
  if (dragAttr !== null)
601
763
  e.preventDefault();
602
764
  }, onDrop: () => {
@@ -604,7 +766,24 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
604
766
  moveAttr(dragAttr, i);
605
767
  setDragAttr(null);
606
768
  }, children: [_jsx("button", { type: "button", className: "crisp-tabletoolbar-sort-grip", "aria-label": reorderAttributeLabel, draggable: true, onDragStart: () => setDragAttr(i), onDragEnd: () => setDragAttr(null), children: _jsx(GripVertical, { size: 15, strokeWidth: 1.75 }) }), _jsxs("span", { className: "crisp-tabletoolbar-sort-optlabel crisp-tabletoolbar-sort-field", children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : null, col.label] }), _jsx("button", { type: "button", className: "crisp-tabletoolbar-sort-remove", onClick: () => hideAttr(col.key), "aria-label": hideAttributeLabel?.(col.label) ??
607
- `${crispMessages.Hide ?? "Hide"} ${col.label} ${crispMessages["from view"] ?? "from view"}`, children: _jsx(X, { size: 15, strokeWidth: 1.75 }) })] }, col.key))), _jsx("div", { className: "crisp-tabletoolbar-sort-addrow", children: _jsxs("button", { type: "button", className: "crisp-tabletoolbar-sort-add", onClick: () => setAddingAttribute(true), children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), addAttributeToViewLabel] }) })] })))] })), _jsx("span", { className: "crisp-tabletoolbar-spacer" }), count != null && (_jsx("span", { className: "crisp-tabletoolbar-count", children: count })), children] }) }));
769
+ `${crispMessages.Hide ?? "Hide"} ${col.label} ${crispMessages["from view"] ?? "from view"}`, children: _jsx(X, { size: 15, strokeWidth: 1.75 }) })] }, col.key))), _jsx("div", { className: "crisp-tabletoolbar-sort-addrow", children: _jsxs("button", { type: "button", className: "crisp-tabletoolbar-sort-add", onClick: () => {
770
+ setAddingAttribute(true);
771
+ setFieldPickerPath([]);
772
+ setFilterQuery("");
773
+ }, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), addAttributeToViewLabel] }) })] })))] })), (onImportCsv ||
774
+ onExportCsv ||
775
+ onExportExcel ||
776
+ importExportHelpHref) && (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: "crisp-tabletoolbar-tool crisp-tabletoolbar-tool--raised", onClick: () => setOpenMenu(openMenu === "importexport" ? null : "importexport"), "aria-haspopup": "menu", "aria-expanded": openMenu === "importexport", "data-menu-trigger": true, children: [_jsx(Upload, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), importExportLabel, _jsx(ChevronDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" })] }), openMenu === "importexport" && (_jsxs("div", { ref: importExportMenuRef, className: "crisp-tabletoolbar-menu", role: "menu", "aria-label": importExportLabel, onKeyDown: onImportExportKeyDown, children: [onImportCsv && (_jsxs("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", onClick: () => {
777
+ onImportCsv();
778
+ setOpenMenu(null);
779
+ }, children: [_jsx(Upload, { size: 15, strokeWidth: 1.75 }), importCsvLabel] })), onImportCsv && (onExportCsv || onExportExcel) && (_jsx("hr", { className: "crisp-tabletoolbar-menu-sep" })), onExportCsv && (_jsxs("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", onClick: () => {
780
+ onExportCsv();
781
+ setOpenMenu(null);
782
+ }, children: [_jsx(Download, { size: 15, strokeWidth: 1.75 }), exportCsvLabel] })), onExportExcel && (_jsxs("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", onClick: () => {
783
+ onExportExcel();
784
+ setOpenMenu(null);
785
+ }, children: [_jsx(Download, { size: 15, strokeWidth: 1.75 }), exportExcelLabel] })), importExportHelpHref &&
786
+ (onImportCsv || onExportCsv || onExportExcel) && (_jsx("hr", { className: "crisp-tabletoolbar-menu-sep" })), importExportHelpHref && (_jsxs("a", { className: "crisp-tabletoolbar-menu-item", role: "menuitem", href: importExportHelpHref, target: "_blank", rel: "noreferrer", children: [_jsx(CircleHelp, { size: 15, strokeWidth: 1.75 }), learnImportExportLabel] }))] }))] })), _jsx("span", { className: "crisp-tabletoolbar-spacer" }), count != null && (_jsx("span", { className: "crisp-tabletoolbar-count", children: count })), children] }) }));
608
787
  }
609
788
  /**
610
789
  * The Sort/Filter chip's own chrome (`.crisp-tabletoolbar-tool`), standalone
@@ -1,5 +1,5 @@
1
- import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
- import { contract as c } from "../../tokens/index.js";
1
+ import { defineRecipe, resolveRecipe, rtlFlipCss } from "../../core/index.js";
2
+ import { contract as c, elevation as e } from "../../tokens/index.js";
3
3
  import { toolbarChromeBody, toolbarChromeHead } from "./tabletoolbar.chrome.js";
4
4
  // The filter condition chip, TableToolbar-only (crisp/#462 follow-up,
5
5
  // re-measured live 2026-09-25). Deliberately NOT added to the shared
@@ -33,17 +33,62 @@ const filterChipChrome = `
33
33
  & .crisp-tabletoolbar-fcond-seg svg { flex-shrink: 0; }
34
34
  & .crisp-tabletoolbar-fcond[data-complete] .crisp-tabletoolbar-fcond-seg--field,
35
35
  & .crisp-tabletoolbar-fcond[data-complete] .crisp-tabletoolbar-fcond-seg--val { background: ${c.bgSurface}; }
36
- & .crisp-tabletoolbar-fcond-seg--field { border-radius: ${c.radius.md} 0 0 ${c.radius.md}; font-weight: ${c.weight.medium}; }
36
+ & .crisp-tabletoolbar-fcond-seg--field { border-radius: ${c.radius.md} 0 0 ${c.radius.md}; font-weight: ${c.weight.medium}; max-width: 200px; }
37
37
  & .crisp-tabletoolbar-fcond-seg--field svg { color: ${c.fgMuted}; }
38
+ /* #592: a nested pick's field segment ("Team › Twitter follower
39
+ count") — measured live: BOTH path segments the same colour (chip
40
+ context only; the picker's own menu rows keep the parent muted, see
41
+ tabletoolbar.chrome.ts), each independently truncated. Attio's own
42
+ layout gives the leaf segment priority (it stays fully readable
43
+ longer, the parent truncates first) via a two-part min-content
44
+ measurement this reproduces qualitatively with a flex-shrink
45
+ differential rather than byte-for-byte (the exact px split Attio
46
+ computes is content-length-dependent, not a fixed value) — not
47
+ independently re-verified at other path-segment lengths. */
48
+ & .crisp-tabletoolbar-fcond-path { display: inline-flex; align-items: center; gap: 2px; min-width: 0; max-width: 178px; }
49
+ & .crisp-tabletoolbar-fcond-path-seg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1 1 auto; }
50
+ & .crisp-tabletoolbar-fcond-path-seg:first-child { flex: 0 1 auto; }
51
+ & .crisp-tabletoolbar-fcond-path-sep { flex-shrink: 0; color: inherit; }
38
52
  & .crisp-tabletoolbar-fcond-seg--op { background: ${c.bgSurface}; color: ${c.fgMuted}; }
39
53
  & .crisp-tabletoolbar-fcond-seg--val { font-weight: ${c.weight.medium}; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
40
54
  & .crisp-tabletoolbar-fcond-seg--kebab { background: ${c.bgSurface}; justify-content: center; width: 28px; padding: 0; border-radius: 0 ${c.radius.md} ${c.radius.md} 0; color: ${c.fgMuted}; }
41
55
  & .crisp-tabletoolbar-fcond-seg--kebab:hover { color: ${c.fgPrimary}; }
56
+ /* #754: the sort row's own field trigger, replacing a plain Select
57
+ with the same nested-drill-down popover Filter's field picker
58
+ already has — reuses Select's own already-measured trigger chrome
59
+ verbatim (its class is scoped under Select's own root class, so it
60
+ can't be reused as a bare class name outside an actual Select tree;
61
+ these values are copied, not re-measured). */
62
+ & .crisp-tabletoolbar-sort-field-picker { position: relative; display: inline-flex; flex: 1; min-width: 0; }
63
+ & .crisp-tabletoolbar-sort-field-trigger { box-sizing: border-box; display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; height: ${c.size.menuRow}; width: 100%; min-width: 0; margin: 0; padding: 0 ${c.space["1.5"]} 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 7%, transparent); font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; cursor: pointer; transition: background-color .12s ease, box-shadow .12s ease; }
64
+ & .crisp-tabletoolbar-sort-field-trigger:hover { background: ${c.bgRaised}; }
65
+ & .crisp-tabletoolbar-sort-field-trigger[aria-expanded="true"],
66
+ & .crisp-tabletoolbar-sort-field-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; background: ${c.bgSurface}; }
67
+ & .crisp-tabletoolbar-sort-field-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; }
68
+ & .crisp-tabletoolbar-sort-field-picker .crisp-tabletoolbar-menu { min-width: 200px; }
42
69
  & .crisp-tabletoolbar-menu--val { min-width: 134px; }
43
70
  & .crisp-tabletoolbar-datepop { width: 300px; }
44
71
  & .crisp-tabletoolbar-datepop-presets { display: flex; flex-wrap: wrap; gap: ${c.space["1"]}; margin-top: ${c.space["2"]}; padding: 0 ${c.space["1"]}; }
45
72
  & .crisp-tabletoolbar-datepop-preset { height: ${c.size.chip}; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.chip}; background: ${c.bgRaised}; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.sm}; cursor: pointer; }
46
73
  & .crisp-tabletoolbar-datepop-preset:hover { background: color-mix(in srgb, ${c.fgPrimary} 8%, transparent); }`;
74
+ // #595: the Import/Export trigger — measured live, `app.attio.com`'s own
75
+ // view bar: `28px` tall, `radius.md` (8px), background `bgSurfaceMuted`
76
+ // (`rgb(246,247,247)`, exact match) and `elevation.field`'s exact 3-layer
77
+ // shadow (`inset 0 0 0 1px transparent, 0 0 2px rgba(28,40,64,.18), 0 1px 3px
78
+ // rgba(0,0,0,.04)`) — a "raised chrome button" distinct from Sort/Filter/View
79
+ // settings' plain ringed `.tool` (no background, no elevation shadow), and
80
+ // the only trigger of the four with a trailing chevron (2 icons total,
81
+ // confirmed live). Overrides `.tool`'s own base background/shadow by cascade
82
+ // order (this block is appended after `toolbarChromeBody`, which defines
83
+ // them), the same way `.tool--ghost` already does. `border`/`font-family`/
84
+ // `cursor` are repeated here too, even though `.tool`'s own base rule (in
85
+ // the shared `tabletoolbar.chrome.ts`) already sets them on every button
86
+ // carrying this modifier — `spec-check.mjs`'s button-reset audit only reads
87
+ // each component's own `<name>.recipe.ts` file verbatim, so a reset living
88
+ // behind `chrome.ts`'s function call is invisible to it; harmless repeats,
89
+ // not a real gap.
90
+ const importExportChrome = `
91
+ & .crisp-tabletoolbar-tool--raised { border: none; background: ${c.bgSurfaceMuted}; box-shadow: ${e.field}; font-family: inherit; cursor: pointer; }`;
47
92
  // The table toolbar, measured: 28px controls, the Sort/Filter buttons and
48
93
  // the menus they open, sort-condition rows, and the filter chips. The chrome
49
94
  // itself lives in ./tabletoolbar.chrome.ts because RecordsTable renders the
@@ -61,6 +106,8 @@ const config = defineRecipe({
61
106
  ${toolbarChromeHead("tabletoolbar")}
62
107
  ${toolbarChromeBody("tabletoolbar")}
63
108
  ${filterChipChrome}
109
+ ${importExportChrome}
110
+ ${rtlFlipCss}
64
111
  `,
65
112
  });
66
113
  export const tabletoolbarRecipe = resolveRecipe(config);
@@ -10,13 +10,23 @@ import { contract as c } from "../../tokens/index.js";
10
10
  // the same reason (see its own doc).
11
11
  const spec = defineSpec({
12
12
  name: "text",
13
- // `display: inline` -- a span's own default, stated rather than left
14
- // implicit: an EMPTY base compiles to an empty string, and
15
- // `core/resolve.ts`'s `if (base) classes.push(prefixedName)` treats that
16
- // falsy string as "no base rule", which drops the `crisp-text` class
17
- // itself, silently, from every instance (caught by this file's own
18
- // tests going red on that exact class).
19
- base: { display: "inline" },
13
+ // Needs a real, always-present declaration so `resolveRecipe` emits the
14
+ // `crisp-text` class at all -- an EMPTY base compiles to an empty string,
15
+ // and `core/resolve.ts`'s `if (base) classes.push(prefixedName)` treats
16
+ // that falsy string as "no base rule", dropping the class itself,
17
+ // silently, from every instance (this file's own tests catch that).
18
+ // `display: "inline"` filled that role until a real bug: `asChild`
19
+ // (Primitive.span -> Radix Slot) merges this class onto WHATEVER element
20
+ // the caller passes, not a `<span>` -- a `<div>`/`<p>`/`<li>` child's own
21
+ // block default lost to `.crisp-text`'s class-specificity `display:
22
+ // inline`, silently collapsing its layout (internal/grc's own Settings.tsx
23
+ // ran two rows onto one line this way). `vertical-align: baseline` fills
24
+ // the same "always-present, never empty" role without that risk: it's a
25
+ // span's own already-inherited default (so nothing changes for the plain,
26
+ // non-asChild case `Primitive.span` renders), and it has NO effect at all
27
+ // on a block-level box, so an `asChild` div/p/li keeps its own display
28
+ // exactly as its own tag already gives it.
29
+ base: { verticalAlign: "baseline" },
20
30
  variants: {
21
31
  tone: {
22
32
  default: { color: c.fgPrimary },