@crisp-ui-kit/crisp 0.54.8 → 0.54.9

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 (54) 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 +541 -51
  6. package/dist/cjs/components/datatable/datatable.recipe.js +61 -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 +148 -15
  10. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +35 -1
  11. package/dist/cjs/product/board/board.js +62 -28
  12. package/dist/cjs/product/board/board.recipe.js +42 -2
  13. package/dist/cjs/product/comments/comments.js +11 -2
  14. package/dist/cjs/product/comments/comments.recipe.js +14 -4
  15. package/dist/cjs/product/recordstable/recordstable.js +1 -17
  16. package/dist/cjs/product/savedviews/savedviews.js +55 -24
  17. package/dist/cjs/product/savedviews/savedviews.recipe.js +27 -0
  18. package/dist/comments.css +14 -4
  19. package/dist/comments.layered.css +14 -4
  20. package/dist/contextmenu.css +24 -0
  21. package/dist/contextmenu.layered.css +24 -0
  22. package/dist/datatable.css +103 -77
  23. package/dist/datatable.layered.css +103 -77
  24. package/dist/esm/components/contextmenu/contextmenu.js +2 -2
  25. package/dist/esm/components/contextmenu/contextmenu.recipe.js +24 -0
  26. package/dist/esm/components/datatable/datatable.js +541 -52
  27. package/dist/esm/components/datatable/datatable.recipe.js +62 -4
  28. package/dist/esm/components/dialog/dialog.js +65 -1
  29. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +37 -2
  30. package/dist/esm/components/tabletoolbar/tabletoolbar.js +149 -16
  31. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +37 -3
  32. package/dist/esm/product/board/board.js +63 -29
  33. package/dist/esm/product/board/board.recipe.js +42 -2
  34. package/dist/esm/product/comments/comments.js +12 -3
  35. package/dist/esm/product/comments/comments.recipe.js +14 -4
  36. package/dist/esm/product/recordstable/recordstable.js +2 -18
  37. package/dist/esm/product/savedviews/savedviews.js +57 -26
  38. package/dist/esm/product/savedviews/savedviews.recipe.js +27 -0
  39. package/dist/recordstable.css +33 -31
  40. package/dist/recordstable.layered.css +33 -31
  41. package/dist/savedviews.css +27 -0
  42. package/dist/savedviews.layered.css +27 -0
  43. package/dist/styles.css +266 -124
  44. package/dist/styles.layered.css +266 -124
  45. package/dist/tabletoolbar.css +33 -4
  46. package/dist/tabletoolbar.layered.css +33 -4
  47. package/dist/types/components/contextmenu/contextmenu.d.ts +8 -1
  48. package/dist/types/components/datatable/datatable.d.ts +90 -2
  49. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +72 -2
  50. package/dist/types/product/board/board.d.ts +48 -2
  51. package/dist/types/product/comments/comments.d.ts +6 -1
  52. package/dist/types/product/recordstable/recordstable.d.ts +0 -1
  53. package/dist/types/product/savedviews/savedviews.d.ts +45 -8
  54. package/package.json +1 -1
@@ -22,6 +22,30 @@ const config = (0, core_1.defineRecipe)({
22
22
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
23
23
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
24
24
  }
25
+ /* crisp/#645: visually-hidden-but-focusable, not visually-hidden-and-
26
+ inert like .crisp-datatable-live above -- same clip-rect shape,
27
+ this one just needs to actually hold DOM focus so a native
28
+ copy/paste event has something to fire on (see its own comment
29
+ in datatable.tsx). resize: none keeps a stray native textarea handle
30
+ from ever being drawable, even though it's clipped to 1px anyway. */
31
+ & .crisp-datatable-clipboard-proxy {
32
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
33
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
34
+ resize: none;
35
+ }
36
+ /* single active cell (crisp/#645 follow-up -- this had no styling of its
37
+ own at all before, a gap against Golden Rule 0: RecordsTable's own
38
+ measured value, .crisp-recordstable-td[data-selected], brought
39
+ across unchanged now that a visible anchor actually matters for the
40
+ Shift+Click range below). */
41
+ & td[data-selected] { outline: 2px solid ${tokens_1.contract.bgBrandSolid}; outline-offset: -2px; border-radius: ${tokens_1.contract.radius.sm}; z-index: 3; }
42
+ /* crisp/#645, G-101: a Shift+Click range -- reuses the row-selection
43
+ tint formula already used twice below (tbody tr[data-selected] td)
44
+ for the same reason a selected group of rows gets one: a selected
45
+ GROUP of cells is the same kind of state, just cell-scoped instead of
46
+ row-scoped. Not independently re-measured against Attio's own range
47
+ highlight (out of scope this slice -- flagged on crisp#645). */
48
+ & td[data-in-range] { background: color-mix(in srgb, ${tokens_1.contract.bgBrandSolid} 10%, ${tokens_1.contract.bgSurface}); }
25
49
  /* The header cell has no padding of its own -- the button owns the
26
50
  full cell so the whole header is clickable (its own inset is 12px).
27
51
  Mirrors the summary-row fix.
@@ -180,11 +204,44 @@ const config = (0, core_1.defineRecipe)({
180
204
  & .crisp-datatable-resize { position: absolute; top: 0; inset-inline-end: -3px; width: 9px; height: 100%; cursor: col-resize; z-index: 6; touch-action: none; }
181
205
  & .crisp-datatable-resize::after { content: ""; position: absolute; top: 0; inset-inline-start: 3px; width: 2px; height: 100%; background: ${tokens_1.contract.bgBrandSolid}; opacity: 0; transition: opacity .12s; }
182
206
  & .crisp-datatable-resize:hover::after, & .crisp-datatable-resize[data-resizing]::after { opacity: 1; }
207
+ /* crisp/#731 -- column drag-to-reorder. Measured live against Attio's
208
+ own grid (the issue's own comment): the dragged header keeps full
209
+ opacity and the plain \`pointer\` cursor throughout (Attio draws no
210
+ ghost and no "grabbing" cursor of its own), so this adds nothing to
211
+ \`[data-dragging]\` itself -- the ONLY new paint is the drop-indicator
212
+ line, a 3px bar in the same measured brand blue the resize handle's
213
+ own hover state (above) already uses, positioned on whichever edge of
214
+ the hovered header the pointer is closer to. */
215
+ & th[data-dragging] { cursor: pointer; }
216
+ & th[data-drop-before]::before, & th[data-drop-after]::after {
217
+ content: ""; position: absolute; top: 0; width: 3px; height: 100%;
218
+ background: ${tokens_1.contract.bgBrandSolid}; z-index: 6; pointer-events: none;
219
+ }
220
+ & th[data-drop-before]::before { inset-inline-start: -1.5px; }
221
+ & th[data-drop-after]::after { inset-inline-end: -1.5px; }
183
222
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
184
223
  & .crisp-datatable-entity { display: flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; min-width: 0; color: ${tokens_1.contract.fgPrimary}; font-weight: ${tokens_1.contract.weight.medium}; }
185
- & .crisp-datatable-entity-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
186
- & .crisp-datatable-entity:hover .crisp-datatable-entity-name { text-decoration: underline; }
224
+ /* crisp/#601, G-050: measured LIVE (getComputedStyle on the name text,
225
+ plain hover vs rest) that Attio's underline is unconditional, not a
226
+ hover reveal -- identical at rest and on hover: text-decoration-color
227
+ is rgba(0,0,0,0.09), a 9% tint, not the name's own full-contrast text
228
+ colour. The previous :hover-gated, full-contrast rule here was itself
229
+ unmeasured and wrong in two ways at once. */
230
+ & .crisp-datatable-entity-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: underline; text-decoration-color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 9%, transparent); }
187
231
  & button.crisp-datatable-entity-name--link { margin: 0; padding: 0; border: none; background: none; font: inherit; color: inherit; letter-spacing: inherit; text-align: start; cursor: pointer; max-width: 100%; }
232
+ /* crisp/#601, G-048: two 20px ghost icons, right-aligned in the primary
233
+ column, revealed on ROW hover (the issue's own title says "row
234
+ hover", and a live check confirmed hovering well past the name --
235
+ not just the icons' own hit target -- still reveals them) or
236
+ keyboard focus. Measured live: border-radius 6px, icon colour
237
+ rgba(0,0,0,0.55) at rest, darkening to the full text colour with a
238
+ rgba(0,0,0,0.04) background on the icon's OWN hover -- both
239
+ expressed as color-mix against fgPrimary so they stay correct in
240
+ dark mode instead of a literal black that would invert. */
241
+ & .crisp-datatable-entity-actions { display: flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; margin-inline-start: auto; flex-shrink: 0; opacity: 0; }
242
+ & tr:hover .crisp-datatable-entity-actions, & .crisp-datatable-entity-actions:focus-within { opacity: 1; }
243
+ & .crisp-datatable-entity-action { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin: 0; padding: 0; border: none; border-radius: ${tokens_1.contract.radius.sm}; background: none; font-family: inherit; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 55%, transparent); cursor: pointer; flex-shrink: 0; }
244
+ & .crisp-datatable-entity-action:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); color: ${tokens_1.contract.fgPrimary}; }
188
245
  & .crisp-datatable-avatar { width: ${tokens_1.contract.size.icon}; height: ${tokens_1.contract.size.icon}; border-radius: ${tokens_1.contract.radius.sm}; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; line-height: 1; font-weight: ${tokens_1.contract.weight.semibold}; text-transform: uppercase; color: ${tokens_1.contract.fgOnBrand}; background: ${tokens_1.contract.bgBrandSolid}; }
189
246
  & .crisp-datatable-avatar[data-tone="success"] { background: ${tokens_1.contract.bgSuccess}; } & .crisp-datatable-avatar[data-tone="warning"] { background: ${tokens_1.contract.bgWarning}; } & .crisp-datatable-avatar[data-tone="danger"] { background: ${tokens_1.contract.bgDanger}; } & .crisp-datatable-avatar[data-tone="purple"] { background: ${tokens_1.contract.bgPurple}; } & .crisp-datatable-avatar[data-tone="cyan"] { background: ${tokens_1.contract.bgCyan}; } & .crisp-datatable-avatar[data-tone="orange"] { background: ${tokens_1.contract.bgOrange}; }
190
247
  & .crisp-datatable-avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
@@ -216,7 +273,8 @@ const config = (0, core_1.defineRecipe)({
216
273
  & .crisp-datatable-col-grip { width: 20px; padding: 0; text-align: center; }
217
274
  & .crisp-datatable-grip { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 100%; margin: 0; border: none; border-radius: 0; background: none; font-family: inherit; color: ${tokens_1.contract.fgMuted}; cursor: grab; }
218
275
  & .crisp-datatable-grip:active { cursor: grabbing; }
219
- & tbody tr[data-drop-target] td, & tbody tr[data-drop-target] th { box-shadow: inset 0 2px 0 0 ${tokens_1.contract.bgBrandSolid}; transition: box-shadow .12s; }`,
276
+ & tbody tr[data-drop-target] td, & tbody tr[data-drop-target] th { box-shadow: inset 0 2px 0 0 ${tokens_1.contract.bgBrandSolid}; transition: box-shadow .12s; }
277
+ ${core_1.rtlFlipCss}`,
220
278
  variants: {},
221
279
  defaultVariants: {},
222
280
  compoundVariants: [],
@@ -63,10 +63,74 @@ function useDialogInstanceId() {
63
63
  function Dialog({ open, onOpenChange, title, icon, width = 640, variant = "panel", description, "aria-label": ariaLabel, closeLabel: closeLabelProp, children, className, style, "aria-describedby": describedBy, ...rest }) {
64
64
  const closeLabel = (0, core_1.useCrispMessage)(closeLabelProp, "Close");
65
65
  const id = useDialogInstanceId();
66
+ const isAlert = variant === "alert";
66
67
  const service = (0, react_1.useMachine)(dialog.machine, {
67
68
  id,
68
69
  open,
69
- role: variant === "alert" ? "alertdialog" : "dialog",
70
+ role: isAlert ? "alertdialog" : "dialog",
71
+ // #704: a stacked (second) Dialog can get marked `aria-hidden="true"`
72
+ // on its own outer wrapper a frame after it opens. Traced into
73
+ // `@zag-js/dialog`'s `hideContentBelow` effect and `@zag-js/aria-hidden`'s
74
+ // own source: a modal Dialog's `hideContentBelow` calls
75
+ // `hideOthers([itself])` (deferred one `requestAnimationFrame`), which
76
+ // walks `document.body`'s children and `aria-hidden`s every one that
77
+ // ISN'T an ancestor of its own content. Each open `Dialog` schedules
78
+ // this independently, with no shared registry of who else is open —
79
+ // when the effect re-runs for the FIRST (panel) Dialog (Zag re-runs an
80
+ // effect whenever any prop it reads is considered changed, which a
81
+ // parent re-render can trigger even when the boolean VALUE didn't),
82
+ // its walk runs again with the SECOND (alert) Dialog now mounted as a
83
+ // sibling body child it has never seen before, and marks that whole
84
+ // sibling subtree — including the alert's own wrapper, the thing
85
+ // actually on top and interactive — as hidden. A real upstream
86
+ // limitation (no per-instance awareness of other open Dialogs), not
87
+ // something crisp can fix by calling the library differently.
88
+ //
89
+ // `hideContentBelow` is gated purely on `modal` (no separate flag), so
90
+ // `modal: false` for the alert variant skips it — `trapFocus`/
91
+ // `preventScroll` are passed explicitly right below so they stay on
92
+ // regardless (their own defaults are ALSO `= modal`, so leaving them
93
+ // out here would silently turn them off too).
94
+ //
95
+ // A DOM-level fix was tried first — correcting the wrongly-set
96
+ // attribute after the fact instead of touching `modal` at all, so
97
+ // every other zag-managed behavior would stay untouched by
98
+ // construction. It broke `formdialog.test.tsx`'s own
99
+ // "the confirm's own Cancel keeps the dirtied form open" test (a real
100
+ // regression, reproduced with the correction reduced to literally one
101
+ // `removeAttribute` call, and reproduced again unmodified on the prior
102
+ // WIP commit — that version was never actually run against the full
103
+ // suite): `waitFor(() => expect(queryByRole("alertdialog")).toBeNull())`
104
+ // hung for the full test timeout rather than the confirm's real,
105
+ // sub-100ms unmount, in a way that traces to `aria-hidden` specifically
106
+ // (swapping the correction for a harmless, differently-named attribute
107
+ // did not reproduce it) — a fragile interaction this DOM-mutation
108
+ // approach could not be made to reliably avoid.
109
+ //
110
+ // `modal: false` also turns off `@zag-js/dismissable`'s own
111
+ // `pointerBlocking` for the alert. Verified live (a real
112
+ // headed-then-headless Chromium session against a built `FormDialog`,
113
+ // not just reasoning from the source) that this does NOT regress
114
+ // outside-click safety in practice — but for a DIFFERENT reason than
115
+ // `pointerBlocking` itself: the thing actually keeping a background
116
+ // click from reaching anything is `@zag-js/remove-scroll`'s
117
+ // `preventBodyScroll`, gated on `preventScroll` (kept `true` above,
118
+ // independent of `modal`) — it sets `pointer-events: none` directly on
119
+ // `<body>` while any modal-ish Dialog is open, and `getContentProps()`
120
+ // re-enables `pointer-events: auto` only on each Dialog's OWN content
121
+ // element. Confirmed live: the alert's content had an explicit
122
+ // `pointer-events: auto`; its OWN backdrop/positioner did not, and
123
+ // inherited body's `none`; clicking anywhere in that dead zone (the
124
+ // alert's backdrop, or the panel's, now both non-interactive) dismissed
125
+ // neither dialog, because the click lands on nothing that listens for
126
+ // it — a stronger guarantee than "click closes the wrong layer", not
127
+ // just an absence of that one failure mode. `pointerBlocking` is a
128
+ // second, redundant layer over the same problem `preventScroll` already
129
+ // solves here; the alert's own `closeOnInteractOutside` is already
130
+ // `false` for any `alertdialog` role regardless of `modal`.
131
+ modal: !isAlert,
132
+ trapFocus: true,
133
+ preventScroll: true,
70
134
  onOpenChange(details) {
71
135
  onOpenChange(details.open);
72
136
  },
@@ -34,6 +34,19 @@ const tokens_1 = require("../../tokens");
34
34
  // content width: a floor sized for the text set overshoots the other two.
35
35
  // Dropped to `min-width: 0` so every op set sizes to its own content, the
36
36
  // same as any other menu here without an explicit floor.
37
+ //
38
+ // `.menu-item` also picked up `text-decoration: none` for #595's Import/
39
+ // Export menu — Attio's own "Import CSV" and "Learn about imports and
40
+ // exports" rows are real `<a>` tags (the middle two, pure JS actions, are
41
+ // plain `<div role="menuitem">`), and an anchor needs the underline reset
42
+ // every button-based `.menu-item` never needed. No-op for every existing
43
+ // button consumer of this class. Same menu's 2 dividers use `.menu-sep` on
44
+ // a real `<hr>` (implicit `role="separator"`, no extra ARIA plumbing needed)
45
+ // rather than a `<div role="separator">` — biome's a11y rules want a
46
+ // separator either genuinely interactive (`tabIndex`, `aria-valuenow`, a
47
+ // resizable-splitter kind of thing this isn't) or the semantic element
48
+ // instead; `.menu-sep` picked up `border: none` for it, a no-op for the
49
+ // existing plain-`<div>` consumers of this class.
37
50
  const toolbarChromeHead = (p) => ` & .${namespace_1.CLASS_PREFIX}${p}-toolbar { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; padding: 0 ${tokens_1.contract.space["3"]} ${tokens_1.contract.space["2"]}; }
38
51
  & .${namespace_1.CLASS_PREFIX}${p}-toolbar[data-inline] { padding-bottom: 0; }
39
52
  & .${namespace_1.CLASS_PREFIX}${p}-search { display: flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; height: ${tokens_1.contract.size.row}; padding: 0 ${tokens_1.contract.space["2.5"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgSurface}; box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderSubtle}; color: ${tokens_1.contract.fgMuted}; }
@@ -73,7 +86,7 @@ const toolbarChromeBody = (p) => ` & .${namespace_1.CLASS_PREFIX}${p}-menu-he
73
86
  & .${namespace_1.CLASS_PREFIX}${p}-sort-learn { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; font-size: ${tokens_1.contract.text.lg}; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); text-decoration: none; }
74
87
  & .${namespace_1.CLASS_PREFIX}${p}-sort-learn svg { flex-shrink: 0; }
75
88
  & .${namespace_1.CLASS_PREFIX}${p}-sort-learn:hover { color: ${tokens_1.contract.fgPrimary}; }
76
- & .${namespace_1.CLASS_PREFIX}${p}-menu-item { display: flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; width: 100%; height: ${tokens_1.contract.size.menuRow}; margin: 0; padding: 0 ${tokens_1.contract.space["2"]}; border: none; background: none; border-radius: ${tokens_1.contract.radius.md}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; line-height: 20px; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgPrimary}; text-align: left; cursor: pointer; }
89
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-item { display: flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; width: 100%; height: ${tokens_1.contract.size.menuRow}; margin: 0; padding: 0 ${tokens_1.contract.space["2"]}; border: none; background: none; border-radius: ${tokens_1.contract.radius.md}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; line-height: 20px; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgPrimary}; text-align: left; text-decoration: none; cursor: pointer; }
77
90
  & .${namespace_1.CLASS_PREFIX}${p}-menu-item:hover { background: ${tokens_1.contract.bgRaised}; }
78
91
  & .${namespace_1.CLASS_PREFIX}${p}-menu-item:disabled { opacity: 0.4; cursor: default; }
79
92
  & .${namespace_1.CLASS_PREFIX}${p}-menu-item:disabled:hover { background: none; }
@@ -81,7 +94,29 @@ const toolbarChromeBody = (p) => ` & .${namespace_1.CLASS_PREFIX}${p}-menu-he
81
94
  & .${namespace_1.CLASS_PREFIX}${p}-menu-item[data-on] svg { color: ${tokens_1.contract.bgBrandSolid}; }
82
95
  & .${namespace_1.CLASS_PREFIX}${p}-menu-item .${namespace_1.CLASS_PREFIX}${p}-menu-check { margin-left: auto; }
83
96
  & .${namespace_1.CLASS_PREFIX}${p}-menu-input { height: ${tokens_1.contract.size.row}; margin: ${tokens_1.contract.space["0.5"]}; padding: 0 ${tokens_1.contract.space["2"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderSubtle}; background: ${tokens_1.contract.bgSurface}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; outline: none; }
84
- & .${namespace_1.CLASS_PREFIX}${p}-menu-sep { height: 1px; background: ${tokens_1.contract.borderSubtle}; margin: ${tokens_1.contract.space["0.5"]} 0; }
97
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-sep { height: 1px; border: none; background: ${tokens_1.contract.borderSubtle}; margin: ${tokens_1.contract.space["0.5"]} 0; }
98
+ /* #592: the Filter field picker's nested-scope header (back control +
99
+ search, once drilled into a nestedColumns column) and a search
100
+ result's own breadcrumb row ("Team › Twitter follower count",
101
+ surfaced by the SAME query matching a nested column). menu-back
102
+ reuses the established 28px icon-only control pattern this file
103
+ already uses twice (sort-remove, the kebab segment) rather than
104
+ an independent measurement of Attio's own back arrow. The breadcrumb
105
+ colours ARE measured live: the parent segment muted at the same
106
+ exact color-mix 63% sort-learn above already uses, the
107
+ separator chevron and leaf segment both full fgPrimary (the
108
+ generic menu-item svg rule below defaults every icon to
109
+ fgMuted, so the separator needs its own override to read
110
+ correctly here). */
111
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-nesthead { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; margin: ${tokens_1.contract.space["0.5"]}; }
112
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-nesthead .${namespace_1.CLASS_PREFIX}${p}-menu-input { flex: 1; margin: 0; }
113
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-back { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; padding: 0; border: none; border-radius: ${tokens_1.contract.radius.md}; background: none; color: ${tokens_1.contract.fgMuted}; cursor: pointer; }
114
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-back:hover { background: ${tokens_1.contract.bgRaised}; color: ${tokens_1.contract.fgPrimary}; }
115
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-item--expand { justify-content: space-between; }
116
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-item-path { display: inline-flex; align-items: center; gap: 2px; min-width: 0; overflow: hidden; }
117
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-item-path-parent { flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; max-width: 100px; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); }
118
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-item-path-sep { flex-shrink: 0; color: ${tokens_1.contract.fgPrimary}; }
119
+ & .${namespace_1.CLASS_PREFIX}${p}-menu-item-path > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
85
120
  & .${namespace_1.CLASS_PREFIX}${p}-menu--up { top: auto; bottom: calc(100% + ${tokens_1.contract.space["1"]}); transform-origin: bottom; }
86
121
  & .${namespace_1.CLASS_PREFIX}${p}-menu--right { left: auto; right: 0; }
87
122
  & .${namespace_1.CLASS_PREFIX}${p}-menu--fixed { position: fixed; top: auto; left: auto; z-index: 40; }
@@ -45,6 +45,14 @@ const datepicker_1 = require("../datepicker");
45
45
  const menu_1 = require("../menu");
46
46
  const select_1 = require("../select");
47
47
  const tabletoolbar_recipe_1 = require("./tabletoolbar.recipe");
48
+ // #592: shared by every direction-bearing ChevronRight usage below (the
49
+ // field picker's own disclosure chevron, a flattened search result's
50
+ // breadcrumb separator, and a filter chip's own breadcrumb separator) so
51
+ // the RTL mirror attribute lives in exactly one JSX tag — build/spec-check
52
+ // .mjs's icon sweep counts that attribute per icon NAME across the whole
53
+ // file, not per call site, and three independently attributed tags for
54
+ // the same name over-counts against that (it expects one per name).
55
+ const RtlChevronRight = ({ size, className, }) => ((0, jsx_runtime_1.jsx)(lucide_react_1.ChevronRight, { "data-rtl-flip": "", size: size, strokeWidth: 1.75, className: className }));
48
56
  const OP_LABELS = {
49
57
  contains: "contains",
50
58
  doesnotcontain: "does not contain",
@@ -105,18 +113,53 @@ const DATE_OP_ORDER = [
105
113
  "empty",
106
114
  "notempty",
107
115
  ];
116
+ // #715: a closed-vocabulary column (GRC Audit's Action/Entity, e.g.) gets
117
+ // its own shorter set too — the text set's `contains`/`starts with`/… mean
118
+ // nothing against a fixed value list. Reuses the already-measured `is`/
119
+ // `is not`/`empty`/`not empty` labels and the same "is/isnot before the
120
+ // type-specific pair, empty/notempty last" ordering every other set here
121
+ // follows — not an independent pixel measurement the way #593's number/date
122
+ // sets were (a live re-check of Attio's own select-column filter menu is
123
+ // still open, see the PR).
124
+ const SELECT_OP_ORDER = ["is", "isnot", "empty", "notempty"];
108
125
  // "isanyof" is appended only for a column that supplies `values` — there is
109
126
  // nothing to check off otherwise. A column's `type` picks the base set
110
- // first; "isanyof" can still follow any of the three (a column's own
111
- // declared values aren't tied to its underlying number/date/text kind).
127
+ // first; "isanyof" can still follow any of the four (a column's own
128
+ // declared values aren't tied to its underlying number/date/text/select
129
+ // kind).
112
130
  const opsFor = (col) => {
113
131
  const base = col?.type === "number"
114
132
  ? NUMBER_OP_ORDER
115
133
  : col?.type === "date"
116
134
  ? DATE_OP_ORDER
117
- : OP_ORDER;
135
+ : col?.type === "select"
136
+ ? SELECT_OP_ORDER
137
+ : OP_ORDER;
118
138
  return col?.values?.length ? [...base, "isanyof"] : base;
119
139
  };
140
+ // #592: a condition's `key` is either a plain top-level column key, or a
141
+ // dot-joined path into `nestedColumns` ("team.twitterFollowerCount"). Walks
142
+ // the path one segment at a time, descending into each match's own
143
+ // `nestedColumns` for the next — returns the resolved column at each step,
144
+ // in order, so a caller has both the LEAF (its own `type`/`values`/`icon`
145
+ // govern the operator set and value editor, unchanged from a plain column)
146
+ // and the FULL PATH (for the breadcrumb the field segment/picker render).
147
+ // Empty when any segment fails to resolve, rather than a partial path — a
148
+ // stale key (its column was removed upstream) should render as "unknown",
149
+ // not as whatever prefix still happens to match.
150
+ const resolveColumnPath = (cols, key) => {
151
+ const segments = key.split(".");
152
+ const path = [];
153
+ let list = cols;
154
+ for (const seg of segments) {
155
+ const found = list.find((c) => c.key === seg);
156
+ if (!found)
157
+ return [];
158
+ path.push(found);
159
+ list = found.nestedColumns ?? [];
160
+ }
161
+ return path;
162
+ };
120
163
  const opNeedsValue = (op) => op === "contains" ||
121
164
  op === "doesnotcontain" ||
122
165
  op === "startswith" ||
@@ -295,7 +338,7 @@ function applySorts(rows, sorts, get = (row, key) => row[key]) {
295
338
  * this (Sort: Due date/Assignee/Creation date; Filter: Assignee/Record/
296
339
  * Created by), which one shared list can't reproduce.
297
340
  */
298
- 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 }) {
341
+ 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 }) {
299
342
  // crisp/#471: an explicit prop always wins; missing, it reads the nearest
300
343
  // CrispMessagesProvider's translation of the English default below, or
301
344
  // that default itself with no provider — unchanged from before this
@@ -322,6 +365,12 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
322
365
  const reorderAttributeLabel = (0, core_1.useCrispMessage)(reorderAttributeLabelProp, "Reorder attribute");
323
366
  const addAttributeToViewLabel = (0, core_1.useCrispMessage)(addAttributeToViewLabelProp, "Add attribute to view");
324
367
  const searchAttributesPlaceholder = (0, core_1.useCrispMessage)(searchAttributesPlaceholderProp, "Search attributes…");
368
+ const importExportLabel = (0, core_1.useCrispMessage)(importExportLabelProp, "Import / Export");
369
+ const importCsvLabel = (0, core_1.useCrispMessage)(importCsvLabelProp, "Import CSV");
370
+ const exportCsvLabel = (0, core_1.useCrispMessage)(exportCsvLabelProp, "Export view as CSV");
371
+ const exportExcelLabel = (0, core_1.useCrispMessage)(exportExcelLabelProp, "Export view as Excel");
372
+ const learnImportExportLabel = (0, core_1.useCrispMessage)(learnImportExportLabelProp, "Learn about imports and exports");
373
+ const backLabel = (0, core_1.useCrispMessage)(backLabelProp, "Back");
325
374
  // OP_LABELS' own words ("contains", "is", "is any of", …) have no prop of
326
375
  // their own — a whole lookup table, not one string — so they translate
327
376
  // straight from the provider's dictionary at the two spots they render
@@ -362,6 +411,11 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
362
411
  const [openMenu, setOpenMenu] = React.useState(null);
363
412
  const [dragSort, setDragSort] = React.useState(null);
364
413
  const [filterQuery, setFilterQuery] = React.useState("");
414
+ // #592: which nested scope the Filter field picker is currently drilled
415
+ // into -- [] is the top level, [team] is inside Team's own attributes,
416
+ // [team, company] is Team -> Company, etc. Reset to [] wherever
417
+ // `filterQuery` already resets (opening the picker fresh), same lifetime.
418
+ const [fieldPickerPath, setFieldPickerPath] = React.useState([]);
365
419
  // filters the "isanyof" checkbox list, same lifetime as filterQuery above
366
420
  const [anyOfQuery, setAnyOfQuery] = React.useState("");
367
421
  const [dragAttr, setDragAttr] = React.useState(null);
@@ -499,14 +553,57 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
499
553
  close: () => setOpenMenu(null),
500
554
  contentRef: opMenuRef,
501
555
  });
556
+ // The Import/Export menu is `role="menu"` too — confirmed live, 2026-09-25
557
+ // (an `<a>`/`<div>` mix, each `role="menuitem"`, two `role="separator"`
558
+ // dividers between the 3 groups) — same shared keyboard model.
559
+ const importExportMenuRef = React.useRef(null);
560
+ const { onKeyDown: onImportExportKeyDown } = (0, menu_1.useMenuKeyboard)({
561
+ open: openMenu === "importexport",
562
+ close: () => setOpenMenu(null),
563
+ contentRef: importExportMenuRef,
564
+ });
502
565
  // Shared between the "Filter" trigger's own add-condition picker and each
503
566
  // chip's field segment (#462 follow-up) — both search the same
504
- // `filterColumns` list and differ only in what a pick does.
505
- const filterFieldPicker = (onSelect) => ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterFieldPlaceholder, "aria-label": filterFieldLabel,
506
- // biome-ignore lint/a11y/noAutofocus: focus the picker on open
507
- autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) }), filterColumns
508
- .filter((col) => col.label.toLowerCase().includes(filterQuery.trim().toLowerCase()))
509
- .map((col) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => onSelect(col.key), children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)("span", { style: { width: 15 } })), col.label] }, col.key)))] }));
567
+ // `filterColumns` list and differ only in what a pick does. #592: also
568
+ // handles drilling into a `nestedColumns` column — `fieldPickerPath`
569
+ // tracks the current scope ([] is the top level), reset to [] wherever
570
+ // the picker opens fresh (both trigger onClicks, above).
571
+ const filterFieldPicker = (onSelect) => {
572
+ const scope = fieldPickerPath.at(-1);
573
+ const scopeColumns = scope ? (scope.nestedColumns ?? []) : filterColumns;
574
+ const query = filterQuery.trim().toLowerCase();
575
+ const direct = scopeColumns.filter((c) => c.label.toLowerCase().includes(query));
576
+ // #592: one level of flattening from wherever the picker is currently
577
+ // scoped — a nested column's own children get searched too, and a
578
+ // match renders breadcrumbed, alongside this level's direct matches.
579
+ // Matches the one case measured live at the TOP level ("Twitter
580
+ // follower" surfaces both the direct field and "Team › Twitter
581
+ // follower count"); applying the identical rule recursively from an
582
+ // already-drilled-into scope (so it also reaches Team → Company → X
583
+ // while already browsing inside Team) is the same construct, one level
584
+ // relative to wherever you are, but wasn't independently re-verified
585
+ // live from inside a nested scope — flagged on the PR.
586
+ const nested = query
587
+ ? scopeColumns.flatMap((c) => (c.nestedColumns ?? [])
588
+ .filter((n) => n.label.toLowerCase().includes(query))
589
+ .map((n) => ({ parent: c, col: n })))
590
+ : [];
591
+ const selectPath = (path) => onSelect(path.map((c) => c.key).join("."));
592
+ const drillIn = (col) => {
593
+ setFieldPickerPath([...fieldPickerPath, col]);
594
+ setFilterQuery("");
595
+ };
596
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [scope ? ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu-nesthead", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-tabletoolbar-menu-back", "aria-label": backLabel, onClick: () => {
597
+ setFieldPickerPath(fieldPickerPath.slice(0, -1));
598
+ setFilterQuery("");
599
+ }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronLeft, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: nestedFieldPlaceholder
600
+ ? nestedFieldPlaceholder(scope.nestedObjectLabel ?? scope.label.toLowerCase())
601
+ : `Search ${scope.nestedObjectLabel ?? scope.label.toLowerCase()} attributes…`, "aria-label": filterFieldLabel,
602
+ // biome-ignore lint/a11y/noAutofocus: focus the picker on open
603
+ autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) })] })) : ((0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterFieldPlaceholder, "aria-label": filterFieldLabel,
604
+ // biome-ignore lint/a11y/noAutofocus: focus the picker on open
605
+ autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) })), scope && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => selectPath(fieldPickerPath), children: [scope.icon ? ((0, jsx_runtime_1.jsx)(scope.icon, { size: 15, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)("span", { style: { width: 15 } })), scope.label] })), direct.map((col) => col.nestedColumns?.length ? ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item crisp-tabletoolbar-menu-item--expand", onClick: () => drillIn(col), children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)("span", { style: { width: 15 } })), col.label, (0, jsx_runtime_1.jsx)(RtlChevronRight, { size: 15, className: "crisp-tabletoolbar-menu-item-chevron" })] }, col.key)) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => selectPath([...fieldPickerPath, col]), children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)("span", { style: { width: 15 } })), col.label] }, col.key))), nested.map(({ parent, col }) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => selectPath([...fieldPickerPath, parent, col]), children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)("span", { style: { width: 15 } })), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-menu-item-path", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-menu-item-path-parent", children: parent.label }), (0, jsx_runtime_1.jsx)(RtlChevronRight, { size: 15, className: "crisp-tabletoolbar-menu-item-path-sep" }), (0, jsx_runtime_1.jsx)("span", { children: col.label })] })] }, `${parent.key}.${col.key}`)))] }));
606
+ };
510
607
  const activeFilters = filters.length;
511
608
  // The default phrase only works in English word order, so a caller can
512
609
  // replace the whole thing rather than fill a template — crisp/#471's
@@ -543,12 +640,21 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
543
640
  label: ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-sort-optlabel", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ArrowDownWideNarrow, { size: 15, strokeWidth: 1.75 }), descendingLabel] })),
544
641
  keywords: descendingLabel,
545
642
  },
546
- ], "aria-label": sortDirectionLabel }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-tabletoolbar-sort-remove", onClick: () => removeSort(i), "aria-label": removeSortLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 15, strokeWidth: 1.75 }) })] }, s.key)))), (0, jsx_runtime_1.jsx)("div", { className: "crisp-tabletoolbar-sort-addrow", children: (0, jsx_runtime_1.jsxs)("button", { ref: addSortRef, type: "button", className: "crisp-tabletoolbar-sort-add", onClick: addSort, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75 }), addSortLabel] }) }), helpHref && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-tabletoolbar-sort-foot", children: (0, jsx_runtime_1.jsxs)("a", { className: "crisp-tabletoolbar-sort-learn", href: helpHref, target: "_blank", rel: "noreferrer", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CircleHelp, { size: 14, strokeWidth: 1.75 }), learnSortingLabel] }) }))] }))] })), showSort && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-divider", "aria-hidden": "true" })), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.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: [(0, jsx_runtime_1.jsx)(lucide_react_1.ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), filterTriggerLabel
643
+ ], "aria-label": sortDirectionLabel }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-tabletoolbar-sort-remove", onClick: () => removeSort(i), "aria-label": removeSortLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 15, strokeWidth: 1.75 }) })] }, s.key)))), (0, jsx_runtime_1.jsx)("div", { className: "crisp-tabletoolbar-sort-addrow", children: (0, jsx_runtime_1.jsxs)("button", { ref: addSortRef, type: "button", className: "crisp-tabletoolbar-sort-add", onClick: addSort, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75 }), addSortLabel] }) }), helpHref && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-tabletoolbar-sort-foot", children: (0, jsx_runtime_1.jsxs)("a", { className: "crisp-tabletoolbar-sort-learn", href: helpHref, target: "_blank", rel: "noreferrer", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CircleHelp, { size: 14, strokeWidth: 1.75 }), learnSortingLabel] }) }))] }))] })), showSort && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-divider", "aria-hidden": "true" })), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: `crisp-tabletoolbar-tool crisp-tabletoolbar-tool--ghost${activeFilters ? " crisp-tabletoolbar-tool--active" : ""}`, onClick: () => {
644
+ setFieldPickerPath([]);
645
+ setOpenMenu(openMenu === "filter" ? null : "filter");
646
+ }, "aria-haspopup": "dialog", "aria-expanded": openMenu === "filter", "data-menu-trigger": true, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), filterTriggerLabel
547
647
  ? filterTriggerLabel(activeFilters)
548
648
  : activeFilters
549
649
  ? `${filterLabel} · ${activeFilters}`
550
650
  : filterLabel] }), openMenu === "filter" && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": filterFieldLabel, children: filterFieldPicker(addFilter) }))] }), filters.map((cond) => {
551
- const col = filterColumns.find((c) => c.key === cond.key);
651
+ // #592: cond.key may be a dot-joined nested path
652
+ // ("team.twitterFollowerCount") — resolve the full path so the
653
+ // field segment can render its breadcrumb; `col` (the leaf) still
654
+ // drives the operator set and value editor exactly as before, for
655
+ // both a plain and a nested key.
656
+ const colPath = resolveColumnPath(filterColumns, cond.key);
657
+ const col = colPath.at(-1);
552
658
  const fieldOpen = openMenu === `ffield:${cond.id}`;
553
659
  const opOpen = openMenu === `fop:${cond.id}`;
554
660
  const valOpen = openMenu === `fval:${cond.id}`;
@@ -564,7 +670,10 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
564
670
  // which never renders a third box in either case.
565
671
  const showValueSeg = cond.op != null &&
566
672
  (opNeedsValue(cond.op) || opNeedsValues(cond.op));
567
- return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-fcond", "data-complete": complete ? "" : undefined, children: [(0, jsx_runtime_1.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 ? (0, jsx_runtime_1.jsx)(col.icon, { size: 14, strokeWidth: 1.75 }) : null, col?.label] }), (0, jsx_runtime_1.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
673
+ return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-fcond", "data-complete": complete ? "" : undefined, children: [(0, jsx_runtime_1.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: () => {
674
+ setFieldPickerPath([]);
675
+ setOpenMenu(fieldOpen ? null : `ffield:${cond.id}`);
676
+ }, children: [col?.icon ? (0, jsx_runtime_1.jsx)(col.icon, { size: 14, strokeWidth: 1.75 }) : null, colPath.length > 1 ? ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fcond-path", children: colPath.map((c, i) => ((0, jsx_runtime_1.jsxs)(React.Fragment, { children: [i > 0 && ((0, jsx_runtime_1.jsx)(RtlChevronRight, { size: 14, className: "crisp-tabletoolbar-fcond-path-sep" })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fcond-path-seg", children: c.label })] }, c.key))) })) : (col?.label)] }), (0, jsx_runtime_1.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
568
677
  ? (crispMessages[OP_LABELS[cond.op]] ?? OP_LABELS[cond.op])
569
678
  : selectConditionLabel }), showValueSeg && ((0, jsx_runtime_1.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"
570
679
  ? cond.values?.length
@@ -594,7 +703,18 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
594
703
  values: col.values?.filter((x) => set.has(x)),
595
704
  });
596
705
  }, children: v }, v));
597
- })] })) : col?.type === "date" ? ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-datepop", children: [(0, jsx_runtime_1.jsx)(datepicker_1.DatePicker, { ...{
706
+ })] })) : col?.type === "select" &&
707
+ col?.values &&
708
+ (cond.op === "is" || cond.op === "isnot") ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter values`,
709
+ // biome-ignore lint/a11y/noAutofocus: focus the search field
710
+ autoFocus: true, value: anyOfQuery, onChange: (e) => setAnyOfQuery(e.target.value) }), col.values
711
+ .filter((v) => v
712
+ .toLowerCase()
713
+ .includes(anyOfQuery.trim().toLowerCase()))
714
+ .map((v) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", "data-on": cond.value === v ? "" : undefined, onClick: () => {
715
+ patchFilter(cond.id, { value: v });
716
+ setOpenMenu(null);
717
+ }, children: [v, cond.value === v ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, v)))] })) : col?.type === "date" ? ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-datepop", children: [(0, jsx_runtime_1.jsx)(datepicker_1.DatePicker, { ...{
598
718
  selectionMode: "single",
599
719
  value: isoToDate(cond.value)
600
720
  ? toDatePickerDate(isoToDate(cond.value))
@@ -643,7 +763,20 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
643
763
  moveAttr(dragAttr, i);
644
764
  setDragAttr(null);
645
765
  }, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-tabletoolbar-sort-grip", "aria-label": reorderAttributeLabel, draggable: true, onDragStart: () => setDragAttr(i), onDragEnd: () => setDragAttr(null), children: (0, jsx_runtime_1.jsx)(lucide_react_1.GripVertical, { size: 15, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-sort-optlabel crisp-tabletoolbar-sort-field", children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : null, col.label] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-tabletoolbar-sort-remove", onClick: () => hideAttr(col.key), "aria-label": hideAttributeLabel?.(col.label) ??
646
- `${crispMessages.Hide ?? "Hide"} ${col.label} ${crispMessages["from view"] ?? "from view"}`, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 15, strokeWidth: 1.75 }) })] }, col.key))), (0, jsx_runtime_1.jsx)("div", { className: "crisp-tabletoolbar-sort-addrow", children: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-sort-add", onClick: () => setAddingAttribute(true), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75 }), addAttributeToViewLabel] }) })] })))] })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-spacer" }), count != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-count", children: count })), children] }) }));
766
+ `${crispMessages.Hide ?? "Hide"} ${col.label} ${crispMessages["from view"] ?? "from view"}`, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 15, strokeWidth: 1.75 }) })] }, col.key))), (0, jsx_runtime_1.jsx)("div", { className: "crisp-tabletoolbar-sort-addrow", children: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-sort-add", onClick: () => setAddingAttribute(true), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75 }), addAttributeToViewLabel] }) })] })))] })), (onImportCsv ||
767
+ onExportCsv ||
768
+ onExportExcel ||
769
+ importExportHelpHref) && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.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: [(0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), importExportLabel, (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" })] }), openMenu === "importexport" && ((0, jsx_runtime_1.jsxs)("div", { ref: importExportMenuRef, className: "crisp-tabletoolbar-menu", role: "menu", "aria-label": importExportLabel, onKeyDown: onImportExportKeyDown, children: [onImportCsv && ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", onClick: () => {
770
+ onImportCsv();
771
+ setOpenMenu(null);
772
+ }, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { size: 15, strokeWidth: 1.75 }), importCsvLabel] })), onImportCsv && (onExportCsv || onExportExcel) && ((0, jsx_runtime_1.jsx)("hr", { className: "crisp-tabletoolbar-menu-sep" })), onExportCsv && ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", onClick: () => {
773
+ onExportCsv();
774
+ setOpenMenu(null);
775
+ }, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Download, { size: 15, strokeWidth: 1.75 }), exportCsvLabel] })), onExportExcel && ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", onClick: () => {
776
+ onExportExcel();
777
+ setOpenMenu(null);
778
+ }, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Download, { size: 15, strokeWidth: 1.75 }), exportExcelLabel] })), importExportHelpHref &&
779
+ (onImportCsv || onExportCsv || onExportExcel) && ((0, jsx_runtime_1.jsx)("hr", { className: "crisp-tabletoolbar-menu-sep" })), importExportHelpHref && ((0, jsx_runtime_1.jsxs)("a", { className: "crisp-tabletoolbar-menu-item", role: "menuitem", href: importExportHelpHref, target: "_blank", rel: "noreferrer", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CircleHelp, { size: 15, strokeWidth: 1.75 }), learnImportExportLabel] }))] }))] })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-spacer" }), count != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-count", children: count })), children] }) }));
647
780
  }
648
781
  /**
649
782
  * The Sort/Filter chip's own chrome (`.crisp-tabletoolbar-tool`), standalone
@@ -36,8 +36,22 @@ const filterChipChrome = `
36
36
  & .crisp-tabletoolbar-fcond-seg svg { flex-shrink: 0; }
37
37
  & .crisp-tabletoolbar-fcond[data-complete] .crisp-tabletoolbar-fcond-seg--field,
38
38
  & .crisp-tabletoolbar-fcond[data-complete] .crisp-tabletoolbar-fcond-seg--val { background: ${tokens_1.contract.bgSurface}; }
39
- & .crisp-tabletoolbar-fcond-seg--field { border-radius: ${tokens_1.contract.radius.md} 0 0 ${tokens_1.contract.radius.md}; font-weight: ${tokens_1.contract.weight.medium}; }
39
+ & .crisp-tabletoolbar-fcond-seg--field { border-radius: ${tokens_1.contract.radius.md} 0 0 ${tokens_1.contract.radius.md}; font-weight: ${tokens_1.contract.weight.medium}; max-width: 200px; }
40
40
  & .crisp-tabletoolbar-fcond-seg--field svg { color: ${tokens_1.contract.fgMuted}; }
41
+ /* #592: a nested pick's field segment ("Team › Twitter follower
42
+ count") — measured live: BOTH path segments the same colour (chip
43
+ context only; the picker's own menu rows keep the parent muted, see
44
+ tabletoolbar.chrome.ts), each independently truncated. Attio's own
45
+ layout gives the leaf segment priority (it stays fully readable
46
+ longer, the parent truncates first) via a two-part min-content
47
+ measurement this reproduces qualitatively with a flex-shrink
48
+ differential rather than byte-for-byte (the exact px split Attio
49
+ computes is content-length-dependent, not a fixed value) — not
50
+ independently re-verified at other path-segment lengths. */
51
+ & .crisp-tabletoolbar-fcond-path { display: inline-flex; align-items: center; gap: 2px; min-width: 0; max-width: 178px; }
52
+ & .crisp-tabletoolbar-fcond-path-seg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1 1 auto; }
53
+ & .crisp-tabletoolbar-fcond-path-seg:first-child { flex: 0 1 auto; }
54
+ & .crisp-tabletoolbar-fcond-path-sep { flex-shrink: 0; color: inherit; }
41
55
  & .crisp-tabletoolbar-fcond-seg--op { background: ${tokens_1.contract.bgSurface}; color: ${tokens_1.contract.fgMuted}; }
42
56
  & .crisp-tabletoolbar-fcond-seg--val { font-weight: ${tokens_1.contract.weight.medium}; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
43
57
  & .crisp-tabletoolbar-fcond-seg--kebab { background: ${tokens_1.contract.bgSurface}; justify-content: center; width: 28px; padding: 0; border-radius: 0 ${tokens_1.contract.radius.md} ${tokens_1.contract.radius.md} 0; color: ${tokens_1.contract.fgMuted}; }
@@ -47,6 +61,24 @@ const filterChipChrome = `
47
61
  & .crisp-tabletoolbar-datepop-presets { display: flex; flex-wrap: wrap; gap: ${tokens_1.contract.space["1"]}; margin-top: ${tokens_1.contract.space["2"]}; padding: 0 ${tokens_1.contract.space["1"]}; }
48
62
  & .crisp-tabletoolbar-datepop-preset { height: ${tokens_1.contract.size.chip}; padding: 0 ${tokens_1.contract.space["2"]}; border: none; border-radius: ${tokens_1.contract.radius.chip}; background: ${tokens_1.contract.bgRaised}; color: ${tokens_1.contract.fgPrimary}; font-family: inherit; font-size: ${tokens_1.contract.text.sm}; cursor: pointer; }
49
63
  & .crisp-tabletoolbar-datepop-preset:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 8%, transparent); }`;
64
+ // #595: the Import/Export trigger — measured live, `app.attio.com`'s own
65
+ // view bar: `28px` tall, `radius.md` (8px), background `bgSurfaceMuted`
66
+ // (`rgb(246,247,247)`, exact match) and `elevation.field`'s exact 3-layer
67
+ // shadow (`inset 0 0 0 1px transparent, 0 0 2px rgba(28,40,64,.18), 0 1px 3px
68
+ // rgba(0,0,0,.04)`) — a "raised chrome button" distinct from Sort/Filter/View
69
+ // settings' plain ringed `.tool` (no background, no elevation shadow), and
70
+ // the only trigger of the four with a trailing chevron (2 icons total,
71
+ // confirmed live). Overrides `.tool`'s own base background/shadow by cascade
72
+ // order (this block is appended after `toolbarChromeBody`, which defines
73
+ // them), the same way `.tool--ghost` already does. `border`/`font-family`/
74
+ // `cursor` are repeated here too, even though `.tool`'s own base rule (in
75
+ // the shared `tabletoolbar.chrome.ts`) already sets them on every button
76
+ // carrying this modifier — `spec-check.mjs`'s button-reset audit only reads
77
+ // each component's own `<name>.recipe.ts` file verbatim, so a reset living
78
+ // behind `chrome.ts`'s function call is invisible to it; harmless repeats,
79
+ // not a real gap.
80
+ const importExportChrome = `
81
+ & .crisp-tabletoolbar-tool--raised { border: none; background: ${tokens_1.contract.bgSurfaceMuted}; box-shadow: ${tokens_1.elevation.field}; font-family: inherit; cursor: pointer; }`;
50
82
  // The table toolbar, measured: 28px controls, the Sort/Filter buttons and
51
83
  // the menus they open, sort-condition rows, and the filter chips. The chrome
52
84
  // itself lives in ./tabletoolbar.chrome.ts because RecordsTable renders the
@@ -64,6 +96,8 @@ const config = (0, core_1.defineRecipe)({
64
96
  ${(0, tabletoolbar_chrome_1.toolbarChromeHead)("tabletoolbar")}
65
97
  ${(0, tabletoolbar_chrome_1.toolbarChromeBody)("tabletoolbar")}
66
98
  ${filterChipChrome}
99
+ ${importExportChrome}
100
+ ${core_1.rtlFlipCss}
67
101
  `,
68
102
  });
69
103
  exports.tabletoolbarRecipe = (0, core_1.resolveRecipe)(config);