@crisp-ui-kit/crisp 0.54.7 → 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 (130) hide show
  1. package/dist/authshell.css +5 -1
  2. package/dist/authshell.layered.css +5 -1
  3. package/dist/badge.css +5 -5
  4. package/dist/badge.layered.css +5 -5
  5. package/dist/board.css +36 -8
  6. package/dist/board.layered.css +36 -8
  7. package/dist/button.css +3 -1
  8. package/dist/button.layered.css +3 -1
  9. package/dist/cjs/components/badge/badge.recipe.js +28 -5
  10. package/dist/cjs/components/button/button.js +33 -2
  11. package/dist/cjs/components/button/button.recipe.js +31 -5
  12. package/dist/cjs/components/contextmenu/contextmenu.js +2 -2
  13. package/dist/cjs/components/contextmenu/contextmenu.recipe.js +24 -0
  14. package/dist/cjs/components/datatable/datatable.js +590 -41
  15. package/dist/cjs/components/datatable/datatable.recipe.js +118 -5
  16. package/dist/cjs/components/dialog/dialog.js +72 -5
  17. package/dist/cjs/components/dialog/dialog.recipe.js +15 -1
  18. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +49 -5
  19. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +365 -49
  20. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +78 -0
  21. package/dist/cjs/components/tabs/tabs.recipe.js +11 -2
  22. package/dist/cjs/components/tooltip/tooltip.js +23 -9
  23. package/dist/cjs/components/tooltip/tooltip.recipe.js +10 -9
  24. package/dist/cjs/product/authshell/authshell.js +14 -1
  25. package/dist/cjs/product/authshell/authshell.recipe.js +13 -1
  26. package/dist/cjs/product/board/board.js +175 -46
  27. package/dist/cjs/product/board/board.recipe.js +56 -4
  28. package/dist/cjs/product/comments/comments.js +34 -12
  29. package/dist/cjs/product/comments/comments.recipe.js +31 -7
  30. package/dist/cjs/product/formdialog/formdialog.js +10 -4
  31. package/dist/cjs/product/navitem/navitem.js +7 -2
  32. package/dist/cjs/product/navitem/navitem.recipe.js +16 -0
  33. package/dist/cjs/product/objectsettings/index.js +1 -2
  34. package/dist/cjs/product/objectsettings/objectsettings.js +3 -13
  35. package/dist/cjs/product/recordstable/recordstable.js +1 -17
  36. package/dist/cjs/product/richtext/richtext.js +76 -1
  37. package/dist/cjs/product/savedviews/savedviews.js +55 -24
  38. package/dist/cjs/product/savedviews/savedviews.recipe.js +27 -0
  39. package/dist/cjs/product/sidebar/sidebar.js +68 -15
  40. package/dist/cjs/product/sidebar/sidebar.recipe.js +6 -1
  41. package/dist/cjs/tokens/palette.js +32 -1
  42. package/dist/cjs/tokens/semantic.js +11 -0
  43. package/dist/comments.css +50 -14
  44. package/dist/comments.layered.css +50 -14
  45. package/dist/contextmenu.css +24 -0
  46. package/dist/contextmenu.layered.css +24 -0
  47. package/dist/datatable.css +101 -37
  48. package/dist/datatable.layered.css +101 -37
  49. package/dist/dialog.css +13 -1
  50. package/dist/dialog.layered.css +13 -1
  51. package/dist/esm/components/badge/badge.recipe.js +28 -5
  52. package/dist/esm/components/button/button.js +34 -3
  53. package/dist/esm/components/button/button.recipe.js +31 -5
  54. package/dist/esm/components/contextmenu/contextmenu.js +2 -2
  55. package/dist/esm/components/contextmenu/contextmenu.recipe.js +24 -0
  56. package/dist/esm/components/datatable/datatable.js +590 -42
  57. package/dist/esm/components/datatable/datatable.recipe.js +119 -6
  58. package/dist/esm/components/dialog/dialog.js +72 -5
  59. package/dist/esm/components/dialog/dialog.recipe.js +15 -1
  60. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +49 -5
  61. package/dist/esm/components/tabletoolbar/tabletoolbar.js +366 -50
  62. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +80 -2
  63. package/dist/esm/components/tabs/tabs.recipe.js +11 -2
  64. package/dist/esm/components/tooltip/tooltip.js +23 -9
  65. package/dist/esm/components/tooltip/tooltip.recipe.js +10 -9
  66. package/dist/esm/product/authshell/authshell.js +15 -2
  67. package/dist/esm/product/authshell/authshell.recipe.js +13 -1
  68. package/dist/esm/product/authshell/index.js +1 -1
  69. package/dist/esm/product/board/board.js +176 -47
  70. package/dist/esm/product/board/board.recipe.js +56 -4
  71. package/dist/esm/product/comments/comments.js +35 -13
  72. package/dist/esm/product/comments/comments.recipe.js +31 -7
  73. package/dist/esm/product/formdialog/formdialog.js +10 -4
  74. package/dist/esm/product/navitem/index.js +1 -1
  75. package/dist/esm/product/navitem/navitem.js +7 -2
  76. package/dist/esm/product/navitem/navitem.recipe.js +16 -0
  77. package/dist/esm/product/objectsettings/index.js +1 -1
  78. package/dist/esm/product/objectsettings/objectsettings.js +2 -12
  79. package/dist/esm/product/recordstable/recordstable.js +2 -18
  80. package/dist/esm/product/richtext/richtext.js +76 -1
  81. package/dist/esm/product/savedviews/savedviews.js +57 -26
  82. package/dist/esm/product/savedviews/savedviews.recipe.js +27 -0
  83. package/dist/esm/product/sidebar/sidebar.js +69 -16
  84. package/dist/esm/product/sidebar/sidebar.recipe.js +6 -1
  85. package/dist/esm/tokens/palette.js +32 -1
  86. package/dist/esm/tokens/semantic.js +11 -0
  87. package/dist/native/tokens.d.ts +1 -1
  88. package/dist/native/tokens.js +16 -2
  89. package/dist/navitem.css +7 -0
  90. package/dist/navitem.layered.css +7 -0
  91. package/dist/recordstable.css +34 -32
  92. package/dist/recordstable.layered.css +34 -32
  93. package/dist/savedviews.css +27 -0
  94. package/dist/savedviews.layered.css +27 -0
  95. package/dist/sidebar.css +6 -1
  96. package/dist/sidebar.layered.css +6 -1
  97. package/dist/styles.css +369 -106
  98. package/dist/styles.layered.css +369 -106
  99. package/dist/tabletoolbar.css +47 -3
  100. package/dist/tabletoolbar.layered.css +47 -3
  101. package/dist/tabs.css +1 -1
  102. package/dist/tabs.layered.css +1 -1
  103. package/dist/tokens.css +9 -1
  104. package/dist/tokens.json +16 -2
  105. package/dist/tooltip.css +1 -1
  106. package/dist/tooltip.layered.css +1 -1
  107. package/dist/types/components/badge/badge.recipe.d.ts +5 -1
  108. package/dist/types/components/button/button.recipe.d.ts +18 -5
  109. package/dist/types/components/contextmenu/contextmenu.d.ts +8 -1
  110. package/dist/types/components/datatable/datatable.d.ts +98 -1
  111. package/dist/types/components/dialog/dialog.d.ts +25 -2
  112. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +113 -3
  113. package/dist/types/components/tooltip/tooltip.d.ts +12 -4
  114. package/dist/types/components/tooltip/tooltip.recipe.d.ts +1 -1
  115. package/dist/types/product/authshell/authshell.d.ts +26 -2
  116. package/dist/types/product/authshell/index.d.ts +1 -1
  117. package/dist/types/product/board/board.d.ts +53 -6
  118. package/dist/types/product/comments/comments.d.ts +16 -3
  119. package/dist/types/product/formdialog/formdialog.d.ts +2 -1
  120. package/dist/types/product/navitem/index.d.ts +1 -1
  121. package/dist/types/product/navitem/navitem.d.ts +23 -0
  122. package/dist/types/product/objectsettings/index.d.ts +1 -1
  123. package/dist/types/product/objectsettings/objectsettings.d.ts +0 -10
  124. package/dist/types/product/recordstable/recordstable.d.ts +0 -1
  125. package/dist/types/product/savedviews/savedviews.d.ts +45 -8
  126. package/dist/types/product/sidebar/sidebar.d.ts +33 -5
  127. package/dist/types/tokens/contract.d.ts +4 -0
  128. package/dist/types/tokens/palette.d.ts +13 -1
  129. package/dist/types/tokens/semantic.d.ts +11 -0
  130. package/package.json +1 -1
@@ -1,6 +1,81 @@
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
+ // The filter condition chip, TableToolbar-only (crisp/#462 follow-up,
5
+ // re-measured live 2026-09-25). Deliberately NOT added to the shared
6
+ // `tabletoolbar.chrome.ts` functions: those also render under RecordsTable's
7
+ // own `crisp-recordstable-` prefix, whose still-tested (84 cases) chip
8
+ // predates this re-measurement and keeps its original `.fchip`/`.fchip-main`
9
+ // markup (one combined field+op+value button, plus a small floating kebab)
10
+ // until RecordsTable is rebuilt onto DataTable + TableToolbar and inherits
11
+ // this one instead. New, disjoint class names here so nothing shared changes
12
+ // meaning under the older markup.
13
+ //
14
+ // Measured on Attio's own chip (`app.attio.com/<ws>/companies`, a text-field
15
+ // condition): 4 independently-clickable, edge-to-edge segments, each 28px
16
+ // tall with its own `inset 0 0 0 1px` ring — field (radius 8/0/0/8), operator
17
+ // (flat), value (flat), kebab (radius 0/8/8/0). Background is NOT fixed per
18
+ // segment: the field and value segments (the two that show the condition's
19
+ // own text) read `rgb(246,247,247)` while the condition has no value yet and
20
+ // flip to white the instant it does (typing a value, or picking an operator
21
+ // like "empty" that needs none, both trigger it); the operator and kebab
22
+ // segments (pure controls, no text of the condition's own) stay white
23
+ // throughout. Click targets, confirmed live: the field segment reopens the
24
+ // attribute picker and replaces this condition's field in place (not a
25
+ // second chip); the operator segment opens an operator-only menu (closing it
26
+ // after a pick, then auto-opening the value popover, focused, when that
27
+ // operator needs one — "empty"/"not empty" just close); the value segment
28
+ // opens a value-only popover/input, independent of the operator menu (Attio
29
+ // never combines the two, unlike crisp's older single dialog).
30
+ const filterChipChrome = `
31
+ & .crisp-tabletoolbar-fcond { display: inline-flex; align-items: center; height: ${c.size.row}; font-size: ${c.text.md}; color: ${c.fgPrimary}; white-space: nowrap; }
32
+ & .crisp-tabletoolbar-fcond-seg { display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; height: 100%; margin: 0; padding: 0 ${c.space["2"]}; border: none; background: ${c.bgSurfaceMuted}; box-shadow: inset 0 0 0 1px ${c.borderDivider}; font-family: inherit; font-size: inherit; color: inherit; cursor: pointer; }
33
+ & .crisp-tabletoolbar-fcond-seg svg { flex-shrink: 0; }
34
+ & .crisp-tabletoolbar-fcond[data-complete] .crisp-tabletoolbar-fcond-seg--field,
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}; max-width: 200px; }
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; }
52
+ & .crisp-tabletoolbar-fcond-seg--op { background: ${c.bgSurface}; color: ${c.fgMuted}; }
53
+ & .crisp-tabletoolbar-fcond-seg--val { font-weight: ${c.weight.medium}; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
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}; }
55
+ & .crisp-tabletoolbar-fcond-seg--kebab:hover { color: ${c.fgPrimary}; }
56
+ & .crisp-tabletoolbar-menu--val { min-width: 134px; }
57
+ & .crisp-tabletoolbar-datepop { width: 300px; }
58
+ & .crisp-tabletoolbar-datepop-presets { display: flex; flex-wrap: wrap; gap: ${c.space["1"]}; margin-top: ${c.space["2"]}; padding: 0 ${c.space["1"]}; }
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; }
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; }`;
4
79
  // The table toolbar, measured: 28px controls, the Sort/Filter buttons and
5
80
  // the menus they open, sort-condition rows, and the filter chips. The chrome
6
81
  // itself lives in ./tabletoolbar.chrome.ts because RecordsTable renders the
@@ -17,6 +92,9 @@ const config = defineRecipe({
17
92
  & * { box-sizing: border-box; }
18
93
  ${toolbarChromeHead("tabletoolbar")}
19
94
  ${toolbarChromeBody("tabletoolbar")}
95
+ ${filterChipChrome}
96
+ ${importExportChrome}
97
+ ${rtlFlipCss}
20
98
  `,
21
99
  });
22
100
  export const tabletoolbarRecipe = resolveRecipe(config);
@@ -11,7 +11,16 @@ import { contract as c } from "../../tokens/index.js";
11
11
  // inactive tabs are muted and fill to a subtle pill on hover. Each tab carries
12
12
  // a 16px leading icon and an optional count badge. Measured from the live
13
13
  // app: bar `padding: 0 12px 8px`, 28px pills, 4/8 padding, 6px icon-label
14
- // gap, 14/500 labels, 10px badges (rgba(0,0,0,.04)).
14
+ // gap, 14/500 labels. The count badge (crisp#697, re-measured against a
15
+ // record's tab counts and a settings "Attributes 35" count) is 14px
16
+ // tall/wide-min, radius 4px (no scale token lands there -- a recipe-local
17
+ // literal, same precedent as this file's own icon/gap literals), 10/500/14
18
+ // text on a 4% `fgPrimary` tint (`rgba(0,0,0,.04)`) with a measured
19
+ // `inset 0 0 0 1px borderSubtle` ring, text `fgNeutralSoft` (rgb(80,81,85),
20
+ // the same token Badge's neutral tone uses, #696) -- was 16px/r6/11px/
21
+ // `fgMuted`/6% tint with no ring at all. The selected tab's own badge
22
+ // colour (`fgPrimary`) wasn't measured (the live Overview tab carries no
23
+ // count) and stays as-is.
15
24
  //
16
25
  // The panel's own `padding-top` (`c.space["8"]`, 32px) is measured
17
26
  // separately, live, on a company record page (Overview tab): the gap from
@@ -42,7 +51,7 @@ const config = defineRecipe({
42
51
  & .crisp-tabs-tab:hover:not([data-selected]) { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); color: ${c.fgPrimary}; }
43
52
  & .crisp-tabs-tab[data-selected] { color: ${c.fgPrimary}; background: ${c.bgSubtle}; box-shadow: inset 0 0 0 1px ${c.borderSubtle}; }
44
53
  & .crisp-tabs-tab:focus-visible { outline: 2px solid ${c.bgBrandSolid}; outline-offset: 1px; }
45
- & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); font-size: ${c.text.xs}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
54
+ & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 14px; height: 14px; padding: 0 ${c.space["1"]}; border-radius: 4px; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); box-shadow: inset 0 0 0 1px ${c.borderSubtle}; font-size: 10px; line-height: 14px; font-weight: ${c.weight.medium}; color: ${c.fgNeutralSoft}; }
46
55
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${c.fgPrimary}; }
47
56
  & .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: ${c.fgPrimary}; border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
48
57
  & .crisp-tabs-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; padding-top: ${c.space["8"]}; }
@@ -67,12 +67,24 @@ style: _style, ...rest }, forwardedRef) => {
67
67
  if (!el)
68
68
  return;
69
69
  const r = el.getBoundingClientRect();
70
- const below = side === "bottom" || r.top < 48;
71
- setPos({
72
- left: Math.round(r.left + r.width / 2),
73
- top: Math.round(below ? r.bottom + 6 : r.top - 6),
74
- below,
75
- });
70
+ const placement = side === "right"
71
+ ? "right"
72
+ : side === "bottom" || r.top < 48
73
+ ? "bottom"
74
+ : "top";
75
+ setPos(placement === "right"
76
+ ? {
77
+ // Measured (crisp#633): the bubble's left edge sits 8px past
78
+ // the trigger's right edge, vertically centred on it.
79
+ left: Math.round(r.right + 8),
80
+ top: Math.round(r.top + r.height / 2),
81
+ placement,
82
+ }
83
+ : {
84
+ left: Math.round(r.left + r.width / 2),
85
+ top: Math.round(placement === "bottom" ? r.bottom + 6 : r.top - 6),
86
+ placement,
87
+ });
76
88
  }, [open, side]);
77
89
  const openAfterDelay = () => {
78
90
  clearTimeout(timer.current);
@@ -119,9 +131,11 @@ style: _style, ...rest }, forwardedRef) => {
119
131
  createPortal(_jsx("div", { id: tipId, role: "tooltip", className: tooltipRecipe({ variant }), style: {
120
132
  left: pos.left,
121
133
  top: pos.top,
122
- transform: pos.below
123
- ? "translate(-50%, 0)"
124
- : "translate(-50%, -100%)",
134
+ transform: pos.placement === "right"
135
+ ? "translate(0, -50%)"
136
+ : pos.placement === "bottom"
137
+ ? "translate(-50%, 0)"
138
+ : "translate(-50%, -100%)",
125
139
  }, children: content }), document.body);
126
140
  if (asChild) {
127
141
  // No wrapper at all: the child carries the hover/focus/Escape
@@ -13,14 +13,15 @@ import { contract as c } from "../../tokens/index.js";
13
13
  // just the wrong pair.
14
14
  //
15
15
  // crisp#563: this `boxShadow` is Attio's PLAIN tooltip (a truncated header
16
- // label, measured on "Twitter follower count") — still an uncorroborated
17
- // single sample, flagged for its own re-measurement pass, not touched here.
18
- // Attio has a SECOND, visually similar but structurally different tooltip
19
- // for a reference-column header (icon + the referenced type's name after
20
- // the label, crisp#580) with its own measured shadow — an inset ring plus
21
- // two offset shadows, `variant: "reference"` below. Background, text
22
- // colour, radius, padding and font are identical between the two measured
23
- // samples; only the shadow differs.
16
+ // label, measured on "Twitter follower count"). Re-measured live and
17
+ // corroborated against a second sample (hovering the same field a second
18
+ // time) per AUTHORING.md Lesson 22 — the value below is an inset ring plus
19
+ // two offset shadows, not the single offset shadow this recipe carried
20
+ // before. Attio has a SECOND, visually similar but structurally different
21
+ // tooltip for a reference-column header (icon + the referenced type's name
22
+ // after the label, crisp#580) with its own measured shadow, `variant:
23
+ // "reference"` below. Background, text colour, radius, padding and font are
24
+ // identical between the two measured samples; only the shadow differs.
24
25
  const spec = defineSpec({
25
26
  name: "tooltip",
26
27
  base: {
@@ -37,7 +38,7 @@ const spec = defineSpec({
37
38
  fontSize: c.text.sm,
38
39
  fontWeight: c.weight.medium,
39
40
  lineHeight: "1.45",
40
- boxShadow: "0 4px 14px -2px rgba(28,40,64,0.28), 0 0 0 1px rgba(255,255,255,0.04)",
41
+ boxShadow: "rgb(33,34,36) 0 0 0 1px inset, rgba(0,0,0,0.04) 0 0 2px 0, rgba(0,0,0,0.12) 0 1px 3px 0",
41
42
  opacity: 1,
42
43
  transition: "opacity 0.12s ease",
43
44
  },
@@ -1,5 +1,18 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isValidElement } from "react";
2
3
  import { authshellRecipe } from "./authshell.recipe.js";
4
+ /** A plain `{ icon, title }` object (not a React element) — the shape
5
+ * `logo` renders as `AuthShell`'s own icon-badge + wordmark layout, as
6
+ * opposed to a caller-supplied node it just centers as-is. Same test
7
+ * `sidebar.tsx`'s `isSidebarFooterCopy` already uses for its own "plain
8
+ * shape vs. any node" prop. */
9
+ function isAuthShellBrand(value) {
10
+ return (typeof value === "object" &&
11
+ value !== null &&
12
+ !isValidElement(value) &&
13
+ "icon" in value &&
14
+ "title" in value);
15
+ }
3
16
  /**
4
17
  * The welcome/auth layout — measured live.
5
18
  * A centered page frame: an optional top progress bar, a logo, a two-pane
@@ -13,6 +26,6 @@ export function AuthShell({ progress, logo, title, children, preview, footer, cl
13
26
  const pct = hasProgress
14
27
  ? Math.round(Math.max(0, Math.min(1, progress)) * 100)
15
28
  : 0;
16
- return (_jsxs("div", { className: authshellRecipe({ className }), "data-single-pane": preview == null ? "" : undefined, ...rest, children: [hasProgress && (_jsx("div", { className: "crisp-authshell-progress", role: "progressbar", "aria-valuenow": pct, "aria-valuemin": 0, "aria-valuemax": 100, children: _jsx("div", { className: "crisp-authshell-progress-fill", style: { width: `${pct}%` } }) })), logo != null && _jsx("div", { className: "crisp-authshell-logo", children: logo }), _jsxs("div", { className: "crisp-authshell-card", children: [_jsx("div", { className: "crisp-authshell-pane", children: _jsxs("div", { className: "crisp-authshell-body", children: [title != null && (_jsx("div", { className: "crisp-authshell-title", children: title })), _jsx("div", { className: "crisp-authshell-form", children: children })] }) }), preview != null && (_jsx("div", { className: "crisp-authshell-preview", children: preview }))] }), footer != null && _jsx("div", { className: "crisp-authshell-footer", children: footer })] }));
29
+ return (_jsxs("div", { className: authshellRecipe({ className }), "data-single-pane": preview == null ? "" : undefined, ...rest, children: [hasProgress && (_jsx("div", { className: "crisp-authshell-progress", role: "progressbar", "aria-valuenow": pct, "aria-valuemin": 0, "aria-valuemax": 100, children: _jsx("div", { className: "crisp-authshell-progress-fill", style: { width: `${pct}%` } }) })), logo != null && (_jsx("div", { className: "crisp-authshell-logo", children: isAuthShellBrand(logo) ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "crisp-authshell-logo-badge", children: logo.icon }), _jsxs("div", { className: "crisp-authshell-logo-text", children: [_jsx("div", { className: "crisp-authshell-logo-title", children: logo.title }), logo.subtitle != null && (_jsx("div", { className: "crisp-authshell-logo-subtitle", children: logo.subtitle }))] })] })) : (logo) })), _jsxs("div", { className: "crisp-authshell-card", children: [_jsx("div", { className: "crisp-authshell-pane", children: _jsxs("div", { className: "crisp-authshell-body", children: [title != null && (_jsx("div", { className: "crisp-authshell-title", children: title })), _jsx("div", { className: "crisp-authshell-form", children: children })] }) }), preview != null && (_jsx("div", { className: "crisp-authshell-preview", children: preview }))] }), footer != null && _jsx("div", { className: "crisp-authshell-footer", children: footer })] }));
17
30
  }
18
31
  AuthShell.displayName = "AuthShell";
@@ -20,6 +20,14 @@ import { contract as c } from "../../tokens/index.js";
20
20
  // roughly centred in the viewport rather than pinned near the top.
21
21
  // `data-single-pane` now drops the card chrome and narrows the body to
22
22
  // match; the two-pane card is unchanged.
23
+ //
24
+ // your-moon/crisp#722: `.crisp-authshell-logo` is `min-height: 24px`, not
25
+ // `height` -- Attio's own mark (a single flat 96x24 SVG, measured live on
26
+ // the logged-out `/login` page) fits exactly at 24px, but a fixed height
27
+ // clipped any consumer logo taller than that. Found via internal/grc's real
28
+ // lockup (an icon badge beside a two-line TECH/tagline wordmark, ~36px
29
+ // tall), silently cut down to a bare one-line span rather than reported as
30
+ // not fitting.
23
31
  const config = defineRecipe({
24
32
  name: "authshell",
25
33
  base: `min-height: 100%; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; position: relative;
@@ -31,7 +39,11 @@ const config = defineRecipe({
31
39
  & .crisp-authshell-progress { position: absolute; top: 0; left: 0; width: 100%; height: 2px; background: ${c.borderDefault}; }
32
40
  & .crisp-authshell-progress-fill { height: 2px; background: ${c.fgPrimary}; border-radius: 4px; transition: width .2s; }
33
41
 
34
- & .crisp-authshell-logo { margin-top: ${c.space["8"]}; height: 24px; display: flex; align-items: center; }
42
+ & .crisp-authshell-logo { margin-top: ${c.space["8"]}; min-height: 24px; display: flex; align-items: center; gap: ${c.space["2.5"]}; }
43
+ & .crisp-authshell-logo-badge { flex-shrink: 0; box-sizing: border-box; width: 32px; height: 32px; border-radius: ${c.radius.md}; background: ${c.bgBrandSolid}; color: #fff; display: flex; align-items: center; justify-content: center; }
44
+ & .crisp-authshell-logo-text { display: flex; flex-direction: column; line-height: 1.2; }
45
+ & .crisp-authshell-logo-title { font-size: ${c.text.xl}; font-weight: ${c.weight.bold}; letter-spacing: -0.2px; color: ${c.fgPrimary}; }
46
+ & .crisp-authshell-logo-subtitle { font-size: ${c.text.sm}; color: ${c.fgMuted}; }
35
47
 
36
48
  & .crisp-authshell-card { margin-top: ${c.space["6"]}; display: flex; height: 692px; border-radius: 20px; border: 1px solid ${c.borderSubtle}; overflow: hidden; }
37
49
  &[data-single-pane] .crisp-authshell-card { height: auto; border: none; border-radius: 0; overflow: visible; }
@@ -1,2 +1,2 @@
1
- export { AuthShell } from "./authshell.js";
1
+ export { AuthShell, } from "./authshell.js";
2
2
  export { authshellRecipe } from "./authshell.recipe.js";
@@ -1,7 +1,8 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Check } from "lucide-react";
2
+ import { Check, Columns2, Columns3, Plus, Rows3, Square } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { Menu, MenuItem } from "../../components/menu/index.js";
5
+ import { useRadioGroupKeyboard } from "../../components/radiogroup/index.js";
5
6
  import { isRtlElement, useCrispMessage, useCrispMessages } from "../../core/index.js";
6
7
  import { boardRecipe } from "./board.recipe.js";
7
8
  const BOARD_AGG_ORDER = [
@@ -55,16 +56,50 @@ function computeBoardAgg(agg, values) {
55
56
  }
56
57
  }
57
58
  /**
58
- * A kanban board with native drag-and-drop between columns. Cards are
59
- * `draggable`; dropping one on a column reparents it via `onCardsChange`
60
- * (the parent owns the cards, so the move is a controlled state update).
61
- * Measured from a deals pipeline.
59
+ * A kanban board with pointer-events drag-and-drop between columns (#674 —
60
+ * Attio's own mechanism, confirmed live: mousedown, several mousemoves,
61
+ * mouseup, no `[draggable]` anywhere). Dropping a card on a column reparents
62
+ * it via `onCardsChange` (the parent owns the cards, so the move is a
63
+ * controlled state update). Measured from a deals pipeline.
62
64
  */
63
- export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, calc, className, ...rest }) {
65
+ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, calc, density = false, columnDensity: columnDensityProp, onColumnDensityChange, cardDensity: cardDensityProp, onCardDensityChange, collapseColumnsLabel: collapseColumnsLabelProp, expandColumnsLabel: expandColumnsLabelProp, collapseCardsLabel: collapseCardsLabelProp, expandCardsLabel: expandCardsLabelProp, columnDensityGroupLabel: columnDensityGroupLabelProp, cardDensityGroupLabel: cardDensityGroupLabelProp, onAddStage, addStageLabel: addStageLabelProp, className, ...rest }) {
64
66
  // crisp/#471: same resolution order as every other component's own —
65
67
  // explicit prop, then a CrispMessagesProvider's translation of the English
66
68
  // default, then that default itself.
67
69
  const newCardLabel = useCrispMessage(newCardLabelProp, "New");
70
+ const collapseColumnsLabel = useCrispMessage(collapseColumnsLabelProp, "Collapse columns");
71
+ const expandColumnsLabel = useCrispMessage(expandColumnsLabelProp, "Expand columns");
72
+ const collapseCardsLabel = useCrispMessage(collapseCardsLabelProp, "Collapse cards");
73
+ const expandCardsLabel = useCrispMessage(expandCardsLabelProp, "Expand cards");
74
+ const columnDensityGroupLabel = useCrispMessage(columnDensityGroupLabelProp, "Column density");
75
+ const cardDensityGroupLabel = useCrispMessage(cardDensityGroupLabelProp, "Card density");
76
+ const addStageLabel = useCrispMessage(addStageLabelProp, "Add stage");
77
+ // #718: same controlled/uncontrolled shape as `sort`/`selectedIds`
78
+ // elsewhere in crisp — explicit prop wins; otherwise this manages its own
79
+ // state, seeded "expand" (Attio's own board opens expanded by default).
80
+ const [internalColumnDensity, setInternalColumnDensity] = React.useState("expand");
81
+ const [internalCardDensity, setInternalCardDensity] = React.useState("expand");
82
+ const columnDensity = columnDensityProp ?? internalColumnDensity;
83
+ const cardDensity = cardDensityProp ?? internalCardDensity;
84
+ const setColumnDensity = (next) => {
85
+ onColumnDensityChange?.(next);
86
+ if (columnDensityProp === undefined)
87
+ setInternalColumnDensity(next);
88
+ };
89
+ const setCardDensity = (next) => {
90
+ onCardDensityChange?.(next);
91
+ if (cardDensityProp === undefined)
92
+ setInternalCardDensity(next);
93
+ };
94
+ // crisp's shared radio-group keyboard model (#200) — the same
95
+ // ←/→/↑/↓/Home/End-with-roving-tabindex model `Segmented`/`RadioGroup`
96
+ // already use, not a bespoke per-button handler (see that hook's own
97
+ // comment on the bug a per-button closure caused once already). Two
98
+ // independent hook instances: each density pair is its own radio group.
99
+ const { onKeyDown: onColumnDensityKeyDown, groupRef: columnDensityGroupRef } = useRadioGroupKeyboard({
100
+ select: (v) => setColumnDensity(v),
101
+ });
102
+ const { onKeyDown: onCardDensityKeyDown, groupRef: cardDensityGroupRef } = useRadioGroupKeyboard({ select: (v) => setCardDensity(v) });
68
103
  // #604: which BoardAgg each column's footer shows, keyed by column id.
69
104
  // Unset (undefined) means the column still shows the "+ Add calculation"
70
105
  // trigger, exactly like the table footer's own per-column state.
@@ -76,11 +111,37 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
76
111
  : `${crispMessages["Moved to"] ?? "Moved to"} ${column}`);
77
112
  const [dragId, setDragId] = React.useState(null);
78
113
  const [overCol, setOverCol] = React.useState(null);
114
+ const [ghost, setGhost] = React.useState(null);
79
115
  const [announcement, setAnnouncement] = React.useState("");
80
116
  // the card a keyboard move just sent to another column; React unmounts and
81
117
  // remounts it under the new parent, so focus has to be put back by hand
82
118
  const [refocusId, setRefocusId] = React.useState(null);
83
119
  const rootRef = React.useRef(null);
120
+ const colRefs = React.useRef(new Map());
121
+ // #674: which column a client point is over, read off each column's own
122
+ // rect at drag time rather than `elementFromPoint` -- the fixed-position
123
+ // ghost sits on top of everything (deliberately, so it never itself
124
+ // becomes the hit) and this is the one MEASURED interaction a test has a
125
+ // real handle on (`elementFromPoint` needs an actual layout engine bun
126
+ // test's happy-dom doesn't run).
127
+ //
128
+ // X only, not a full box: `.crisp-board`'s columns are a single
129
+ // non-wrapping row (`display: flex`, no `flex-wrap`), so X alone already
130
+ // identifies the column unambiguously. Checking Y against each column's
131
+ // OWN rect looked more precise but was actually a bug caught driving this
132
+ // in a real headed browser (Playwright), not by the tests passing --
133
+ // `align-items: flex-start` on the row means a short/empty column's own
134
+ // box ends where its content does, well short of a taller neighbour's, so
135
+ // a drop below that point silently found no column at all even while
136
+ // visually still inside the board.
137
+ const columnAt = (x) => {
138
+ for (const [id, el] of colRefs.current) {
139
+ const r = el.getBoundingClientRect();
140
+ if (x >= r.left && x <= r.right)
141
+ return id;
142
+ }
143
+ return null;
144
+ };
84
145
  const move = (cardId, toColumn) => {
85
146
  const card = cards.find((c) => c.id === cardId);
86
147
  if (!card || card.columnId === toColumn)
@@ -96,8 +157,77 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
96
157
  setRefocusId(null);
97
158
  }, [refocusId]);
98
159
  /**
99
- * Native HTML5 drag-and-drop is pointer-only by specification, so the board's
100
- * primary action needs its own key model or it has none at all.
160
+ * #674: Attio's own board drag is driven by pointer events (mousedown ->
161
+ * several mousemoves -> mouseup) -- confirmed live, no `[draggable]`
162
+ * anywhere -- not native HTML5 `draggable`/`onDragStart`, which is
163
+ * pointer-only by specification (no touch/pen support) and fires its own
164
+ * separate, browser-chrome-owned drag session crisp never controlled: no
165
+ * custom drag image, no way to redraw the dragged card while it moves.
166
+ * Pointer events give the board its own tracked drag state instead, the
167
+ * same primitive `resizeHandle`/`recordstable`'s own column-resize already
168
+ * use elsewhere in crisp.
169
+ *
170
+ * A drag doesn't "start" the instant the pointer goes down on a card --
171
+ * only once it has moved past a small threshold (4px). Below that, the
172
+ * gesture reads as a click/tap that never happened here (the card carries
173
+ * no click handler of its own today, but a future one would otherwise
174
+ * fire through a phantom near-zero-distance "drag").
175
+ */
176
+ const dragStateRef = React.useRef(null);
177
+ const DRAG_THRESHOLD = 4;
178
+ const onCardPointerDown = (e, card) => {
179
+ if (!e.isPrimary)
180
+ return;
181
+ const r = e.currentTarget.getBoundingClientRect();
182
+ dragStateRef.current = {
183
+ id: card.id,
184
+ pointerId: e.pointerId,
185
+ startX: e.clientX,
186
+ startY: e.clientY,
187
+ active: false,
188
+ grabDx: e.clientX - r.left,
189
+ grabDy: e.clientY - r.top,
190
+ width: r.width,
191
+ height: r.height,
192
+ };
193
+ };
194
+ const onCardPointerMove = (e) => {
195
+ const st = dragStateRef.current;
196
+ if (!st || st.pointerId !== e.pointerId)
197
+ return;
198
+ if (!st.active) {
199
+ const dx = e.clientX - st.startX;
200
+ const dy = e.clientY - st.startY;
201
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD)
202
+ return;
203
+ st.active = true;
204
+ setDragId(st.id);
205
+ e.currentTarget.setPointerCapture(e.pointerId);
206
+ }
207
+ setGhost({
208
+ x: e.clientX - st.grabDx,
209
+ y: e.clientY - st.grabDy,
210
+ width: st.width,
211
+ height: st.height,
212
+ });
213
+ setOverCol(columnAt(e.clientX));
214
+ };
215
+ const endCardDrag = (e, commit) => {
216
+ const st = dragStateRef.current;
217
+ if (!st || st.pointerId !== e.pointerId)
218
+ return;
219
+ if (commit && st.active && overCol)
220
+ move(st.id, overCol);
221
+ dragStateRef.current = null;
222
+ setDragId(null);
223
+ setOverCol(null);
224
+ setGhost(null);
225
+ };
226
+ /**
227
+ * Neither drag mechanism the board has used (the old native HTML5 kind,
228
+ * this file's own pointer-events one now) is reachable from a keyboard, so
229
+ * the board's primary action needs its own key model or it has none at
230
+ * all.
101
231
  *
102
232
  * Ctrl/Cmd + Left/Right rather than plain arrows: a screen reader owns the
103
233
  * bare arrow keys in browse mode, and a board scrolls horizontally, so plain
@@ -113,44 +243,43 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
113
243
  setRefocusId(card.id);
114
244
  return true;
115
245
  };
116
- return (_jsxs("div", { ref: rootRef, className: boardRecipe({ className }), ...rest, children: [columns.map((col) => {
117
- const colCards = cards.filter((c) => c.columnId === col.id);
118
- return (_jsxs("div", { className: "crisp-board-col", "data-over": overCol === col.id ? "" : undefined, onDragOver: (e) => {
119
- if (!dragId)
120
- return;
121
- e.preventDefault();
122
- setOverCol(col.id);
123
- }, onDragLeave: (e) => {
124
- if (!e.currentTarget.contains(e.relatedTarget)) {
125
- setOverCol((v) => (v === col.id ? null : v));
126
- }
127
- }, onDrop: (e) => {
128
- e.preventDefault();
129
- if (dragId)
130
- move(dragId, col.id);
131
- setDragId(null);
132
- setOverCol(null);
133
- }, children: [_jsxs("div", { className: "crisp-board-col-header", children: [col.accent && (_jsx("span", { className: "crisp-board-col-dot", style: { background: col.accent } })), _jsx("span", { className: "crisp-board-col-title", children: col.title }), _jsx("span", { className: "crisp-board-col-count", children: colCards.length }), onNewCard && (_jsx("button", { type: "button", className: "crisp-board-col-add", "aria-label": `${crispMessages["Add to"] ?? "Add to"} ${col.title}`, onClick: () => onNewCard(col.id), children: "+" }))] }), _jsxs("div", { className: "crisp-board-col-body", role: "list", "aria-label": col.title, children: [onNewCard && (_jsxs("button", { type: "button", className: "crisp-board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => (_jsx("div", { className: "crisp-board-card", role: "listitem",
134
- // biome-ignore lint/a11y/noNoninteractiveTabindex: the rule assumes a listitem cannot be operated. This one carries the board's only keyboard path to move a card, so it has to be reachable by Tab
135
- tabIndex: 0, "aria-label": getCardLabel?.(card), "data-card-id": card.id, draggable: true, "data-dragging": dragId === card.id ? "" : undefined, onDragStart: () => setDragId(card.id), onDragEnd: () => {
136
- setDragId(null);
137
- setOverCol(null);
138
- }, onKeyDown: (e) => {
139
- if (!(e.ctrlKey || e.metaKey))
140
- return;
141
- // the columns are a flex row, so under RTL the next one
142
- // is to the LEFT and the keys swap (#255)
143
- const rtl = isRtlElement(e.currentTarget);
144
- const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
145
- const backKey = rtl ? "ArrowRight" : "ArrowLeft";
146
- const delta = e.key === backKey ? -1 : e.key === forwardKey ? 1 : 0;
147
- // only swallow the key when the move actually happens, so
148
- // Ctrl+Right at the last column still reaches the browser
149
- if (delta && moveByKey(card, delta))
150
- e.preventDefault();
151
- }, children: renderCard(card) }, card.id)))] }), calc && (_jsx("div", { className: "crisp-board-col-footer", children: _jsx(Menu, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-board-col-calc", "data-empty": colAgg[col.id] ? undefined : "", children: colAgg[col.id] ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-board-col-calc-label", children: crispMessages[AGG_LABEL[colAgg[col.id]]] ??
152
- AGG_LABEL[colAgg[col.id]] }), _jsx("span", { className: "crisp-board-col-calc-value", children: computeBoardAgg(colAgg[col.id], colCards.map((c) => calc.getValue(c))) })] })) : ((crispMessages["+ Add calculation"] ??
153
- "+ Add calculation")) }), children: BOARD_AGG_ORDER.map((a) => (_jsxs(MenuItem, { onSelect: () => setColAgg((prev) => ({ ...prev, [col.id]: a })), children: [crispMessages[AGG_LABEL[a]] ?? AGG_LABEL[a], colAgg[col.id] === a ? (_jsx(Check, { size: 15, strokeWidth: 1.75 })) : null] }, a))) }) }))] }, col.id));
154
- }), _jsx("div", { className: "crisp-board-live", role: "status", "aria-live": "polite", children: announcement })] }));
246
+ return (_jsxs("div", { ref: rootRef, className: boardRecipe({ className }), ...rest, children: [density && (_jsxs("div", { className: "crisp-board-toolbar", children: [_jsxs("div", { ref: columnDensityGroupRef, className: "crisp-board-density", role: "radiogroup", "aria-label": columnDensityGroupLabel, onKeyDown: onColumnDensityKeyDown, children: [_jsx("button", { type: "button", role: "radio", "data-value": "collapse", "aria-checked": columnDensity === "collapse", "aria-label": collapseColumnsLabel, "data-state": columnDensity === "collapse" ? "on" : "off", onClick: () => setColumnDensity("collapse"), children: _jsx(Columns3, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("button", { type: "button", role: "radio", "data-value": "expand", "aria-checked": columnDensity === "expand", "aria-label": expandColumnsLabel, "data-state": columnDensity === "expand" ? "on" : "off", onClick: () => setColumnDensity("expand"), children: _jsx(Columns2, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), _jsxs("div", { ref: cardDensityGroupRef, className: "crisp-board-density", role: "radiogroup", "aria-label": cardDensityGroupLabel, onKeyDown: onCardDensityKeyDown, children: [_jsx("button", { type: "button", role: "radio", "data-value": "collapse", "aria-checked": cardDensity === "collapse", "aria-label": collapseCardsLabel, "data-state": cardDensity === "collapse" ? "on" : "off", onClick: () => setCardDensity("collapse"), children: _jsx(Rows3, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("button", { type: "button", role: "radio", "data-value": "expand", "aria-checked": cardDensity === "expand", "aria-label": expandCardsLabel, "data-state": cardDensity === "expand" ? "on" : "off", onClick: () => setCardDensity("expand"), children: _jsx(Square, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] })] })), _jsxs("div", { className: "crisp-board-row", children: [columns.map((col) => {
247
+ const colCards = cards.filter((c) => c.columnId === col.id);
248
+ return (_jsxs("div", { ref: (el) => {
249
+ if (el)
250
+ colRefs.current.set(col.id, el);
251
+ else
252
+ colRefs.current.delete(col.id);
253
+ }, className: "crisp-board-col", "data-over": overCol === col.id ? "" : undefined, "data-density": columnDensity === "collapse" ? "collapse" : undefined, children: [_jsxs("div", { className: "crisp-board-col-header", children: [col.accent && (_jsx("span", { className: "crisp-board-col-dot", style: { background: col.accent } })), _jsx("span", { className: "crisp-board-col-title", children: col.title }), _jsx("span", { className: "crisp-board-col-count", children: colCards.length }), onNewCard && columnDensity !== "collapse" && (_jsx("button", { type: "button", className: "crisp-board-col-add", "aria-label": `${crispMessages["Add to"] ?? "Add to"} ${col.title}`, onClick: () => onNewCard(col.id), children: "+" }))] }), _jsxs("div", { className: "crisp-board-col-body", role: "list", "aria-label": col.title, children: [onNewCard && (_jsxs("button", { type: "button", className: "crisp-board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => (_jsx("div", { className: "crisp-board-card", role: "listitem",
254
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: the rule assumes a listitem cannot be operated. This one carries the board's only keyboard path to move a card, so it has to be reachable by Tab
255
+ tabIndex: 0, "aria-label": getCardLabel?.(card), "data-card-id": card.id, "data-dragging": dragId === card.id ? "" : undefined, "data-density": cardDensity === "collapse" ? "collapse" : undefined, onPointerDown: (e) => onCardPointerDown(e, card), onPointerMove: onCardPointerMove, onPointerUp: (e) => endCardDrag(e, true), onPointerCancel: (e) => endCardDrag(e, false), onKeyDown: (e) => {
256
+ if (!(e.ctrlKey || e.metaKey))
257
+ return;
258
+ // the columns are a flex row, so under RTL the next one
259
+ // is to the LEFT and the keys swap (#255)
260
+ const rtl = isRtlElement(e.currentTarget);
261
+ const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
262
+ const backKey = rtl ? "ArrowRight" : "ArrowLeft";
263
+ const delta = e.key === backKey ? -1 : e.key === forwardKey ? 1 : 0;
264
+ // only swallow the key when the move actually happens, so
265
+ // Ctrl+Right at the last column still reaches the browser
266
+ if (delta && moveByKey(card, delta))
267
+ e.preventDefault();
268
+ }, children: renderCard(card, cardDensity) }, card.id)))] }), calc && (_jsx("div", { className: "crisp-board-col-footer", children: _jsx(Menu, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-board-col-calc", "data-empty": colAgg[col.id] ? undefined : "", children: colAgg[col.id] ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-board-col-calc-label", children: crispMessages[AGG_LABEL[colAgg[col.id]]] ??
269
+ AGG_LABEL[colAgg[col.id]] }), _jsx("span", { className: "crisp-board-col-calc-value", children: computeBoardAgg(colAgg[col.id], colCards.map((c) => calc.getValue(c))) })] })) : ((crispMessages["+ Add calculation"] ??
270
+ "+ Add calculation")) }), children: BOARD_AGG_ORDER.map((a) => (_jsxs(MenuItem, { onSelect: () => setColAgg((prev) => ({ ...prev, [col.id]: a })), children: [crispMessages[AGG_LABEL[a]] ?? AGG_LABEL[a], colAgg[col.id] === a ? (_jsx(Check, { size: 15, strokeWidth: 1.75 })) : null] }, a))) }) }))] }, col.id));
271
+ }), onAddStage && (_jsx("button", { type: "button", className: "crisp-board-addstage", "aria-label": addStageLabel, onClick: onAddStage, children: _jsx(Plus, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }))] }), dragId &&
272
+ ghost &&
273
+ (() => {
274
+ const draggedCard = cards.find((c) => c.id === dragId);
275
+ if (!draggedCard)
276
+ return null;
277
+ return (_jsx("div", { className: "crisp-board-card crisp-board-ghost", style: {
278
+ left: ghost.x,
279
+ top: ghost.y,
280
+ width: ghost.width,
281
+ height: ghost.height,
282
+ }, children: renderCard(draggedCard, cardDensity) }));
283
+ })(), _jsx("div", { className: "crisp-board-live", role: "status", "aria-live": "assertive", children: announcement })] }));
155
284
  }
156
285
  Board.displayName = "Board";