@crisp-ui-kit/crisp 0.56.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/callout.css +6 -0
  2. package/dist/callout.d.ts +2 -2
  3. package/dist/callout.layered.css +6 -0
  4. package/dist/cjs/components/callout/callout.js +2 -2
  5. package/dist/cjs/components/callout/callout.recipe.js +15 -2
  6. package/dist/cjs/components/input/input.recipe.js +6 -0
  7. package/dist/cjs/components/menu/menu.js +2 -2
  8. package/dist/cjs/components/menu/menu.recipe.js +8 -0
  9. package/dist/cjs/components/multiselect/multiselect.js +23 -10
  10. package/dist/cjs/components/multiselect/multiselect.recipe.js +95 -65
  11. package/dist/cjs/components/select/listbox-keyboard.js +7 -1
  12. package/dist/cjs/components/select/select.js +24 -11
  13. package/dist/cjs/components/select/select.recipe.js +150 -78
  14. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +3 -3
  15. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +26 -1
  16. package/dist/cjs/components/tabs/tabs.js +63 -4
  17. package/dist/cjs/components/tabs/tabs.recipe.js +13 -0
  18. package/dist/cjs/components/textlink/textlink.js +2 -2
  19. package/dist/cjs/components/textlink/textlink.recipe.js +18 -0
  20. package/dist/cjs/product/recordpage/recordpage.js +17 -4
  21. package/dist/cjs/tokens/elevation.js +7 -0
  22. package/dist/cjs/tokens/palette.js +41 -0
  23. package/dist/cjs/tokens/scale.js +8 -0
  24. package/dist/cjs/tokens/semantic.js +18 -0
  25. package/dist/esm/components/callout/callout.js +2 -2
  26. package/dist/esm/components/callout/callout.recipe.js +15 -2
  27. package/dist/esm/components/input/input.recipe.js +6 -0
  28. package/dist/esm/components/menu/menu.js +2 -2
  29. package/dist/esm/components/menu/menu.recipe.js +8 -0
  30. package/dist/esm/components/multiselect/multiselect.js +23 -10
  31. package/dist/esm/components/multiselect/multiselect.recipe.js +95 -65
  32. package/dist/esm/components/select/listbox-keyboard.js +7 -1
  33. package/dist/esm/components/select/select.js +24 -11
  34. package/dist/esm/components/select/select.recipe.js +150 -78
  35. package/dist/esm/components/tabletoolbar/tabletoolbar.js +3 -3
  36. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +26 -1
  37. package/dist/esm/components/tabs/tabs.js +63 -4
  38. package/dist/esm/components/tabs/tabs.recipe.js +13 -0
  39. package/dist/esm/components/textlink/textlink.js +2 -2
  40. package/dist/esm/components/textlink/textlink.recipe.js +18 -0
  41. package/dist/esm/product/recordpage/recordpage.js +18 -5
  42. package/dist/esm/tokens/elevation.js +7 -0
  43. package/dist/esm/tokens/palette.js +41 -0
  44. package/dist/esm/tokens/scale.js +8 -0
  45. package/dist/esm/tokens/semantic.js +18 -0
  46. package/dist/input.css +4 -0
  47. package/dist/input.d.ts +2 -2
  48. package/dist/input.layered.css +4 -0
  49. package/dist/menu.css +8 -0
  50. package/dist/menu.layered.css +8 -0
  51. package/dist/multiselect.css +53 -29
  52. package/dist/multiselect.d.ts +3 -1
  53. package/dist/multiselect.layered.css +53 -29
  54. package/dist/native/tokens.d.ts +1 -1
  55. package/dist/native/tokens.js +49 -6
  56. package/dist/select.css +80 -42
  57. package/dist/select.d.ts +3 -2
  58. package/dist/select.layered.css +80 -42
  59. package/dist/styles.css +199 -72
  60. package/dist/styles.layered.css +199 -72
  61. package/dist/tabletoolbar.css +8 -1
  62. package/dist/tabletoolbar.layered.css +8 -1
  63. package/dist/tabs.css +6 -0
  64. package/dist/tabs.layered.css +6 -0
  65. package/dist/textlink.css +9 -0
  66. package/dist/textlink.d.ts +2 -1
  67. package/dist/textlink.layered.css +9 -0
  68. package/dist/tokens.css +25 -0
  69. package/dist/tokens.json +49 -6
  70. package/dist/types/components/callout/callout.d.ts +9 -1
  71. package/dist/types/components/callout/callout.recipe.d.ts +12 -2
  72. package/dist/types/components/input/input.d.ts +2 -0
  73. package/dist/types/components/input/input.recipe.d.ts +3 -0
  74. package/dist/types/components/menu/menu.d.ts +7 -1
  75. package/dist/types/components/multiselect/multiselect.d.ts +24 -6
  76. package/dist/types/components/multiselect/multiselect.recipe.d.ts +24 -0
  77. package/dist/types/components/select/listbox-keyboard.d.ts +10 -1
  78. package/dist/types/components/select/select.d.ts +28 -6
  79. package/dist/types/components/select/select.recipe.d.ts +22 -10
  80. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +14 -0
  81. package/dist/types/components/tabs/tabs.d.ts +9 -1
  82. package/dist/types/components/textlink/textlink.d.ts +8 -1
  83. package/dist/types/components/textlink/textlink.recipe.d.ts +10 -0
  84. package/dist/types/product/recordpage/recordpage.d.ts +25 -1
  85. package/dist/types/tokens/contract.d.ts +13 -3
  86. package/dist/types/tokens/elevation.d.ts +7 -0
  87. package/dist/types/tokens/palette.d.ts +36 -0
  88. package/dist/types/tokens/scale.d.ts +5 -0
  89. package/dist/types/tokens/semantic.d.ts +18 -0
  90. package/package.json +1 -1
@@ -1,24 +1,36 @@
1
1
  import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
2
  import { contract as c, elevation as e } from "../../tokens/index.js";
3
- // A select / dropdown, measured from settings: a 32px combobox trigger
4
- // (radius 9, value + a 16px chevron) over a listbox — radius 12, 4px padding, a
5
- // layered soft shadow — of 32px options (radius 8, 6/8 padding) that show a
6
- // check on the selected one and highlight on hover / keyboard. The listbox is
7
- // portaled to document.body (#339) so no ancestor's transform/backdrop-filter
8
- // (Dialog's own measured surface sets both) can capture its position: fixed
9
- // box; z-index 70 keeps it above Dialog/Drawer's z-index 60/61 layers, since
10
- // it's no longer nested inside whichever one opened it.
11
- // The listbox and everything inside it live in `globalCss` below, not
12
- // nested under `&` (#357): nesting compiles to a real descendant combinator
13
- // scoped to the recipe's root class, which needs actual DOM containment —
14
- // exactly what the portal to `document.body` breaks. Nested, these rules
15
- // matched nothing once the listbox moved and it rendered completely
16
- // unstyled. The trigger and its own parts stay nested under `&` — the
17
- // trigger is NOT portaled, it stays where it was authored. `font-family`/
18
- // smoothing are restated on the listbox's own rule (its children pick font
19
- // up via their own `font-family: inherit`, which still works — that
20
- // inheritance is intact WITHIN the portaled subtree, only the link back to
21
- // the trigger-holding root broke).
3
+ // seed-design's Select, resolved value-for-value from
4
+ // `@seed-design/rootage`'s select.yaml / select-trigger.yaml / select-item.yaml
5
+ // (crisp/#867) — a deliberate, owner-requested exception to "measure Attio
6
+ // first": this ONE component follows seed-design, not Attio, the same way
7
+ // DatePicker already follows seed's calendar structure. Every px/colour below
8
+ // traces to a resolved seed token (see the new entries in tokens/scale.ts,
9
+ // tokens/palette.ts, tokens/semantic.ts and tokens/elevation.ts, each
10
+ // commented with the seed token it came from) — nothing here is measured
11
+ // live, because there is no live Attio reference for this shape any more.
12
+ //
13
+ // crisp keeps its own `searchable` filter field and `triggerLabel` (#846):
14
+ // seed has no search of its own, and the owner asked to keep both. The
15
+ // listbox stays portaled to `document.body` for the same reason #339
16
+ // established (no ancestor's transform/backdrop-filter can capture its
17
+ // position) — see the sibling comment on `globalCss` below for why its rules
18
+ // live there rather than nested under `&`.
19
+ //
20
+ // Two things seed has that this recipe deliberately does NOT reproduce:
21
+ // - A JS-driven exit animation (seed's `exitDuration`/`exitScale`/
22
+ // `exitOpacity`, played before unmount). Every other crisp overlay
23
+ // (Dialog, Menu, ContextMenu, Popover) unmounts synchronously on close and
24
+ // only animates the ENTER transition via `@starting-style` — adding a
25
+ // delayed-unmount mechanism just for Select would be a new architectural
26
+ // pattern with no precedent anywhere else in the repo, for one component.
27
+ // The enter animation (scale 0.95→1, opacity 0→1, seed's own curve) IS
28
+ // reproduced below.
29
+ // - A visible `divider` slot: `select.yaml` defines one, but nothing in
30
+ // crisp or GRC groups options with an explicit separator today, and
31
+ // AUTHORING's rule against building what nothing consumes applies to CSS
32
+ // too. The resolved value is in `~/grc-parity/seed-select-spec.md` for
33
+ // whoever adds it.
22
34
  const config = defineRecipe({
23
35
  name: "select",
24
36
  base: `
@@ -26,87 +38,147 @@ const config = defineRecipe({
26
38
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
27
39
 
28
40
  & .crisp-select-trigger {
29
- box-sizing: border-box; display: inline-flex; align-items: center; gap: ${c.space["1.5"]};
30
- height: ${c.size.menuRow}; width: 100%; min-width: 0; margin: 0; padding: 0 ${c.space["1.5"]} 0 ${c.space["2"]};
31
- border: none; border-radius: ${c.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 7%, transparent);
32
- font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; cursor: pointer;
33
- transition: background-color .12s ease, box-shadow .12s ease;
41
+ box-sizing: border-box; display: inline-flex; align-items: center; width: 100%; min-width: 0; margin: 0;
42
+ border: 1px solid ${c.strokeSelectTrigger}; background: transparent; cursor: pointer;
43
+ font-family: inherit; color: ${c.fgSelectNeutral};
44
+ transition: background-color .15s cubic-bezier(.35,0,.35,1), border-color .1s cubic-bezier(.35,0,.35,1), border-width .1s cubic-bezier(.35,0,.35,1);
34
45
  }
35
- & .crisp-select-trigger:hover { background: ${c.bgRaised}; }
36
- & .crisp-select-trigger[data-open], & .crisp-select-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; background: ${c.bgSurface}; }
37
- & .crisp-select-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
38
- & .crisp-select-value { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; }
39
- & .crisp-select-placeholder { color: ${c.fgMuted}; }
40
- & .crisp-select-chevron { flex-shrink: 0; color: ${c.fgMuted}; transition: transform .15s ease; }
41
- & .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); }
46
+ /* seed defines no hover state for this trigger -- only pressed, invalid,
47
+ disabled and readonly. The scale-down is scoped to the CONTENT
48
+ (seed's scaleScope: content), not the button itself, so the border
49
+ never moves. */
50
+ /* Only the value scales, not the chevron: the chevron's own transform is
51
+ its open/close rotation, and a scale would fight it for one shared
52
+ CSS property when both are true at once (pressing while open). */
53
+ & .crisp-select-trigger:active:not(:disabled):not([aria-readonly="true"]) { background: ${c.bgSelectPressed}; }
54
+ & .crisp-select-trigger:active:not(:disabled):not([aria-readonly="true"]) .crisp-select-value { transform: scale(0.98); }
55
+ & .crisp-select-trigger:focus-visible { outline: 2px solid ${c.strokeFocusRing}; outline-offset: -2px; }
56
+ & .crisp-select-trigger:disabled { background: ${c.bgSelectDisabled}; cursor: not-allowed; }
57
+ & .crisp-select-trigger:disabled .crisp-select-value,
58
+ & .crisp-select-trigger:disabled .crisp-select-placeholder,
59
+ & .crisp-select-trigger:disabled .crisp-select-chevron { color: ${c.fgDisabled}; }
60
+ /* readonly: same fill as disabled, but the value/placeholder text stays
61
+ normal ink -- it reads as "answered and locked", not "answered and
62
+ greyed out" (select-trigger.yaml's readonly state only overrides
63
+ root.color; value/placeholder keep their enabled colours). */
64
+ & .crisp-select-trigger[aria-readonly="true"] { background: ${c.bgSelectDisabled}; cursor: default; }
65
+ & .crisp-select-value { flex: 1; min-width: 0; display: inline-flex; align-items: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; transition: transform .15s cubic-bezier(0,0,.15,1); }
66
+ & .crisp-select-placeholder { color: ${c.fgSelectPlaceholder}; }
67
+ /* closeRotateDuration/closeRotateTimingFunction: 100ms, seed's "easing"
68
+ curve. Overridden to 150ms on open (openRotateDuration) below --
69
+ same curve both ways, only the duration differs. */
70
+ & .crisp-select-chevron { flex-shrink: 0; color: ${c.fgNeutralMuted}; transition: transform .1s cubic-bezier(.35,0,.35,1); }
71
+ & .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); transition-duration: .15s; }
42
72
  `,
43
73
  globalCss: `
44
74
  /* pointer-events: auto (#375): a modal Dialog sets pointer-events: none
45
- on document.body while open (Zag re-enables it only on layers IT
46
- tracks), and the portaled listbox is outside Zag's knowledge -- it
47
- inherited the lock, so every option was unclickable, hit-testing
48
- right through to whatever real element sat behind it. */
75
+ on document.body while open, and the portaled listbox is outside its
76
+ knowledge -- it inherited the lock without this. */
49
77
  .crisp-select-listbox {
50
78
  font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
51
- position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
52
- max-height: 280px; overflow-y: auto; padding: ${c.space["1"]};
53
- border-radius: ${c.radius.xl}; background: ${c.bgPopover};
54
- box-shadow: ${e.floating};
55
- transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
79
+ position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space["2"]};
80
+ max-height: 480px; overflow-y: auto; padding: ${c.space["2"]};
81
+ border-radius: ${c.radius["3xl"]}; background: ${c.bgSelectListbox};
82
+ box-shadow: ${e.selectFloating};
83
+ transform-origin: top left; opacity: 1; transform: none;
84
+ transition: opacity .15s cubic-bezier(0,0,.15,1), transform .15s cubic-bezier(0,0,.15,1);
56
85
  }
57
- .crisp-select-listbox[data-up] { transform-origin: bottom; }
58
- .crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
59
- .crisp-select-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${c.space["1"]}) calc(-1 * ${c.space["1"]}) ${c.space["1"]}; padding: 0 ${c.space["2.5"]}; border-bottom: 1px solid ${c.borderSubtle}; color: ${c.fgMuted}; }
86
+ .crisp-select-listbox[data-up] { transform-origin: bottom left; }
87
+ .crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.95); } }
88
+ .crisp-select-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${c.space["2"]}) calc(-1 * ${c.space["2"]}) ${c.space["2"]}; padding: 0 ${c.space["2.5"]}; border-bottom: 1px solid ${c.borderSubtle}; color: ${c.fgNeutralMuted}; }
60
89
  .crisp-select-search svg { flex-shrink: 0; }
61
- .crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; }
62
- .crisp-select-search input::placeholder { color: ${c.fgMuted}; }
63
- .crisp-select-empty { padding: ${c.space["3"]} ${c.space["2"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }
64
- /* #343: same measured values as Menu's own group heading
65
- (crisp-menu-label) -- reused rather than re-measured, since it's the
66
- same "small muted heading above a run of rows" treatment. */
67
- .crisp-select-label { padding: ${c.space["1.5"]} ${c.space["2"]} ${c.space["0.5"]}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
90
+ .crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgSelectNeutral}; }
91
+ .crisp-select-search input::placeholder { color: ${c.fgSelectPlaceholder}; }
92
+ .crisp-select-empty { padding: ${c.space["3"]} ${c.space["4"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgNeutralSubtle}; }
93
+ /* select.yaml's groupLabel -- font-size/line-height/weight are the same
94
+ across sizes, only paddingY differs (medium 8px, large 10px). */
95
+ .crisp-select-label { padding: ${c.space["2"]} ${c.space["4"]} 0; font-size: ${c.text.md}; line-height: 18px; font-weight: ${c.weight.regular}; color: ${c.fgNeutralSubtle}; }
68
96
  .crisp-select-option {
69
- box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.menuRow}; flex-shrink: 0;
70
- padding: 0 ${c.space["2"]}; margin: 0; border: none; background: none; border-radius: ${c.radius.md};
71
- font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; text-align: start; cursor: pointer; white-space: nowrap;
97
+ box-sizing: border-box; display: flex; align-items: center; flex-shrink: 0;
98
+ padding: ${c.space["2.5"]} ${c.space["4"]}; margin-inline: 0; border: none; background: none; border-radius: ${c.radius.xl};
99
+ font-family: inherit; color: ${c.fgSelectNeutral}; text-align: start; cursor: pointer; white-space: nowrap;
100
+ transition: background-color .15s cubic-bezier(.35,0,.35,1), margin-inline .15s cubic-bezier(.35,0,.35,1);
72
101
  }
102
+ /* Keyboard/hover highlight keeps crisp's own visible neutral fill --
103
+ seed's spec has no "highlighted" state at all (only pressed/selected/
104
+ disabled), and its actual pressed tint (below) is a ~3% black hint,
105
+ too faint to serve as a keyboard-navigation indicator. */
73
106
  .crisp-select-option[data-highlighted] { background: ${c.bgRaised}; }
74
- .crisp-select-option[data-disabled] { opacity: 0.5; cursor: not-allowed; }
75
- .crisp-select-option-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${c.radius.full}; }
107
+ .crisp-select-option:active:not([data-disabled]) { background: ${c.bgSelectPressed}; margin-inline: ${c.space["2"]}; }
108
+ .crisp-select-option[data-disabled] { cursor: not-allowed; }
109
+ .crisp-select-option[data-disabled] .crisp-select-option-label,
110
+ .crisp-select-option[data-disabled] .crisp-select-option-description,
111
+ .crisp-select-option[data-disabled] .crisp-select-option-check { color: ${c.fgDisabled}; }
112
+ .crisp-select-option-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${c.radius.full}; margin-inline-end: ${c.space["2"]}; }
113
+ .crisp-select-option-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: ${c.space["0.5"]}; }
76
114
  .crisp-select-option-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
77
- .crisp-select-option-check { flex-shrink: 0; color: ${c.bgBrandSolid}; }
115
+ .crisp-select-option-description { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: ${c.fgNeutralSubtle}; }
116
+ .crisp-select-option-check { flex-shrink: 0; color: ${c.fgSelectNeutral}; margin-inline-start: ${c.space["2"]}; }
78
117
  `,
79
118
  variants: {
80
- // ghost: borderless at rest (transparent), reveals a subtle fill on hover —
81
- // inline selects (e.g. the sort configurator).
82
- variant: {
83
- default: "",
84
- ghost: `
85
- & .crisp-select-trigger { background: transparent; box-shadow: none; }
86
- & .crisp-select-trigger:hover { background: ${c.bgRaised}; box-shadow: none; }
87
- & .crisp-select-trigger[data-open], & .crisp-select-trigger:focus-visible { background: transparent; box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
88
- `,
89
- },
90
- // full (default): fills its container, as before. auto: sizes to content.
119
+ // full (default): fills its container. auto: sizes to content.
91
120
  width: {
92
121
  full: `width: 100%;`,
93
122
  auto: `width: auto;`,
94
123
  },
95
- // Same ring Input's `invalid` draws, adapted to Select's box-shadow-ring
96
- // trigger (a real <button>, so it needs no ::after overlay the way
97
- // Input/Textarea do). Outranks hover/open/focus, same reasoning as
98
- // Input: a field being corrected stays visibly wrong while it is.
99
- invalid: {
100
- true: `
101
- & .crisp-select-trigger,
102
- & .crisp-select-trigger:hover,
103
- & .crisp-select-trigger[data-open],
104
- & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${c.bgDanger}; }
124
+ // seed's two trigger/item sizes (select-trigger.yaml / select-item.yaml).
125
+ // medium is the default -- closest to crisp's previous single size.
126
+ size: {
127
+ medium: `
128
+ & .crisp-select-trigger { height: ${c.size.selectTriggerMd}; border-radius: ${c.radius.md}; gap: ${c.space["2"]}; padding-inline: ${c.space["3.5"]}; }
129
+ & .crisp-select-value, & .crisp-select-placeholder { font-size: ${c.text.lg}; line-height: 19px; }
130
+ & .crisp-select-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
131
+ .crisp-select-option { gap: ${c.space["2"]}; }
132
+ .crisp-select-option-label { font-size: ${c.text.lg}; line-height: 19px; }
133
+ .crisp-select-option-description { font-size: ${c.text.sm}; line-height: 16px; }
134
+ .crisp-select-option-check { width: ${c.space["3"]}; height: ${c.space["3"]}; }
135
+ .crisp-select-label { padding-top: ${c.space["2"]}; }
105
136
  `,
137
+ large: `
138
+ & .crisp-select-trigger { height: ${c.size.selectTriggerLg}; border-radius: ${c.radius.xl}; gap: ${c.space["2.5"]}; padding-inline: ${c.space["4"]}; }
139
+ & .crisp-select-value, & .crisp-select-placeholder { font-size: ${c.text.xl}; line-height: 22px; }
140
+ & .crisp-select-chevron { width: ${c.size.selectIconLg}; height: ${c.size.selectIconLg}; }
141
+ .crisp-select-option { gap: ${c.space["3"]}; }
142
+ .crisp-select-option-label { font-size: ${c.text.xl}; line-height: 22px; }
143
+ .crisp-select-option-description { font-size: ${c.text.md}; line-height: 18px; }
144
+ .crisp-select-option-check { width: ${c.space["3.5"]}; height: ${c.space["3.5"]}; }
145
+ .crisp-select-label { padding-top: ${c.space["2.5"]}; }
146
+ `,
147
+ },
148
+ // Real border width+colour change (seed: strokeWidth 1px→2px,
149
+ // strokeColor neutral-weak→critical-solid), not a box-shadow ring --
150
+ // crisp's own field/switch invalid rings don't apply here since this
151
+ // component's whole border model changed to match seed's real stroke.
152
+ invalid: {
153
+ true: `& .crisp-select-trigger { border-width: 2px; border-color: ${c.strokeSelectInvalid}; }`,
106
154
  false: ``,
107
155
  },
156
+ // ghost: borderless at rest, a subtle fill on hover -- inline selects
157
+ // (e.g. the sort configurator, and datatable's own cell editors, which
158
+ // embed at whatever height their row already is, not seed's fixed
159
+ // trigger height). Not a seed state; kept from crisp's existing API
160
+ // surface, layered on top of the seed base.
161
+ //
162
+ // Declared LAST on purpose: `resolveRecipe` emits one CSS rule per
163
+ // variant, in the order this object lists them, and same-specificity
164
+ // rules resolve by source order -- ghost's `height: 100%` below must
165
+ // come after `size`'s fixed height to win the cascade. Move `variant`
166
+ // earlier and every ghost consumer's trigger silently grows to 40/52px
167
+ // inside its 36px row again.
168
+ variant: {
169
+ default: "",
170
+ ghost: `
171
+ & .crisp-select-trigger { height: 100%; border-color: transparent; }
172
+ & .crisp-select-trigger:hover:not(:disabled) { background: ${c.bgSelectPressed}; }
173
+ `,
174
+ },
175
+ },
176
+ defaultVariants: {
177
+ width: "full",
178
+ size: "medium",
179
+ invalid: false,
180
+ variant: "default",
108
181
  },
109
- defaultVariants: { variant: "default", width: "full", invalid: false },
110
182
  compoundVariants: [],
111
183
  });
112
184
  export const selectRecipe = Object.assign(resolveRecipe(config), { config });
@@ -733,7 +733,7 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
733
733
  : sorts.length
734
734
  ? `${crispMessages["Sorted by"] ?? "Sorted by"} ${sortField}${sorts.length > 1 ? ` +${sorts.length - 1}` : ""}`
735
735
  : sortLabel;
736
- return (_jsx("div", { ref: rootRef, className: tabletoolbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-tabletoolbar-toolbar", "data-inline": inline ? "" : undefined, children: [showSort && (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `crisp-tabletoolbar-tool${sorts.length ? " crisp-tabletoolbar-tool--active" : " crisp-tabletoolbar-tool--dashed"}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [_jsx(ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortTriggerLabel || !sorts.length ? (sortedLabel) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-tabletoolbar-sorted-prefix", children: crispMessages["Sorted by"] ?? "Sorted by" }), " ", _jsx("span", { className: "crisp-tabletoolbar-sorted-field", children: sortField }), sorts.length > 1 && (_jsxs("span", { className: "crisp-tabletoolbar-sorted-count", children: [" ", "+", sorts.length - 1] }))] }))] }), openMenu === "sort" && (_jsxs("div", { ref: sortListRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? (_jsx("div", { className: "crisp-tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => (_jsxs("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
736
+ return (_jsx("div", { ref: rootRef, className: tabletoolbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-tabletoolbar-toolbar", "data-inline": inline ? "" : undefined, children: [showSort && (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `crisp-tabletoolbar-tool${sorts.length ? " crisp-tabletoolbar-tool--active" : " crisp-tabletoolbar-tool--dashed"}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [_jsx(ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortTriggerLabel || !sorts.length ? (sortedLabel) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-tabletoolbar-sorted-prefix", children: crispMessages["Sorted by"] ?? "Sorted by" }), " ", _jsx("span", { className: "crisp-tabletoolbar-sorted-field", children: sortField }), sorts.length > 1 && (_jsxs("span", { className: "crisp-tabletoolbar-sorted-count", children: ["+", sorts.length - 1] }))] }))] }), openMenu === "sort" && (_jsxs("div", { ref: sortListRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? (_jsx("div", { className: "crisp-tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => (_jsxs("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
737
737
  if (dragSort !== null)
738
738
  e.preventDefault();
739
739
  }, onDrop: () => {
@@ -928,12 +928,12 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
928
928
  * contents` so it contributes no box of its own — the same technique
929
929
  * `TableToolbar`'s own root already uses to disappear from layout.
930
930
  */
931
- export const ToolbarTool = React.forwardRef(({ active, ghost, className, ...rest }, ref) => (_jsx("span", { className: tabletoolbarRecipe(), children: _jsx("button", { ref: ref, type: "button", className: [
931
+ export const ToolbarTool = React.forwardRef(({ active, ghost, count, className, children, ...rest }, ref) => (_jsx("span", { className: tabletoolbarRecipe(), children: _jsxs("button", { ref: ref, type: "button", className: [
932
932
  "crisp-tabletoolbar-tool",
933
933
  active && "crisp-tabletoolbar-tool--active",
934
934
  ghost && "crisp-tabletoolbar-tool--ghost",
935
935
  className,
936
936
  ]
937
937
  .filter(Boolean)
938
- .join(" "), ...rest }) })));
938
+ .join(" "), ...rest, children: [children, count != null && (_jsx("span", { className: "crisp-tabletoolbar-tool-count", children: count }))] }) })));
939
939
  ToolbarTool.displayName = "ToolbarTool";
@@ -104,9 +104,33 @@ const importExportChrome = `
104
104
  // vs 14px) and its own mid-grey, distinct from the "Sorted by" muted tone.
105
105
  // TableToolbar-only, same reasoning as `filterChipChrome` above (RecordsTable
106
106
  // keeps its own older Sort label markup, not touched here).
107
+ // crisp#876: re-measured live -- the "+N" isn't just smaller/greyer text,
108
+ // it's a real chip: `background: rgba(0,0,0,.04)`, `box-shadow: inset 0 0 0
109
+ // 1px rgb(238,239,241)` (borderSubtle), `border-radius: 6px`, `padding: 0
110
+ // 4px`, 16px tall, colour `rgb(80,81,85)` -- bit-for-bit crisp's own Badge
111
+ // `tone="neutral" size="small"` shape, so this reuses those exact tokens
112
+ // (`fgNeutralSoft`, `borderSubtle`, `radius.sm`, `space["1"]`) rather than
113
+ // re-deriving new ones or leaving the old 73%-mix colour approximation.
107
114
  const sortedLabelChrome = `
108
115
  & .crisp-tabletoolbar-sorted-prefix { color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
109
- & .crisp-tabletoolbar-sorted-count { font-size: ${c.text.sm}; color: color-mix(in srgb, ${c.fgPrimary} 73%, transparent); }`;
116
+ & .crisp-tabletoolbar-sorted-count { display: inline-flex; align-items: center; height: 16px; box-sizing: border-box;
117
+ padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent);
118
+ box-shadow: inset 0 0 0 1px ${c.borderSubtle}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgNeutralSoft}; }`;
119
+ // crisp#875: a general count-badge slot for `ToolbarTool` (a screen's own
120
+ // quick filter, e.g. "My tasks •5") -- the SAME measured shape as
121
+ // `sorted-count` above, independently re-confirmed on a second live Attio
122
+ // surface this round (a Kanban board's own stage-column count, "Idea 0":
123
+ // identical `data-variant="tertiary" data-size="16"` chip, same bg/ring/
124
+ // radius/padding/colour, plus `font-variant-numeric: tabular-nums` for
125
+ // digit alignment past a single character). Same reasoning as
126
+ // search/menu-input's own duplicated ring formula elsewhere in this file:
127
+ // a distinct class, not a shared one, so `sorted-count`'s own meaning
128
+ // doesn't get diluted by an unrelated consumer.
129
+ const toolCountChrome = `
130
+ & .crisp-tabletoolbar-tool-count { display: inline-flex; align-items: center; height: 16px; box-sizing: border-box;
131
+ padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent);
132
+ box-shadow: inset 0 0 0 1px ${c.borderSubtle}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgNeutralSoft};
133
+ font-variant-numeric: tabular-nums; }`;
110
134
  // The table toolbar, measured: 28px controls, the Sort/Filter buttons and
111
135
  // the menus they open, sort-condition rows, and the filter chips. The chrome
112
136
  // itself lives in ./tabletoolbar.chrome.ts because RecordsTable renders the
@@ -126,6 +150,7 @@ ${toolbarChromeBody("tabletoolbar")}
126
150
  ${filterChipChrome}
127
151
  ${importExportChrome}
128
152
  ${sortedLabelChrome}
153
+ ${toolCountChrome}
129
154
  ${rtlFlipCss}
130
155
  `,
131
156
  });
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDown } from "lucide-react";
2
3
  import * as React from "react";
4
+ import { Menu, MenuItem } from "../menu/index.js";
3
5
  import { tabsRecipe } from "./tabs.recipe.js";
4
6
  import { useTablistKeyboard } from "./tabs-keyboard.js";
5
7
  /**
@@ -8,7 +10,7 @@ import { useTablistKeyboard } from "./tabs-keyboard.js";
8
10
  * Home/End jump); activation follows focus. Controlled via
9
11
  * `value`/`onValueChange`, or uncontrolled from `defaultValue`.
10
12
  */
11
- export function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, className, fill, "aria-label": ariaLabel, ...rest }) {
13
+ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, moreLabel = (n) => `+${n} more`, moreMenuLabel = "More tabs", className, fill, "aria-label": ariaLabel, ...rest }) {
12
14
  // Scoped to this instance: `tab-${value}` and `panel-${value}` are global,
13
15
  // so two tab bars sharing a value mint the same id twice and every
14
16
  // aria-labelledby resolves to the first one (#227).
@@ -41,16 +43,73 @@ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, childr
41
43
  ro.observe(list);
42
44
  return () => ro.disconnect();
43
45
  }, [active, tabs]);
46
+ // crisp#863 -- responsive "+N more" overflow, measured live: Attio
47
+ // collapses the TRAILING tabs into a single trigger once the strip runs
48
+ // out of room, rather than letting the row wrap or overrun. Every tab
49
+ // (and the "+N more" trigger itself) stays permanently mounted -- an
50
+ // overflowed one gets `data-tabs-overflow-hidden` (position: absolute;
51
+ // visibility: hidden in the recipe), which removes it from the flex row
52
+ // WITHOUT ever making its `offsetWidth` unmeasurable the way `display:
53
+ // none` would. That's what lets this recompute purely from cached
54
+ // per-tab widths on every resize, with no flash: a naive
55
+ // measure-everything-visible-first pass would show the full unclipped
56
+ // row for one frame before collapsing it.
57
+ const [hiddenFrom, setHiddenFrom] = React.useState(null);
58
+ const moreRef = React.useRef(null);
59
+ React.useLayoutEffect(() => {
60
+ const list = listRef.current;
61
+ if (!list || tabs.length === 0)
62
+ return;
63
+ const recompute = () => {
64
+ const containerWidth = list.clientWidth;
65
+ // Not yet laid out (e.g. this render, or the test environment, which
66
+ // never computes real box metrics) -- never collapse everything on a
67
+ // 0-width read, that's a false "nothing fits" rather than "not
68
+ // measurable yet".
69
+ if (containerWidth <= 0)
70
+ return;
71
+ const els = tabs.map((t) => list.querySelector(`[data-value="${CSS.escape(t.value)}"]`));
72
+ if (els.some((el) => !el))
73
+ return;
74
+ const gap = 4; // ${c.space["1"]}, the list's own gap (recipe.ts)
75
+ const moreWidth = moreRef.current?.offsetWidth ?? 0;
76
+ let used = 0;
77
+ let cutoff = tabs.length;
78
+ for (let i = 0; i < els.length; i++) {
79
+ const w = (els[i]?.offsetWidth ?? 0) + (i > 0 ? gap : 0);
80
+ const reserve = i < tabs.length - 1 ? moreWidth + gap : 0;
81
+ if (used + w + reserve > containerWidth) {
82
+ cutoff = i;
83
+ break;
84
+ }
85
+ used += w;
86
+ }
87
+ // The active tab is never allowed to fall into the overflow menu --
88
+ // not itself measured live (Attio's own fixture never exercised
89
+ // navigating INTO an overflowed tab first), but "the tab you're on
90
+ // stays visible" is the safe, unsurprising default for any tab UI.
91
+ const activeIndex = tabs.findIndex((t) => t.value === active);
92
+ if (activeIndex >= 0 && activeIndex >= cutoff)
93
+ cutoff = activeIndex + 1;
94
+ setHiddenFrom(cutoff >= tabs.length ? null : cutoff);
95
+ };
96
+ recompute();
97
+ const ro = new ResizeObserver(recompute);
98
+ ro.observe(list);
99
+ return () => ro.disconnect();
100
+ }, [tabs, active]);
101
+ const hiddenTabs = hiddenFrom == null ? [] : tabs.slice(hiddenFrom);
44
102
  const { onKeyDown, tabIndexFor } = useTablistKeyboard({
45
103
  tabs,
46
104
  active,
47
105
  select,
48
106
  listRef,
49
107
  });
50
- return (_jsxs("div", { className: tabsRecipe({ fill, className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t) => {
108
+ return (_jsxs("div", { className: tabsRecipe({ fill, className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t, i) => {
51
109
  const selected = t.value === active;
52
- return (_jsxs("button", { type: "button", role: "tab", id: tabId(t.value), "aria-selected": selected, "aria-controls": children && selected ? panelId(t.value) : undefined, tabIndex: tabIndexFor(t.value), "data-value": t.value, "data-selected": selected ? "" : undefined, disabled: t.disabled, className: "crisp-tabs-tab", onClick: () => select(t.value), children: [t.icon, _jsx("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && (_jsx("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value));
53
- }), indicator && (_jsx("span", { className: "crisp-tabs-indicator", style: {
110
+ const overflowHidden = hiddenFrom != null && i >= hiddenFrom;
111
+ return (_jsxs("button", { type: "button", role: "tab", id: tabId(t.value), "aria-selected": selected, "aria-controls": children && selected ? panelId(t.value) : undefined, tabIndex: overflowHidden ? -1 : tabIndexFor(t.value), "data-value": t.value, "data-selected": selected ? "" : undefined, "data-tabs-overflow-hidden": overflowHidden ? "" : undefined, disabled: t.disabled, className: "crisp-tabs-tab", onClick: () => select(t.value), children: [t.icon, _jsx("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && (_jsx("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value));
112
+ }), _jsx("span", { ref: moreRef, className: "crisp-tabs-more", "data-tabs-overflow-hidden": hiddenTabs.length === 0 ? "" : undefined, children: _jsx(Menu, { "aria-label": moreMenuLabel, trigger: _jsxs("button", { type: "button", className: "crisp-tabs-tab", children: [_jsx("span", { className: "tabs-tab-label", children: moreLabel(hiddenTabs.length) }), _jsx(ChevronDown, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }), children: hiddenTabs.map((t) => (_jsxs(MenuItem, { icon: t.icon, onSelect: () => select(t.value), children: [t.label, t.badge != null && (_jsx("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value))) }) }), indicator && (_jsx("span", { className: "crisp-tabs-indicator", style: {
54
113
  width: indicator.width,
55
114
  transform: `translateX(${indicator.left}px)`,
56
115
  } }))] }), children && (_jsx("div", { className: "crisp-tabs-panel", role: "tabpanel", id: panelId(active), "aria-labelledby": tabId(active),
@@ -54,6 +54,19 @@ const config = defineRecipe({
54
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}; }
55
55
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${c.fgPrimary}; }
56
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; }
57
+ /* crisp#863: the "+N more" overflow trigger, measured live -- its
58
+ computed style is identical to a plain .crisp-tabs-tab (same pill,
59
+ same 6px icon/label gap the tab class already gives via
60
+ space["1.5"]), and the dropdown it opens is crisp's own Menu/
61
+ MenuItem chrome (already matches Attio's popover, nothing new to
62
+ style there). Only the wrapper needs a rule: an overflowed tab (or
63
+ the trigger itself, when nothing is hidden) is taken out of the
64
+ flex row and out of the a11y tree without ever making its own
65
+ offsetWidth unmeasurable the way display:none would -- that's what
66
+ lets the tabs.tsx effect recompute the cutoff from cached widths on
67
+ every resize with no flash of an unclipped row. */
68
+ & .crisp-tabs-more { display: inline-flex; }
69
+ & [data-tabs-overflow-hidden] { position: absolute; visibility: hidden; pointer-events: none; }
57
70
  & .crisp-tabs-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; padding-top: ${c.space["8"]}; }
58
71
  & .crisp-tabs-panel:focus-visible { outline: none; }
59
72
  `,
@@ -7,7 +7,7 @@ import { textlinkRecipe } from "./textlink.recipe.js";
7
7
  * `event.preventDefault()` pairs it with client-side routing (e.g.
8
8
  * react-router's own `Link`).
9
9
  */
10
- export function TextLink({ tone = "muted", className, ...rest }) {
11
- return _jsx("a", { className: textlinkRecipe({ tone, className }), ...rest });
10
+ export function TextLink({ tone = "muted", size, className, ...rest }) {
11
+ return _jsx("a", { className: textlinkRecipe({ tone, size, className }), ...rest });
12
12
  }
13
13
  TextLink.displayName = "TextLink";
@@ -24,6 +24,24 @@ const config = defineRecipe({
24
24
  // that only goes brand on hover.
25
25
  muted: `font-size: ${c.text.md}; font-weight: ${c.weight.regular}; color: ${c.fgMuted}; &:hover { color: ${c.bgBrandSolid}; }`,
26
26
  },
27
+ // crisp#874: an explicit, OPTIONAL size override -- no `defaultVariants`
28
+ // entry below, so omitting `size` emits no `--size_*` class at all and
29
+ // each tone's own hard-coded font-size (above) stands completely
30
+ // unchanged. Only when a caller passes `size` explicitly does this
31
+ // override it: `size`'s CSS block is emitted AFTER `tone`'s in the
32
+ // generated stylesheet (`generate-recipes.mjs` walks `variants` in
33
+ // object-key order), so at equal specificity it wins the cascade.
34
+ // Measured live (a running GRC server, `getComputedStyle`):
35
+ // internal/grc's two primary-column record links
36
+ // (routes/lists/Lists.tsx:51, routes/registers/Registers.tsx:63) are
37
+ // 14px/500 ambient, inherited from DataTable's own
38
+ // `.crisp-datatable-entity-name` -- `size="lg"` matches that context
39
+ // without changing `brand`'s weight/colour.
40
+ size: {
41
+ sm: `font-size: ${c.text.sm};`,
42
+ md: `font-size: ${c.text.md};`,
43
+ lg: `font-size: ${c.text.lg};`,
44
+ },
27
45
  },
28
46
  defaultVariants: { tone: "muted" },
29
47
  compoundVariants: [],
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Activity as ActivityIcon, CalendarClock, ChevronDown, CircleDollarSign, Copy, DollarSign, FileText, Folder, Inbox, Landmark, LayoutGrid, Mail, Maximize2, Minimize2, MoreHorizontal, Pencil, Phone, Plus, Search, Share2, Sparkles, SquareCheck, Star, Trash2, Users, UsersRound, X, Zap, } from "lucide-react";
2
+ import { Activity as ActivityIcon, CalendarClock, ChevronDown, CircleDollarSign, Copy, DollarSign, FileText, Folder, Inbox, Landmark, LayoutGrid, Mail, Maximize2, Minimize2, MoreHorizontal, Pencil, Phone, Plus, Search, Sparkles, SquareCheck, Star, Trash2, Users, UsersRound, Workflow, X, Zap, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { Avatar } from "../../components/avatar/index.js";
5
5
  import { Checkbox } from "../../components/checkbox/index.js";
@@ -38,8 +38,12 @@ export const recordPageLabels = {
38
38
  copyRecordLink: "Copy record link",
39
39
  deleteRecord: "Delete record",
40
40
  linkRecords: "Link records",
41
- logANote: "Log a note",
42
- createATask: "Create a task",
41
+ // Tooltip copy re-measured live for #865 (was "Log a note"/"Create a
42
+ // task" — the field names stay, only the visible text changes to match
43
+ // Attio's own React-tooltip strings on these two icon buttons exactly).
44
+ logANote: "New note",
45
+ createATask: "New task",
46
+ runWorkflow: "Run workflow",
43
47
  fullScreen: "Full screen",
44
48
  exitFullScreen: "Exit full screen",
45
49
  resizeDetails: "Resize details column",
@@ -419,7 +423,7 @@ RecordDetails.displayName = "RecordDetails";
419
423
  * Emails sections (Notes & Tasks are live — add and check things off). Drop it
420
424
  * into the app's main content area.
421
425
  */
422
- export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, sections, highlights: highlightsProp, ...rest }) {
426
+ export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, sections, highlights: highlightsProp, quickActions: quickActionsProp, ...rest }) {
423
427
  const t = useRecordPageLabels(labels);
424
428
  const [favorite, setFavorite] = React.useState(false);
425
429
  // Notes/Tasks are controlled when the caller supplies `notes`/`tasks` (even
@@ -528,6 +532,15 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
528
532
  },
529
533
  ];
530
534
  const highlights = highlightsProp === undefined ? defaultHighlights : highlightsProp;
535
+ // #865: the built-in CRM icon set for the quick-action row, used when the
536
+ // caller omits `quickActions` -- same "caller's own list, not a merge"
537
+ // choice `tabs` (#379) and `highlights` (#528) already make.
538
+ const defaultQuickActions = [
539
+ { label: t.logANote, icon: FileText },
540
+ { label: t.runWorkflow, icon: Workflow },
541
+ { label: t.createATask, icon: SquareCheck },
542
+ ];
543
+ const quickActions = quickActionsProp ?? defaultQuickActions;
531
544
  const ti = { size: 15, strokeWidth: 1.75 };
532
545
  const builtinTabs = [
533
546
  { value: "overview", label: t.tabs.overview, icon: _jsx(LayoutGrid, { ...ti }) },
@@ -636,7 +649,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
636
649
  const effectivePanel = tabsProp
637
650
  ? (active) => tabsProp.find((tb) => tb.id === active)?.render()
638
651
  : builtinPanel;
639
- return (_jsxs("div", { className: recordpageRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-topbar", children: [_jsxs("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [_jsx("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), _jsx("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), _jsx("span", { className: "crisp-recordpage-crumb-name", children: name })] }), _jsxs("div", { className: "crisp-recordpage-actions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [_jsx(Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && (_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? (_jsx(Minimize2, { size: 15, strokeWidth: 1.75 })) : (_jsx(Maximize2, { size: 15, strokeWidth: 1.75 })) })), _jsxs(Menu, { align: "end", "aria-label": t.recordActions, trigger: _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [_jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), _jsx(MenuItem, { icon: _jsx(Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), _jsx(MenuItem, { icon: _jsx(Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), _jsxs("div", { className: "crisp-recordpage-body", children: [_jsxs("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [_jsxs("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : (_jsx(Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), _jsx("span", { className: "crisp-recordpage-entity-name", children: name }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: _jsx(Pencil, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "data-active": favorite ? "" : undefined, "aria-pressed": favorite, onClick: () => setFavorite((f) => !f), "aria-label": favorite ? t.removeFromFavourites : t.addToFavourites, children: _jsx(Star, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-quickactions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.copyRecordLink, children: _jsx(Copy, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.logANote, children: _jsx(FileText, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.linkRecords, children: _jsx(Share2, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.createATask, children: _jsx(SquareCheck, { size: 15, strokeWidth: 1.75 }) })] }), _jsx(RecordDetails, { record: record, columns: columns, sections: sections, onEdit: onEdit, labels: labels }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "crisp-recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), _jsx("div", { className: "crisp-recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": DETAILS_MIN_W, "aria-valuemax": DETAILS_MAX_W, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
652
+ return (_jsxs("div", { className: recordpageRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-topbar", children: [_jsxs("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [_jsx("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), _jsx("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), _jsx("span", { className: "crisp-recordpage-crumb-name", children: name })] }), _jsxs("div", { className: "crisp-recordpage-actions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [_jsx(Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && (_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? (_jsx(Minimize2, { size: 15, strokeWidth: 1.75 })) : (_jsx(Maximize2, { size: 15, strokeWidth: 1.75 })) })), _jsxs(Menu, { align: "end", "aria-label": t.recordActions, trigger: _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [_jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), _jsx(MenuItem, { icon: _jsx(Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), _jsx(MenuItem, { icon: _jsx(Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), _jsxs("div", { className: "crisp-recordpage-body", children: [_jsxs("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [_jsxs("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : (_jsx(Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), _jsx("span", { className: "crisp-recordpage-entity-name", children: name }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: _jsx(Pencil, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "data-active": favorite ? "" : undefined, "aria-pressed": favorite, onClick: () => setFavorite((f) => !f), "aria-label": favorite ? t.removeFromFavourites : t.addToFavourites, children: _jsx(Star, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-quickactions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), _jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Plus, { size: 16, strokeWidth: 1.75 }), t.addToList] }), quickActions.map((a, i) => (_jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": a.label, onClick: a.onSelect, children: _jsx(a.icon, { size: 15, strokeWidth: 1.75 }) }, i)))] }), _jsx(RecordDetails, { record: record, columns: columns, sections: sections, onEdit: onEdit, labels: labels }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "crisp-recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), _jsx("div", { className: "crisp-recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": DETAILS_MIN_W, "aria-valuemax": DETAILS_MAX_W, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
640
653
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
641
654
  return;
642
655
  // The pointer drag learned its direction in #212; this did not.
@@ -56,4 +56,11 @@ export const elevation = {
56
56
  * nobody measured for them.
57
57
  */
58
58
  fieldNoRing: `0 0 2px rgba(28,40,64,0.18), 0 1px 3px rgba(0,0,0,0.04)`,
59
+ /**
60
+ * Select's own listbox shadow (crisp/#867) — seed-design's `$shadow.s3`, a
61
+ * single layer, NOT `floating` above. This component follows seed, not
62
+ * Attio, for the same reason `DatePicker` follows seed's calendar
63
+ * structure: an explicit owner request to match seed-design exactly.
64
+ */
65
+ selectFloating: `0 4px 16px ${c.shadowSelect}`,
59
66
  };