@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
@@ -1,4 +1,4 @@
1
- import { defineRecipe, resolveRecipe } from "../../core/index.js";
1
+ import { defineRecipe, resolveRecipe, rtlFlipCss } from "../../core/index.js";
2
2
  import { contract as c, elevation as e } from "../../tokens/index.js";
3
3
  // The feature-rich, config-driven data grid (sort / filter / selection /
4
4
  // inline edit) built on the composable `Table` primitives. This recipe adds the
@@ -19,6 +19,30 @@ const config = defineRecipe({
19
19
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
20
20
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
21
21
  }
22
+ /* crisp/#645: visually-hidden-but-focusable, not visually-hidden-and-
23
+ inert like .crisp-datatable-live above -- same clip-rect shape,
24
+ this one just needs to actually hold DOM focus so a native
25
+ copy/paste event has something to fire on (see its own comment
26
+ in datatable.tsx). resize: none keeps a stray native textarea handle
27
+ from ever being drawable, even though it's clipped to 1px anyway. */
28
+ & .crisp-datatable-clipboard-proxy {
29
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
30
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
31
+ resize: none;
32
+ }
33
+ /* single active cell (crisp/#645 follow-up -- this had no styling of its
34
+ own at all before, a gap against Golden Rule 0: RecordsTable's own
35
+ measured value, .crisp-recordstable-td[data-selected], brought
36
+ across unchanged now that a visible anchor actually matters for the
37
+ Shift+Click range below). */
38
+ & td[data-selected] { outline: 2px solid ${c.bgBrandSolid}; outline-offset: -2px; border-radius: ${c.radius.sm}; z-index: 3; }
39
+ /* crisp/#645, G-101: a Shift+Click range -- reuses the row-selection
40
+ tint formula already used twice below (tbody tr[data-selected] td)
41
+ for the same reason a selected group of rows gets one: a selected
42
+ GROUP of cells is the same kind of state, just cell-scoped instead of
43
+ row-scoped. Not independently re-measured against Attio's own range
44
+ highlight (out of scope this slice -- flagged on crisp#645). */
45
+ & td[data-in-range] { background: color-mix(in srgb, ${c.bgBrandSolid} 10%, ${c.bgSurface}); }
22
46
  /* The header cell has no padding of its own -- the button owns the
23
47
  full cell so the whole header is clickable (its own inset is 12px).
24
48
  Mirrors the summary-row fix.
@@ -177,11 +201,44 @@ const config = defineRecipe({
177
201
  & .crisp-datatable-resize { position: absolute; top: 0; inset-inline-end: -3px; width: 9px; height: 100%; cursor: col-resize; z-index: 6; touch-action: none; }
178
202
  & .crisp-datatable-resize::after { content: ""; position: absolute; top: 0; inset-inline-start: 3px; width: 2px; height: 100%; background: ${c.bgBrandSolid}; opacity: 0; transition: opacity .12s; }
179
203
  & .crisp-datatable-resize:hover::after, & .crisp-datatable-resize[data-resizing]::after { opacity: 1; }
204
+ /* crisp/#731 -- column drag-to-reorder. Measured live against Attio's
205
+ own grid (the issue's own comment): the dragged header keeps full
206
+ opacity and the plain \`pointer\` cursor throughout (Attio draws no
207
+ ghost and no "grabbing" cursor of its own), so this adds nothing to
208
+ \`[data-dragging]\` itself -- the ONLY new paint is the drop-indicator
209
+ line, a 3px bar in the same measured brand blue the resize handle's
210
+ own hover state (above) already uses, positioned on whichever edge of
211
+ the hovered header the pointer is closer to. */
212
+ & th[data-dragging] { cursor: pointer; }
213
+ & th[data-drop-before]::before, & th[data-drop-after]::after {
214
+ content: ""; position: absolute; top: 0; width: 3px; height: 100%;
215
+ background: ${c.bgBrandSolid}; z-index: 6; pointer-events: none;
216
+ }
217
+ & th[data-drop-before]::before { inset-inline-start: -1.5px; }
218
+ & th[data-drop-after]::after { inset-inline-end: -1.5px; }
180
219
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
181
220
  & .crisp-datatable-entity { display: flex; align-items: center; gap: ${c.space["1.5"]}; min-width: 0; color: ${c.fgPrimary}; font-weight: ${c.weight.medium}; }
182
- & .crisp-datatable-entity-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
183
- & .crisp-datatable-entity:hover .crisp-datatable-entity-name { text-decoration: underline; }
221
+ /* crisp/#601, G-050: measured LIVE (getComputedStyle on the name text,
222
+ plain hover vs rest) that Attio's underline is unconditional, not a
223
+ hover reveal -- identical at rest and on hover: text-decoration-color
224
+ is rgba(0,0,0,0.09), a 9% tint, not the name's own full-contrast text
225
+ colour. The previous :hover-gated, full-contrast rule here was itself
226
+ unmeasured and wrong in two ways at once. */
227
+ & .crisp-datatable-entity-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: underline; text-decoration-color: color-mix(in srgb, ${c.fgPrimary} 9%, transparent); }
184
228
  & 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%; }
229
+ /* crisp/#601, G-048: two 20px ghost icons, right-aligned in the primary
230
+ column, revealed on ROW hover (the issue's own title says "row
231
+ hover", and a live check confirmed hovering well past the name --
232
+ not just the icons' own hit target -- still reveals them) or
233
+ keyboard focus. Measured live: border-radius 6px, icon colour
234
+ rgba(0,0,0,0.55) at rest, darkening to the full text colour with a
235
+ rgba(0,0,0,0.04) background on the icon's OWN hover -- both
236
+ expressed as color-mix against fgPrimary so they stay correct in
237
+ dark mode instead of a literal black that would invert. */
238
+ & .crisp-datatable-entity-actions { display: flex; align-items: center; gap: ${c.space["1.5"]}; margin-inline-start: auto; flex-shrink: 0; opacity: 0; }
239
+ & tr:hover .crisp-datatable-entity-actions, & .crisp-datatable-entity-actions:focus-within { opacity: 1; }
240
+ & .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: ${c.radius.sm}; background: none; font-family: inherit; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); cursor: pointer; flex-shrink: 0; }
241
+ & .crisp-datatable-entity-action:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); color: ${c.fgPrimary}; }
185
242
  & .crisp-datatable-avatar { width: ${c.size.icon}; height: ${c.size.icon}; border-radius: ${c.radius.sm}; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; line-height: 1; font-weight: ${c.weight.semibold}; text-transform: uppercase; color: ${c.fgOnBrand}; background: ${c.bgBrandSolid}; }
186
243
  & .crisp-datatable-avatar[data-tone="success"] { background: ${c.bgSuccess}; } & .crisp-datatable-avatar[data-tone="warning"] { background: ${c.bgWarning}; } & .crisp-datatable-avatar[data-tone="danger"] { background: ${c.bgDanger}; } & .crisp-datatable-avatar[data-tone="purple"] { background: ${c.bgPurple}; } & .crisp-datatable-avatar[data-tone="cyan"] { background: ${c.bgCyan}; } & .crisp-datatable-avatar[data-tone="orange"] { background: ${c.bgOrange}; }
187
244
  & .crisp-datatable-avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
@@ -213,7 +270,8 @@ const config = defineRecipe({
213
270
  & .crisp-datatable-col-grip { width: 20px; padding: 0; text-align: center; }
214
271
  & .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: ${c.fgMuted}; cursor: grab; }
215
272
  & .crisp-datatable-grip:active { cursor: grabbing; }
216
- & tbody tr[data-drop-target] td, & tbody tr[data-drop-target] th { box-shadow: inset 0 2px 0 0 ${c.bgBrandSolid}; transition: box-shadow .12s; }`,
273
+ & tbody tr[data-drop-target] td, & tbody tr[data-drop-target] th { box-shadow: inset 0 2px 0 0 ${c.bgBrandSolid}; transition: box-shadow .12s; }
274
+ ${rtlFlipCss}`,
217
275
  variants: {},
218
276
  defaultVariants: {},
219
277
  compoundVariants: [],
@@ -25,10 +25,74 @@ function useDialogInstanceId() {
25
25
  export function Dialog({ open, onOpenChange, title, icon, width = 640, variant = "panel", description, "aria-label": ariaLabel, closeLabel: closeLabelProp, children, className, style, "aria-describedby": describedBy, ...rest }) {
26
26
  const closeLabel = useCrispMessage(closeLabelProp, "Close");
27
27
  const id = useDialogInstanceId();
28
+ const isAlert = variant === "alert";
28
29
  const service = useMachine(dialog.machine, {
29
30
  id,
30
31
  open,
31
- role: variant === "alert" ? "alertdialog" : "dialog",
32
+ role: isAlert ? "alertdialog" : "dialog",
33
+ // #704: a stacked (second) Dialog can get marked `aria-hidden="true"`
34
+ // on its own outer wrapper a frame after it opens. Traced into
35
+ // `@zag-js/dialog`'s `hideContentBelow` effect and `@zag-js/aria-hidden`'s
36
+ // own source: a modal Dialog's `hideContentBelow` calls
37
+ // `hideOthers([itself])` (deferred one `requestAnimationFrame`), which
38
+ // walks `document.body`'s children and `aria-hidden`s every one that
39
+ // ISN'T an ancestor of its own content. Each open `Dialog` schedules
40
+ // this independently, with no shared registry of who else is open —
41
+ // when the effect re-runs for the FIRST (panel) Dialog (Zag re-runs an
42
+ // effect whenever any prop it reads is considered changed, which a
43
+ // parent re-render can trigger even when the boolean VALUE didn't),
44
+ // its walk runs again with the SECOND (alert) Dialog now mounted as a
45
+ // sibling body child it has never seen before, and marks that whole
46
+ // sibling subtree — including the alert's own wrapper, the thing
47
+ // actually on top and interactive — as hidden. A real upstream
48
+ // limitation (no per-instance awareness of other open Dialogs), not
49
+ // something crisp can fix by calling the library differently.
50
+ //
51
+ // `hideContentBelow` is gated purely on `modal` (no separate flag), so
52
+ // `modal: false` for the alert variant skips it — `trapFocus`/
53
+ // `preventScroll` are passed explicitly right below so they stay on
54
+ // regardless (their own defaults are ALSO `= modal`, so leaving them
55
+ // out here would silently turn them off too).
56
+ //
57
+ // A DOM-level fix was tried first — correcting the wrongly-set
58
+ // attribute after the fact instead of touching `modal` at all, so
59
+ // every other zag-managed behavior would stay untouched by
60
+ // construction. It broke `formdialog.test.tsx`'s own
61
+ // "the confirm's own Cancel keeps the dirtied form open" test (a real
62
+ // regression, reproduced with the correction reduced to literally one
63
+ // `removeAttribute` call, and reproduced again unmodified on the prior
64
+ // WIP commit — that version was never actually run against the full
65
+ // suite): `waitFor(() => expect(queryByRole("alertdialog")).toBeNull())`
66
+ // hung for the full test timeout rather than the confirm's real,
67
+ // sub-100ms unmount, in a way that traces to `aria-hidden` specifically
68
+ // (swapping the correction for a harmless, differently-named attribute
69
+ // did not reproduce it) — a fragile interaction this DOM-mutation
70
+ // approach could not be made to reliably avoid.
71
+ //
72
+ // `modal: false` also turns off `@zag-js/dismissable`'s own
73
+ // `pointerBlocking` for the alert. Verified live (a real
74
+ // headed-then-headless Chromium session against a built `FormDialog`,
75
+ // not just reasoning from the source) that this does NOT regress
76
+ // outside-click safety in practice — but for a DIFFERENT reason than
77
+ // `pointerBlocking` itself: the thing actually keeping a background
78
+ // click from reaching anything is `@zag-js/remove-scroll`'s
79
+ // `preventBodyScroll`, gated on `preventScroll` (kept `true` above,
80
+ // independent of `modal`) — it sets `pointer-events: none` directly on
81
+ // `<body>` while any modal-ish Dialog is open, and `getContentProps()`
82
+ // re-enables `pointer-events: auto` only on each Dialog's OWN content
83
+ // element. Confirmed live: the alert's content had an explicit
84
+ // `pointer-events: auto`; its OWN backdrop/positioner did not, and
85
+ // inherited body's `none`; clicking anywhere in that dead zone (the
86
+ // alert's backdrop, or the panel's, now both non-interactive) dismissed
87
+ // neither dialog, because the click lands on nothing that listens for
88
+ // it — a stronger guarantee than "click closes the wrong layer", not
89
+ // just an absence of that one failure mode. `pointerBlocking` is a
90
+ // second, redundant layer over the same problem `preventScroll` already
91
+ // solves here; the alert's own `closeOnInteractOutside` is already
92
+ // `false` for any `alertdialog` role regardless of `modal`.
93
+ modal: !isAlert,
94
+ trapFocus: true,
95
+ preventScroll: true,
32
96
  onOpenChange(details) {
33
97
  onOpenChange(details.open);
34
98
  },
@@ -31,6 +31,19 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
31
31
  // content width: a floor sized for the text set overshoots the other two.
32
32
  // Dropped to `min-width: 0` so every op set sizes to its own content, the
33
33
  // same as any other menu here without an explicit floor.
34
+ //
35
+ // `.menu-item` also picked up `text-decoration: none` for #595's Import/
36
+ // Export menu — Attio's own "Import CSV" and "Learn about imports and
37
+ // exports" rows are real `<a>` tags (the middle two, pure JS actions, are
38
+ // plain `<div role="menuitem">`), and an anchor needs the underline reset
39
+ // every button-based `.menu-item` never needed. No-op for every existing
40
+ // button consumer of this class. Same menu's 2 dividers use `.menu-sep` on
41
+ // a real `<hr>` (implicit `role="separator"`, no extra ARIA plumbing needed)
42
+ // rather than a `<div role="separator">` — biome's a11y rules want a
43
+ // separator either genuinely interactive (`tabIndex`, `aria-valuenow`, a
44
+ // resizable-splitter kind of thing this isn't) or the semantic element
45
+ // instead; `.menu-sep` picked up `border: none` for it, a no-op for the
46
+ // existing plain-`<div>` consumers of this class.
34
47
  export const toolbarChromeHead = (p) => ` & .${CLASS_PREFIX}${p}-toolbar { display: flex; align-items: center; gap: ${c.space["2"]}; padding: 0 ${c.space["3"]} ${c.space["2"]}; }
35
48
  & .${CLASS_PREFIX}${p}-toolbar[data-inline] { padding-bottom: 0; }
36
49
  & .${CLASS_PREFIX}${p}-search { display: flex; align-items: center; gap: ${c.space["1.5"]}; height: ${c.size.row}; padding: 0 ${c.space["2.5"]}; border: none; border-radius: ${c.radius.md}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.borderSubtle}; color: ${c.fgMuted}; }
@@ -69,7 +82,7 @@ export const toolbarChromeBody = (p) => ` & .${CLASS_PREFIX}${p}-menu-head {
69
82
  & .${CLASS_PREFIX}${p}-sort-learn { display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; font-size: ${c.text.lg}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); text-decoration: none; }
70
83
  & .${CLASS_PREFIX}${p}-sort-learn svg { flex-shrink: 0; }
71
84
  & .${CLASS_PREFIX}${p}-sort-learn:hover { color: ${c.fgPrimary}; }
72
- & .${CLASS_PREFIX}${p}-menu-item { display: flex; align-items: center; gap: ${c.space["1.5"]}; width: 100%; height: ${c.size.menuRow}; margin: 0; padding: 0 ${c.space["2"]}; border: none; background: none; border-radius: ${c.radius.md}; font-family: inherit; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; text-align: left; cursor: pointer; }
85
+ & .${CLASS_PREFIX}${p}-menu-item { display: flex; align-items: center; gap: ${c.space["1.5"]}; width: 100%; height: ${c.size.menuRow}; margin: 0; padding: 0 ${c.space["2"]}; border: none; background: none; border-radius: ${c.radius.md}; font-family: inherit; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; text-align: left; text-decoration: none; cursor: pointer; }
73
86
  & .${CLASS_PREFIX}${p}-menu-item:hover { background: ${c.bgRaised}; }
74
87
  & .${CLASS_PREFIX}${p}-menu-item:disabled { opacity: 0.4; cursor: default; }
75
88
  & .${CLASS_PREFIX}${p}-menu-item:disabled:hover { background: none; }
@@ -77,7 +90,29 @@ export const toolbarChromeBody = (p) => ` & .${CLASS_PREFIX}${p}-menu-head {
77
90
  & .${CLASS_PREFIX}${p}-menu-item[data-on] svg { color: ${c.bgBrandSolid}; }
78
91
  & .${CLASS_PREFIX}${p}-menu-item .${CLASS_PREFIX}${p}-menu-check { margin-left: auto; }
79
92
  & .${CLASS_PREFIX}${p}-menu-input { height: ${c.size.row}; margin: ${c.space["0.5"]}; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; box-shadow: inset 0 0 0 1px ${c.borderSubtle}; background: ${c.bgSurface}; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; outline: none; }
80
- & .${CLASS_PREFIX}${p}-menu-sep { height: 1px; background: ${c.borderSubtle}; margin: ${c.space["0.5"]} 0; }
93
+ & .${CLASS_PREFIX}${p}-menu-sep { height: 1px; border: none; background: ${c.borderSubtle}; margin: ${c.space["0.5"]} 0; }
94
+ /* #592: the Filter field picker's nested-scope header (back control +
95
+ search, once drilled into a nestedColumns column) and a search
96
+ result's own breadcrumb row ("Team › Twitter follower count",
97
+ surfaced by the SAME query matching a nested column). menu-back
98
+ reuses the established 28px icon-only control pattern this file
99
+ already uses twice (sort-remove, the kebab segment) rather than
100
+ an independent measurement of Attio's own back arrow. The breadcrumb
101
+ colours ARE measured live: the parent segment muted at the same
102
+ exact color-mix 63% sort-learn above already uses, the
103
+ separator chevron and leaf segment both full fgPrimary (the
104
+ generic menu-item svg rule below defaults every icon to
105
+ fgMuted, so the separator needs its own override to read
106
+ correctly here). */
107
+ & .${CLASS_PREFIX}${p}-menu-nesthead { display: flex; align-items: center; gap: ${c.space["1"]}; margin: ${c.space["0.5"]}; }
108
+ & .${CLASS_PREFIX}${p}-menu-nesthead .${CLASS_PREFIX}${p}-menu-input { flex: 1; margin: 0; }
109
+ & .${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: ${c.radius.md}; background: none; color: ${c.fgMuted}; cursor: pointer; }
110
+ & .${CLASS_PREFIX}${p}-menu-back:hover { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
111
+ & .${CLASS_PREFIX}${p}-menu-item--expand { justify-content: space-between; }
112
+ & .${CLASS_PREFIX}${p}-menu-item-path { display: inline-flex; align-items: center; gap: 2px; min-width: 0; overflow: hidden; }
113
+ & .${CLASS_PREFIX}${p}-menu-item-path-parent { flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; max-width: 100px; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
114
+ & .${CLASS_PREFIX}${p}-menu-item-path-sep { flex-shrink: 0; color: ${c.fgPrimary}; }
115
+ & .${CLASS_PREFIX}${p}-menu-item-path > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
81
116
  & .${CLASS_PREFIX}${p}-menu--up { top: auto; bottom: calc(100% + ${c.space["1"]}); transform-origin: bottom; }
82
117
  & .${CLASS_PREFIX}${p}-menu--right { left: auto; right: 0; }
83
118
  & .${CLASS_PREFIX}${p}-menu--fixed { position: fixed; top: auto; left: auto; z-index: 40; }
@@ -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,11 @@ 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([]);
326
380
  // filters the "isanyof" checkbox list, same lifetime as filterQuery above
327
381
  const [anyOfQuery, setAnyOfQuery] = React.useState("");
328
382
  const [dragAttr, setDragAttr] = React.useState(null);
@@ -460,14 +514,57 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
460
514
  close: () => setOpenMenu(null),
461
515
  contentRef: opMenuRef,
462
516
  });
517
+ // The Import/Export menu is `role="menu"` too — confirmed live, 2026-09-25
518
+ // (an `<a>`/`<div>` mix, each `role="menuitem"`, two `role="separator"`
519
+ // dividers between the 3 groups) — same shared keyboard model.
520
+ const importExportMenuRef = React.useRef(null);
521
+ const { onKeyDown: onImportExportKeyDown } = useMenuKeyboard({
522
+ open: openMenu === "importexport",
523
+ close: () => setOpenMenu(null),
524
+ contentRef: importExportMenuRef,
525
+ });
463
526
  // Shared between the "Filter" trigger's own add-condition picker and each
464
527
  // 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)))] }));
528
+ // `filterColumns` list and differ only in what a pick does. #592: also
529
+ // handles drilling into a `nestedColumns` column — `fieldPickerPath`
530
+ // tracks the current scope ([] is the top level), reset to [] wherever
531
+ // the picker opens fresh (both trigger onClicks, above).
532
+ const filterFieldPicker = (onSelect) => {
533
+ const scope = fieldPickerPath.at(-1);
534
+ const scopeColumns = scope ? (scope.nestedColumns ?? []) : filterColumns;
535
+ const query = filterQuery.trim().toLowerCase();
536
+ const direct = scopeColumns.filter((c) => c.label.toLowerCase().includes(query));
537
+ // #592: one level of flattening from wherever the picker is currently
538
+ // scoped — a nested column's own children get searched too, and a
539
+ // match renders breadcrumbed, alongside this level's direct matches.
540
+ // Matches the one case measured live at the TOP level ("Twitter
541
+ // follower" surfaces both the direct field and "Team › Twitter
542
+ // follower count"); applying the identical rule recursively from an
543
+ // already-drilled-into scope (so it also reaches Team → Company → X
544
+ // while already browsing inside Team) is the same construct, one level
545
+ // relative to wherever you are, but wasn't independently re-verified
546
+ // live from inside a nested scope — flagged on the PR.
547
+ const nested = query
548
+ ? scopeColumns.flatMap((c) => (c.nestedColumns ?? [])
549
+ .filter((n) => n.label.toLowerCase().includes(query))
550
+ .map((n) => ({ parent: c, col: n })))
551
+ : [];
552
+ const selectPath = (path) => onSelect(path.map((c) => c.key).join("."));
553
+ const drillIn = (col) => {
554
+ setFieldPickerPath([...fieldPickerPath, col]);
555
+ setFilterQuery("");
556
+ };
557
+ 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: () => {
558
+ setFieldPickerPath(fieldPickerPath.slice(0, -1));
559
+ setFilterQuery("");
560
+ }, children: _jsx(ChevronLeft, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }) }), _jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: nestedFieldPlaceholder
561
+ ? nestedFieldPlaceholder(scope.nestedObjectLabel ?? scope.label.toLowerCase())
562
+ : `Search ${scope.nestedObjectLabel ?? scope.label.toLowerCase()} attributes…`, "aria-label": filterFieldLabel,
563
+ // biome-ignore lint/a11y/noAutofocus: focus the picker on open
564
+ autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) })] })) : (_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterFieldPlaceholder, "aria-label": filterFieldLabel,
565
+ // biome-ignore lint/a11y/noAutofocus: focus the picker on open
566
+ 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}`)))] }));
567
+ };
471
568
  const activeFilters = filters.length;
472
569
  // The default phrase only works in English word order, so a caller can
473
570
  // replace the whole thing rather than fill a template — crisp/#471's
@@ -504,12 +601,21 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
504
601
  label: (_jsxs("span", { className: "crisp-tabletoolbar-sort-optlabel", children: [_jsx(ArrowDownWideNarrow, { size: 15, strokeWidth: 1.75 }), descendingLabel] })),
505
602
  keywords: descendingLabel,
506
603
  },
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
604
+ ], "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: () => {
605
+ setFieldPickerPath([]);
606
+ setOpenMenu(openMenu === "filter" ? null : "filter");
607
+ }, "aria-haspopup": "dialog", "aria-expanded": openMenu === "filter", "data-menu-trigger": true, children: [_jsx(ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), filterTriggerLabel
508
608
  ? filterTriggerLabel(activeFilters)
509
609
  : activeFilters
510
610
  ? `${filterLabel} · ${activeFilters}`
511
611
  : 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);
612
+ // #592: cond.key may be a dot-joined nested path
613
+ // ("team.twitterFollowerCount") — resolve the full path so the
614
+ // field segment can render its breadcrumb; `col` (the leaf) still
615
+ // drives the operator set and value editor exactly as before, for
616
+ // both a plain and a nested key.
617
+ const colPath = resolveColumnPath(filterColumns, cond.key);
618
+ const col = colPath.at(-1);
513
619
  const fieldOpen = openMenu === `ffield:${cond.id}`;
514
620
  const opOpen = openMenu === `fop:${cond.id}`;
515
621
  const valOpen = openMenu === `fval:${cond.id}`;
@@ -525,7 +631,10 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
525
631
  // which never renders a third box in either case.
526
632
  const showValueSeg = cond.op != null &&
527
633
  (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
634
+ 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: () => {
635
+ setFieldPickerPath([]);
636
+ setOpenMenu(fieldOpen ? null : `ffield:${cond.id}`);
637
+ }, 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
638
  ? (crispMessages[OP_LABELS[cond.op]] ?? OP_LABELS[cond.op])
530
639
  : 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
640
  ? cond.values?.length
@@ -555,7 +664,18 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
555
664
  values: col.values?.filter((x) => set.has(x)),
556
665
  });
557
666
  }, children: v }, v));
558
- })] })) : col?.type === "date" ? (_jsxs("div", { className: "crisp-tabletoolbar-datepop", children: [_jsx(DatePicker, { ...{
667
+ })] })) : col?.type === "select" &&
668
+ col?.values &&
669
+ (cond.op === "is" || cond.op === "isnot") ? (_jsxs(_Fragment, { children: [_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter values`,
670
+ // biome-ignore lint/a11y/noAutofocus: focus the search field
671
+ autoFocus: true, value: anyOfQuery, onChange: (e) => setAnyOfQuery(e.target.value) }), col.values
672
+ .filter((v) => v
673
+ .toLowerCase()
674
+ .includes(anyOfQuery.trim().toLowerCase()))
675
+ .map((v) => (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", "data-on": cond.value === v ? "" : undefined, onClick: () => {
676
+ patchFilter(cond.id, { value: v });
677
+ setOpenMenu(null);
678
+ }, 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
679
  selectionMode: "single",
560
680
  value: isoToDate(cond.value)
561
681
  ? toDatePickerDate(isoToDate(cond.value))
@@ -604,7 +724,20 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
604
724
  moveAttr(dragAttr, i);
605
725
  setDragAttr(null);
606
726
  }, 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] }) }));
727
+ `${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] }) })] })))] })), (onImportCsv ||
728
+ onExportCsv ||
729
+ onExportExcel ||
730
+ 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: () => {
731
+ onImportCsv();
732
+ setOpenMenu(null);
733
+ }, 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: () => {
734
+ onExportCsv();
735
+ setOpenMenu(null);
736
+ }, children: [_jsx(Download, { size: 15, strokeWidth: 1.75 }), exportCsvLabel] })), onExportExcel && (_jsxs("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", onClick: () => {
737
+ onExportExcel();
738
+ setOpenMenu(null);
739
+ }, children: [_jsx(Download, { size: 15, strokeWidth: 1.75 }), exportExcelLabel] })), importExportHelpHref &&
740
+ (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
741
  }
609
742
  /**
610
743
  * 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,8 +33,22 @@ 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}; }
@@ -44,6 +58,24 @@ const filterChipChrome = `
44
58
  & .crisp-tabletoolbar-datepop-presets { display: flex; flex-wrap: wrap; gap: ${c.space["1"]}; margin-top: ${c.space["2"]}; padding: 0 ${c.space["1"]}; }
45
59
  & .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
60
  & .crisp-tabletoolbar-datepop-preset:hover { background: color-mix(in srgb, ${c.fgPrimary} 8%, transparent); }`;
61
+ // #595: the Import/Export trigger — measured live, `app.attio.com`'s own
62
+ // view bar: `28px` tall, `radius.md` (8px), background `bgSurfaceMuted`
63
+ // (`rgb(246,247,247)`, exact match) and `elevation.field`'s exact 3-layer
64
+ // shadow (`inset 0 0 0 1px transparent, 0 0 2px rgba(28,40,64,.18), 0 1px 3px
65
+ // rgba(0,0,0,.04)`) — a "raised chrome button" distinct from Sort/Filter/View
66
+ // settings' plain ringed `.tool` (no background, no elevation shadow), and
67
+ // the only trigger of the four with a trailing chevron (2 icons total,
68
+ // confirmed live). Overrides `.tool`'s own base background/shadow by cascade
69
+ // order (this block is appended after `toolbarChromeBody`, which defines
70
+ // them), the same way `.tool--ghost` already does. `border`/`font-family`/
71
+ // `cursor` are repeated here too, even though `.tool`'s own base rule (in
72
+ // the shared `tabletoolbar.chrome.ts`) already sets them on every button
73
+ // carrying this modifier — `spec-check.mjs`'s button-reset audit only reads
74
+ // each component's own `<name>.recipe.ts` file verbatim, so a reset living
75
+ // behind `chrome.ts`'s function call is invisible to it; harmless repeats,
76
+ // not a real gap.
77
+ const importExportChrome = `
78
+ & .crisp-tabletoolbar-tool--raised { border: none; background: ${c.bgSurfaceMuted}; box-shadow: ${e.field}; font-family: inherit; cursor: pointer; }`;
47
79
  // The table toolbar, measured: 28px controls, the Sort/Filter buttons and
48
80
  // the menus they open, sort-condition rows, and the filter chips. The chrome
49
81
  // itself lives in ./tabletoolbar.chrome.ts because RecordsTable renders the
@@ -61,6 +93,8 @@ const config = defineRecipe({
61
93
  ${toolbarChromeHead("tabletoolbar")}
62
94
  ${toolbarChromeBody("tabletoolbar")}
63
95
  ${filterChipChrome}
96
+ ${importExportChrome}
97
+ ${rtlFlipCss}
64
98
  `,
65
99
  });
66
100
  export const tabletoolbarRecipe = resolveRecipe(config);