@crisp-ui-kit/crisp 0.56.0 → 0.57.1

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 (139) hide show
  1. package/dist/accentpicker.css +5 -0
  2. package/dist/accentpicker.d.ts +7 -0
  3. package/dist/accentpicker.layered.css +7 -0
  4. package/dist/avatar.css +1 -1
  5. package/dist/avatar.layered.css +1 -1
  6. package/dist/badge.css +2 -2
  7. package/dist/badge.layered.css +2 -2
  8. package/dist/button.css +3 -3
  9. package/dist/button.layered.css +3 -3
  10. package/dist/callout.css +6 -0
  11. package/dist/callout.d.ts +2 -2
  12. package/dist/callout.layered.css +6 -0
  13. package/dist/cjs/components/accentpicker/accentpicker.js +66 -0
  14. package/dist/cjs/components/accentpicker/accentpicker.recipe.js +25 -0
  15. package/dist/cjs/components/accentpicker/index.js +7 -0
  16. package/dist/cjs/components/avatar/avatar.recipe.js +4 -1
  17. package/dist/cjs/components/badge/badge.recipe.js +16 -2
  18. package/dist/cjs/components/button/button.recipe.js +16 -6
  19. package/dist/cjs/components/callout/callout.js +2 -2
  20. package/dist/cjs/components/callout/callout.recipe.js +15 -2
  21. package/dist/cjs/components/input/input.js +2 -2
  22. package/dist/cjs/components/input/input.recipe.js +14 -0
  23. package/dist/cjs/components/menu/menu.js +2 -2
  24. package/dist/cjs/components/menu/menu.recipe.js +8 -0
  25. package/dist/cjs/components/multiselect/multiselect.js +23 -10
  26. package/dist/cjs/components/multiselect/multiselect.recipe.js +104 -65
  27. package/dist/cjs/components/progress/progress.js +21 -4
  28. package/dist/cjs/components/progress/progress.recipe.js +30 -0
  29. package/dist/cjs/components/select/listbox-keyboard.js +7 -1
  30. package/dist/cjs/components/select/select.js +24 -11
  31. package/dist/cjs/components/select/select.recipe.js +177 -78
  32. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +3 -3
  33. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +26 -1
  34. package/dist/cjs/components/tabs/tabs.js +63 -4
  35. package/dist/cjs/components/tabs/tabs.recipe.js +13 -0
  36. package/dist/cjs/components/text/text.recipe.js +8 -7
  37. package/dist/cjs/components/textarea/textarea.js +3 -2
  38. package/dist/cjs/components/textarea/textarea.recipe.js +2 -1
  39. package/dist/cjs/components/textlink/textlink.js +2 -2
  40. package/dist/cjs/components/textlink/textlink.recipe.js +24 -3
  41. package/dist/cjs/index.js +1 -0
  42. package/dist/cjs/product/recordpage/recordpage.js +61 -7
  43. package/dist/cjs/tokens/elevation.js +7 -0
  44. package/dist/cjs/tokens/palette.js +120 -25
  45. package/dist/cjs/tokens/scale.js +9 -0
  46. package/dist/cjs/tokens/semantic.js +42 -0
  47. package/dist/esm/components/accentpicker/accentpicker.js +30 -0
  48. package/dist/esm/components/accentpicker/accentpicker.recipe.js +22 -0
  49. package/dist/esm/components/accentpicker/index.js +2 -0
  50. package/dist/esm/components/avatar/avatar.recipe.js +4 -1
  51. package/dist/esm/components/badge/badge.recipe.js +16 -2
  52. package/dist/esm/components/button/button.recipe.js +16 -6
  53. package/dist/esm/components/callout/callout.js +2 -2
  54. package/dist/esm/components/callout/callout.recipe.js +15 -2
  55. package/dist/esm/components/input/input.js +3 -3
  56. package/dist/esm/components/input/input.recipe.js +14 -0
  57. package/dist/esm/components/menu/menu.js +2 -2
  58. package/dist/esm/components/menu/menu.recipe.js +8 -0
  59. package/dist/esm/components/multiselect/multiselect.js +23 -10
  60. package/dist/esm/components/multiselect/multiselect.recipe.js +104 -65
  61. package/dist/esm/components/progress/progress.js +22 -5
  62. package/dist/esm/components/progress/progress.recipe.js +30 -0
  63. package/dist/esm/components/select/listbox-keyboard.js +7 -1
  64. package/dist/esm/components/select/select.js +24 -11
  65. package/dist/esm/components/select/select.recipe.js +177 -78
  66. package/dist/esm/components/tabletoolbar/tabletoolbar.js +3 -3
  67. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +26 -1
  68. package/dist/esm/components/tabs/tabs.js +63 -4
  69. package/dist/esm/components/tabs/tabs.recipe.js +13 -0
  70. package/dist/esm/components/text/text.recipe.js +8 -7
  71. package/dist/esm/components/textarea/textarea.js +3 -2
  72. package/dist/esm/components/textarea/textarea.recipe.js +2 -1
  73. package/dist/esm/components/textlink/textlink.js +2 -2
  74. package/dist/esm/components/textlink/textlink.recipe.js +24 -3
  75. package/dist/esm/index.js +1 -0
  76. package/dist/esm/product/recordpage/recordpage.js +62 -8
  77. package/dist/esm/tokens/elevation.js +7 -0
  78. package/dist/esm/tokens/palette.js +120 -25
  79. package/dist/esm/tokens/scale.js +9 -0
  80. package/dist/esm/tokens/semantic.js +42 -0
  81. package/dist/input.css +11 -0
  82. package/dist/input.d.ts +2 -2
  83. package/dist/input.layered.css +11 -0
  84. package/dist/menu.css +8 -0
  85. package/dist/menu.layered.css +8 -0
  86. package/dist/multiselect.css +57 -29
  87. package/dist/multiselect.d.ts +3 -1
  88. package/dist/multiselect.layered.css +57 -29
  89. package/dist/native/tokens.d.ts +1 -1
  90. package/dist/native/tokens.js +82 -28
  91. package/dist/progress.css +3 -0
  92. package/dist/progress.layered.css +3 -0
  93. package/dist/select.css +100 -42
  94. package/dist/select.d.ts +3 -2
  95. package/dist/select.layered.css +100 -42
  96. package/dist/styles.css +268 -93
  97. package/dist/styles.layered.css +268 -93
  98. package/dist/tabletoolbar.css +8 -1
  99. package/dist/tabletoolbar.layered.css +8 -1
  100. package/dist/tabs.css +6 -0
  101. package/dist/tabs.layered.css +6 -0
  102. package/dist/text.css +1 -1
  103. package/dist/text.layered.css +1 -1
  104. package/dist/textarea.css +2 -1
  105. package/dist/textarea.layered.css +2 -1
  106. package/dist/textlink.css +11 -2
  107. package/dist/textlink.d.ts +2 -1
  108. package/dist/textlink.layered.css +11 -2
  109. package/dist/tokens.css +43 -11
  110. package/dist/tokens.json +82 -28
  111. package/dist/types/components/accentpicker/accentpicker.d.ts +23 -0
  112. package/dist/types/components/accentpicker/accentpicker.recipe.d.ts +3 -0
  113. package/dist/types/components/accentpicker/index.d.ts +2 -0
  114. package/dist/types/components/button/button.recipe.d.ts +2 -2
  115. package/dist/types/components/callout/callout.d.ts +9 -1
  116. package/dist/types/components/callout/callout.recipe.d.ts +12 -2
  117. package/dist/types/components/input/input.d.ts +14 -0
  118. package/dist/types/components/input/input.recipe.d.ts +3 -0
  119. package/dist/types/components/menu/menu.d.ts +7 -1
  120. package/dist/types/components/multiselect/multiselect.d.ts +25 -6
  121. package/dist/types/components/multiselect/multiselect.recipe.d.ts +27 -0
  122. package/dist/types/components/progress/progress.d.ts +16 -4
  123. package/dist/types/components/progress/progress.recipe.d.ts +22 -0
  124. package/dist/types/components/select/listbox-keyboard.d.ts +10 -1
  125. package/dist/types/components/select/select.d.ts +29 -6
  126. package/dist/types/components/select/select.recipe.d.ts +25 -10
  127. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +14 -0
  128. package/dist/types/components/tabs/tabs.d.ts +9 -1
  129. package/dist/types/components/textarea/textarea.d.ts +16 -0
  130. package/dist/types/components/textlink/textlink.d.ts +8 -1
  131. package/dist/types/components/textlink/textlink.recipe.d.ts +10 -0
  132. package/dist/types/index.d.ts +1 -0
  133. package/dist/types/product/recordpage/recordpage.d.ts +61 -1
  134. package/dist/types/tokens/contract.d.ts +16 -3
  135. package/dist/types/tokens/elevation.d.ts +7 -0
  136. package/dist/types/tokens/palette.d.ts +55 -11
  137. package/dist/types/tokens/scale.d.ts +6 -0
  138. package/dist/types/tokens/semantic.d.ts +33 -0
  139. package/package.json +6 -1
@@ -3,25 +3,37 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.selectRecipe = void 0;
4
4
  const core_1 = require("../../core");
5
5
  const tokens_1 = require("../../tokens");
6
- // A select / dropdown, measured from settings: a 32px combobox trigger
7
- // (radius 9, value + a 16px chevron) over a listbox — radius 12, 4px padding, a
8
- // layered soft shadow — of 32px options (radius 8, 6/8 padding) that show a
9
- // check on the selected one and highlight on hover / keyboard. The listbox is
10
- // portaled to document.body (#339) so no ancestor's transform/backdrop-filter
11
- // (Dialog's own measured surface sets both) can capture its position: fixed
12
- // box; z-index 70 keeps it above Dialog/Drawer's z-index 60/61 layers, since
13
- // it's no longer nested inside whichever one opened it.
14
- // The listbox and everything inside it live in `globalCss` below, not
15
- // nested under `&` (#357): nesting compiles to a real descendant combinator
16
- // scoped to the recipe's root class, which needs actual DOM containment —
17
- // exactly what the portal to `document.body` breaks. Nested, these rules
18
- // matched nothing once the listbox moved and it rendered completely
19
- // unstyled. The trigger and its own parts stay nested under `&` — the
20
- // trigger is NOT portaled, it stays where it was authored. `font-family`/
21
- // smoothing are restated on the listbox's own rule (its children pick font
22
- // up via their own `font-family: inherit`, which still works — that
23
- // inheritance is intact WITHIN the portaled subtree, only the link back to
24
- // the trigger-holding root broke).
6
+ // seed-design's Select, resolved value-for-value from
7
+ // `@seed-design/rootage`'s select.yaml / select-trigger.yaml / select-item.yaml
8
+ // (crisp/#867) — a deliberate, owner-requested exception to "measure Attio
9
+ // first": this ONE component follows seed-design, not Attio, the same way
10
+ // DatePicker already follows seed's calendar structure. Every px/colour below
11
+ // traces to a resolved seed token (see the new entries in tokens/scale.ts,
12
+ // tokens/palette.ts, tokens/semantic.ts and tokens/elevation.ts, each
13
+ // commented with the seed token it came from) — nothing here is measured
14
+ // live, because there is no live Attio reference for this shape any more.
15
+ //
16
+ // crisp keeps its own `searchable` filter field and `triggerLabel` (#846):
17
+ // seed has no search of its own, and the owner asked to keep both. The
18
+ // listbox stays portaled to `document.body` for the same reason #339
19
+ // established (no ancestor's transform/backdrop-filter can capture its
20
+ // position) — see the sibling comment on `globalCss` below for why its rules
21
+ // live there rather than nested under `&`.
22
+ //
23
+ // Two things seed has that this recipe deliberately does NOT reproduce:
24
+ // - A JS-driven exit animation (seed's `exitDuration`/`exitScale`/
25
+ // `exitOpacity`, played before unmount). Every other crisp overlay
26
+ // (Dialog, Menu, ContextMenu, Popover) unmounts synchronously on close and
27
+ // only animates the ENTER transition via `@starting-style` — adding a
28
+ // delayed-unmount mechanism just for Select would be a new architectural
29
+ // pattern with no precedent anywhere else in the repo, for one component.
30
+ // The enter animation (scale 0.95→1, opacity 0→1, seed's own curve) IS
31
+ // reproduced below.
32
+ // - A visible `divider` slot: `select.yaml` defines one, but nothing in
33
+ // crisp or GRC groups options with an explicit separator today, and
34
+ // AUTHORING's rule against building what nothing consumes applies to CSS
35
+ // too. The resolved value is in `~/grc-parity/seed-select-spec.md` for
36
+ // whoever adds it.
25
37
  const config = (0, core_1.defineRecipe)({
26
38
  name: "select",
27
39
  base: `
@@ -29,87 +41,174 @@ const config = (0, core_1.defineRecipe)({
29
41
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
30
42
 
31
43
  & .crisp-select-trigger {
32
- box-sizing: border-box; display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]};
33
- height: ${tokens_1.contract.size.menuRow}; width: 100%; min-width: 0; margin: 0; padding: 0 ${tokens_1.contract.space["1.5"]} 0 ${tokens_1.contract.space["2"]};
34
- border: none; border-radius: ${tokens_1.contract.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${tokens_1.contract.fgPrimary} 7%, transparent);
35
- font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; cursor: pointer;
36
- transition: background-color .12s ease, box-shadow .12s ease;
44
+ box-sizing: border-box; display: inline-flex; align-items: center; width: 100%; min-width: 0; margin: 0;
45
+ border: 1px solid ${tokens_1.contract.strokeSelectTrigger}; background: transparent; cursor: pointer;
46
+ font-family: inherit; color: ${tokens_1.contract.fgSelectNeutral};
47
+ 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);
37
48
  }
38
- & .crisp-select-trigger:hover { background: ${tokens_1.contract.bgRaised}; }
39
- & .crisp-select-trigger[data-open], & .crisp-select-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgBrandSolid}; background: ${tokens_1.contract.bgSurface}; }
40
- & .crisp-select-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
41
- & .crisp-select-value { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; }
42
- & .crisp-select-placeholder { color: ${tokens_1.contract.fgMuted}; }
43
- & .crisp-select-chevron { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; transition: transform .15s ease; }
44
- & .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); }
49
+ /* seed defines no hover state for this trigger -- only pressed, invalid,
50
+ disabled and readonly. The scale-down is scoped to the CONTENT
51
+ (seed's scaleScope: content), not the button itself, so the border
52
+ never moves. */
53
+ /* Only the value scales, not the chevron: the chevron's own transform is
54
+ its open/close rotation, and a scale would fight it for one shared
55
+ CSS property when both are true at once (pressing while open). */
56
+ & .crisp-select-trigger:active:not(:disabled):not([aria-readonly="true"]) { background: ${tokens_1.contract.bgSelectPressed}; }
57
+ & .crisp-select-trigger:active:not(:disabled):not([aria-readonly="true"]) .crisp-select-value { transform: scale(0.98); }
58
+ & .crisp-select-trigger:focus-visible { outline: 2px solid ${tokens_1.contract.strokeFocusRing}; outline-offset: -2px; }
59
+ & .crisp-select-trigger:disabled { background: ${tokens_1.contract.bgSelectDisabled}; cursor: not-allowed; }
60
+ & .crisp-select-trigger:disabled .crisp-select-value,
61
+ & .crisp-select-trigger:disabled .crisp-select-placeholder,
62
+ & .crisp-select-trigger:disabled .crisp-select-chevron { color: ${tokens_1.contract.fgDisabled}; }
63
+ /* readonly: same fill as disabled, but the value/placeholder text stays
64
+ normal ink -- it reads as "answered and locked", not "answered and
65
+ greyed out" (select-trigger.yaml's readonly state only overrides
66
+ root.color; value/placeholder keep their enabled colours). */
67
+ & .crisp-select-trigger[aria-readonly="true"] { background: ${tokens_1.contract.bgSelectDisabled}; cursor: default; }
68
+ & .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); }
69
+ & .crisp-select-placeholder { color: ${tokens_1.contract.fgSelectPlaceholder}; }
70
+ /* closeRotateDuration/closeRotateTimingFunction: 100ms, seed's "easing"
71
+ curve. Overridden to 150ms on open (openRotateDuration) below --
72
+ same curve both ways, only the duration differs. */
73
+ & .crisp-select-chevron { flex-shrink: 0; color: ${tokens_1.contract.fgNeutralMuted}; transition: transform .1s cubic-bezier(.35,0,.35,1); }
74
+ & .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); transition-duration: .15s; }
45
75
  `,
46
76
  globalCss: `
47
77
  /* pointer-events: auto (#375): a modal Dialog sets pointer-events: none
48
- on document.body while open (Zag re-enables it only on layers IT
49
- tracks), and the portaled listbox is outside Zag's knowledge -- it
50
- inherited the lock, so every option was unclickable, hit-testing
51
- right through to whatever real element sat behind it. */
78
+ on document.body while open, and the portaled listbox is outside its
79
+ knowledge -- it inherited the lock without this. */
52
80
  .crisp-select-listbox {
53
81
  font-family: ${tokens_1.contract.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
54
- position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px};
55
- max-height: 280px; overflow-y: auto; padding: ${tokens_1.contract.space["1"]};
56
- border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover};
57
- box-shadow: ${tokens_1.elevation.floating};
58
- transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
82
+ position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space["2"]};
83
+ max-height: 480px; overflow-y: auto; padding: ${tokens_1.contract.space["2"]};
84
+ border-radius: ${tokens_1.contract.radius["3xl"]}; background: ${tokens_1.contract.bgSelectListbox};
85
+ box-shadow: ${tokens_1.elevation.selectFloating};
86
+ transform-origin: top left; opacity: 1; transform: none;
87
+ transition: opacity .15s cubic-bezier(0,0,.15,1), transform .15s cubic-bezier(0,0,.15,1);
59
88
  }
60
- .crisp-select-listbox[data-up] { transform-origin: bottom; }
61
- .crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
62
- .crisp-select-search { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${tokens_1.contract.space["1"]}) calc(-1 * ${tokens_1.contract.space["1"]}) ${tokens_1.contract.space["1"]}; padding: 0 ${tokens_1.contract.space["2.5"]}; border-bottom: 1px solid ${tokens_1.contract.borderSubtle}; color: ${tokens_1.contract.fgMuted}; }
89
+ .crisp-select-listbox[data-up] { transform-origin: bottom left; }
90
+ .crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.95); } }
91
+ /* Composed from @seed-design/rootage's text-input.yaml
92
+ (variant=outline,size=medium) -- seed's Select has no search field of
93
+ its own to resolve this from (see the file header), so this reuses
94
+ the SAME tokens #867 already resolved for the trigger at this size:
95
+ minHeight x10 (${tokens_1.contract.size.selectTriggerMd}), paddingX x3_5
96
+ (${tokens_1.contract.space["3.5"]}), gap x2 (${tokens_1.contract.space["2"]}), cornerRadius r2
97
+ (${tokens_1.contract.radius.md}), strokeColor stroke.neutral-weak
98
+ (${tokens_1.contract.strokeSelectTrigger}) -- a real bordered field sitting flush
99
+ inside the listbox's own 8px padding, replacing the old bare
100
+ 34px/no-border/bleeds-to-the-edge strip (crisp/#904). */
101
+ .crisp-select-search { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: ${tokens_1.contract.size.selectTriggerMd}; flex-shrink: 0; padding: 0 ${tokens_1.contract.space["3.5"]}; border: 1px solid ${tokens_1.contract.strokeSelectTrigger}; border-radius: ${tokens_1.contract.radius.md}; color: ${tokens_1.contract.fgNeutralMuted}; }
63
102
  .crisp-select-search svg { flex-shrink: 0; }
64
- .crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; }
65
- .crisp-select-search input::placeholder { color: ${tokens_1.contract.fgMuted}; }
66
- .crisp-select-empty { padding: ${tokens_1.contract.space["3"]} ${tokens_1.contract.space["2"]}; text-align: center; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgMuted}; }
67
- /* #343: same measured values as Menu's own group heading
68
- (crisp-menu-label) -- reused rather than re-measured, since it's the
69
- same "small muted heading above a run of rows" treatment. */
70
- .crisp-select-label { padding: ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["0.5"]}; font-size: ${tokens_1.contract.text.sm}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgMuted}; }
103
+ .crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; line-height: 19px; color: ${tokens_1.contract.fgSelectNeutral}; }
104
+ .crisp-select-search input::placeholder { color: ${tokens_1.contract.fgSelectPlaceholder}; }
105
+ .crisp-select-empty { padding: ${tokens_1.contract.space["3"]} ${tokens_1.contract.space["4"]}; text-align: center; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgNeutralSubtle}; }
106
+ /* select.yaml's groupLabel -- font-size/line-height/weight are the same
107
+ across sizes, only paddingY differs (medium 8px, large 10px). */
108
+ .crisp-select-label { padding: ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["4"]} 0; font-size: ${tokens_1.contract.text.md}; line-height: 18px; font-weight: ${tokens_1.contract.weight.regular}; color: ${tokens_1.contract.fgNeutralSubtle}; }
71
109
  .crisp-select-option {
72
- box-sizing: border-box; display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: ${tokens_1.contract.size.menuRow}; flex-shrink: 0;
73
- padding: 0 ${tokens_1.contract.space["2"]}; margin: 0; border: none; background: none; border-radius: ${tokens_1.contract.radius.md};
74
- font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; text-align: start; cursor: pointer; white-space: nowrap;
110
+ box-sizing: border-box; display: flex; align-items: center; flex-shrink: 0;
111
+ padding: ${tokens_1.contract.space["2.5"]} ${tokens_1.contract.space["4"]}; margin-inline: 0; border: none; background: none; border-radius: ${tokens_1.contract.radius.xl};
112
+ font-family: inherit; color: ${tokens_1.contract.fgSelectNeutral}; text-align: start; cursor: pointer; white-space: nowrap;
113
+ transition: background-color .15s cubic-bezier(.35,0,.35,1), margin-inline .15s cubic-bezier(.35,0,.35,1);
75
114
  }
115
+ /* Keyboard/hover highlight keeps crisp's own visible neutral fill --
116
+ seed's spec has no "highlighted" state at all (only pressed/selected/
117
+ disabled), and its actual pressed tint (below) is a ~3% black hint,
118
+ too faint to serve as a keyboard-navigation indicator. */
76
119
  .crisp-select-option[data-highlighted] { background: ${tokens_1.contract.bgRaised}; }
77
- .crisp-select-option[data-disabled] { opacity: 0.5; cursor: not-allowed; }
78
- .crisp-select-option-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${tokens_1.contract.radius.full}; }
120
+ .crisp-select-option:active:not([data-disabled]) { background: ${tokens_1.contract.bgSelectPressed}; margin-inline: ${tokens_1.contract.space["2"]}; }
121
+ .crisp-select-option[data-disabled] { cursor: not-allowed; }
122
+ .crisp-select-option[data-disabled] .crisp-select-option-label,
123
+ .crisp-select-option[data-disabled] .crisp-select-option-description,
124
+ .crisp-select-option[data-disabled] .crisp-select-option-check { color: ${tokens_1.contract.fgDisabled}; }
125
+ .crisp-select-option-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${tokens_1.contract.radius.full}; margin-inline-end: ${tokens_1.contract.space["2"]}; }
126
+ .crisp-select-option-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: ${tokens_1.contract.space["0.5"]}; }
79
127
  .crisp-select-option-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
80
- .crisp-select-option-check { flex-shrink: 0; color: ${tokens_1.contract.bgBrandSolid}; }
128
+ .crisp-select-option-description { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: ${tokens_1.contract.fgNeutralSubtle}; }
129
+ .crisp-select-option-check { flex-shrink: 0; color: ${tokens_1.contract.fgSelectNeutral}; margin-inline-start: ${tokens_1.contract.space["2"]}; }
81
130
  `,
82
131
  variants: {
83
- // ghost: borderless at rest (transparent), reveals a subtle fill on hover —
84
- // inline selects (e.g. the sort configurator).
85
- variant: {
86
- default: "",
87
- ghost: `
88
- & .crisp-select-trigger { background: transparent; box-shadow: none; }
89
- & .crisp-select-trigger:hover { background: ${tokens_1.contract.bgRaised}; box-shadow: none; }
90
- & .crisp-select-trigger[data-open], & .crisp-select-trigger:focus-visible { background: transparent; box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgBrandSolid}; }
91
- `,
92
- },
93
- // full (default): fills its container, as before. auto: sizes to content.
132
+ // full (default): fills its container. auto: sizes to content.
94
133
  width: {
95
134
  full: `width: 100%;`,
96
135
  auto: `width: auto;`,
97
136
  },
98
- // Same ring Input's `invalid` draws, adapted to Select's box-shadow-ring
99
- // trigger (a real <button>, so it needs no ::after overlay the way
100
- // Input/Textarea do). Outranks hover/open/focus, same reasoning as
101
- // Input: a field being corrected stays visibly wrong while it is.
102
- invalid: {
103
- true: `
104
- & .crisp-select-trigger,
105
- & .crisp-select-trigger:hover,
106
- & .crisp-select-trigger[data-open],
107
- & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgDanger}; }
137
+ // Owner's follow-up after #867/#879 (crisp/#889): the LOOK stays seed's
138
+ // (border, listbox, radius, shadow, items, states, all unchanged below),
139
+ // but the SIZE is Attio's again -- a 40px trigger next to a 32px `Input`
140
+ // in the same form visibly mismatched (measured live in GRC's own "New
141
+ // employee" dialog). `small` is the new default: Attio's original
142
+ // 32px `c.size.menuRow`, `Input`'s own radius/padding/line-height so the
143
+ // two line up exactly, not just approximately. `medium`/`large` (seed's
144
+ // own 40/52px) stay, opt-in. Item rows are IDENTICAL across all three --
145
+ // only the trigger differs; only it was ever the mismatch.
146
+ size: {
147
+ small: `
148
+ & .crisp-select-trigger { height: ${tokens_1.contract.size.menuRow}; border-radius: ${tokens_1.contract.radius.md}; gap: ${tokens_1.contract.space["1.5"]}; padding-inline: ${tokens_1.contract.space["2.5"]}; }
149
+ & .crisp-select-value, & .crisp-select-placeholder { font-size: ${tokens_1.contract.text.lg}; line-height: 20px; }
150
+ & .crisp-select-chevron { width: ${tokens_1.contract.size.icon}; height: ${tokens_1.contract.size.icon}; }
151
+ .crisp-select-option { gap: ${tokens_1.contract.space["2"]}; }
152
+ .crisp-select-option-label { font-size: ${tokens_1.contract.text.lg}; line-height: 19px; }
153
+ .crisp-select-option-description { font-size: ${tokens_1.contract.text.sm}; line-height: 16px; }
154
+ .crisp-select-option-check { width: ${tokens_1.contract.space["3"]}; height: ${tokens_1.contract.space["3"]}; }
155
+ .crisp-select-label { padding-top: ${tokens_1.contract.space["2"]}; }
156
+ `,
157
+ medium: `
158
+ & .crisp-select-trigger { height: ${tokens_1.contract.size.selectTriggerMd}; border-radius: ${tokens_1.contract.radius.md}; gap: ${tokens_1.contract.space["2"]}; padding-inline: ${tokens_1.contract.space["3.5"]}; }
159
+ & .crisp-select-value, & .crisp-select-placeholder { font-size: ${tokens_1.contract.text.lg}; line-height: 19px; }
160
+ & .crisp-select-chevron { width: ${tokens_1.contract.size.icon}; height: ${tokens_1.contract.size.icon}; }
161
+ .crisp-select-option { gap: ${tokens_1.contract.space["2"]}; }
162
+ .crisp-select-option-label { font-size: ${tokens_1.contract.text.lg}; line-height: 19px; }
163
+ .crisp-select-option-description { font-size: ${tokens_1.contract.text.sm}; line-height: 16px; }
164
+ .crisp-select-option-check { width: ${tokens_1.contract.space["3"]}; height: ${tokens_1.contract.space["3"]}; }
165
+ .crisp-select-label { padding-top: ${tokens_1.contract.space["2"]}; }
166
+ `,
167
+ large: `
168
+ & .crisp-select-trigger { height: ${tokens_1.contract.size.selectTriggerLg}; border-radius: ${tokens_1.contract.radius.xl}; gap: ${tokens_1.contract.space["2.5"]}; padding-inline: ${tokens_1.contract.space["4"]}; }
169
+ & .crisp-select-value, & .crisp-select-placeholder { font-size: ${tokens_1.contract.text.xl}; line-height: 22px; }
170
+ & .crisp-select-chevron { width: ${tokens_1.contract.size.selectIconLg}; height: ${tokens_1.contract.size.selectIconLg}; }
171
+ .crisp-select-option { gap: ${tokens_1.contract.space["3"]}; }
172
+ .crisp-select-option-label { font-size: ${tokens_1.contract.text.xl}; line-height: 22px; }
173
+ .crisp-select-option-description { font-size: ${tokens_1.contract.text.md}; line-height: 18px; }
174
+ .crisp-select-option-check { width: ${tokens_1.contract.space["3.5"]}; height: ${tokens_1.contract.space["3.5"]}; }
175
+ .crisp-select-label { padding-top: ${tokens_1.contract.space["2.5"]}; }
108
176
  `,
177
+ },
178
+ // Real border width+colour change (seed: strokeWidth 1px→2px,
179
+ // strokeColor neutral-weak→critical-solid), not a box-shadow ring --
180
+ // crisp's own field/switch invalid rings don't apply here since this
181
+ // component's whole border model changed to match seed's real stroke.
182
+ invalid: {
183
+ true: `& .crisp-select-trigger { border-width: 2px; border-color: ${tokens_1.contract.strokeSelectInvalid}; }`,
109
184
  false: ``,
110
185
  },
186
+ // ghost: borderless at rest, a subtle fill on hover -- inline selects
187
+ // (e.g. the sort configurator, and datatable's own cell editors, which
188
+ // embed at whatever height their row already is, not seed's fixed
189
+ // trigger height). Not a seed state; kept from crisp's existing API
190
+ // surface, layered on top of the seed base.
191
+ //
192
+ // Declared LAST on purpose: `resolveRecipe` emits one CSS rule per
193
+ // variant, in the order this object lists them, and same-specificity
194
+ // rules resolve by source order -- ghost's `height: 100%` below must
195
+ // come after `size`'s fixed height to win the cascade. Move `variant`
196
+ // earlier and every ghost consumer's trigger silently grows to 40/52px
197
+ // inside its 36px row again.
198
+ variant: {
199
+ default: "",
200
+ ghost: `
201
+ & .crisp-select-trigger { height: 100%; border-color: transparent; }
202
+ & .crisp-select-trigger:hover:not(:disabled) { background: ${tokens_1.contract.bgSelectPressed}; }
203
+ `,
204
+ },
205
+ },
206
+ defaultVariants: {
207
+ width: "full",
208
+ size: "small",
209
+ invalid: false,
210
+ variant: "default",
111
211
  },
112
- defaultVariants: { variant: "default", width: "full", invalid: false },
113
212
  compoundVariants: [],
114
213
  });
115
214
  exports.selectRecipe = Object.assign((0, core_1.resolveRecipe)(config), { config });
@@ -772,7 +772,7 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
772
772
  : sorts.length
773
773
  ? `${crispMessages["Sorted by"] ?? "Sorted by"} ${sortField}${sorts.length > 1 ? ` +${sorts.length - 1}` : ""}`
774
774
  : sortLabel;
775
- return ((0, jsx_runtime_1.jsx)("div", { ref: rootRef, className: (0, tabletoolbar_recipe_1.tabletoolbarRecipe)({ className }), ...rest, children: (0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-toolbar", "data-inline": inline ? "" : undefined, children: [showSort && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.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: [(0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortTriggerLabel || !sorts.length ? (sortedLabel) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-sorted-prefix", children: crispMessages["Sorted by"] ?? "Sorted by" }), " ", (0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-sorted-field", children: sortField }), sorts.length > 1 && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-sorted-count", children: [" ", "+", sorts.length - 1] }))] }))] }), openMenu === "sort" && ((0, jsx_runtime_1.jsxs)("div", { ref: sortListRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "crisp-tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
775
+ return ((0, jsx_runtime_1.jsx)("div", { ref: rootRef, className: (0, tabletoolbar_recipe_1.tabletoolbarRecipe)({ className }), ...rest, children: (0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-toolbar", "data-inline": inline ? "" : undefined, children: [showSort && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.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: [(0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortTriggerLabel || !sorts.length ? (sortedLabel) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-sorted-prefix", children: crispMessages["Sorted by"] ?? "Sorted by" }), " ", (0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-sorted-field", children: sortField }), sorts.length > 1 && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-sorted-count", children: ["+", sorts.length - 1] }))] }))] }), openMenu === "sort" && ((0, jsx_runtime_1.jsxs)("div", { ref: sortListRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "crisp-tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
776
776
  if (dragSort !== null)
777
777
  e.preventDefault();
778
778
  }, onDrop: () => {
@@ -967,12 +967,12 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
967
967
  * contents` so it contributes no box of its own — the same technique
968
968
  * `TableToolbar`'s own root already uses to disappear from layout.
969
969
  */
970
- exports.ToolbarTool = React.forwardRef(({ active, ghost, className, ...rest }, ref) => ((0, jsx_runtime_1.jsx)("span", { className: (0, tabletoolbar_recipe_1.tabletoolbarRecipe)(), children: (0, jsx_runtime_1.jsx)("button", { ref: ref, type: "button", className: [
970
+ exports.ToolbarTool = React.forwardRef(({ active, ghost, count, className, children, ...rest }, ref) => ((0, jsx_runtime_1.jsx)("span", { className: (0, tabletoolbar_recipe_1.tabletoolbarRecipe)(), children: (0, jsx_runtime_1.jsxs)("button", { ref: ref, type: "button", className: [
971
971
  "crisp-tabletoolbar-tool",
972
972
  active && "crisp-tabletoolbar-tool--active",
973
973
  ghost && "crisp-tabletoolbar-tool--ghost",
974
974
  className,
975
975
  ]
976
976
  .filter(Boolean)
977
- .join(" "), ...rest }) })));
977
+ .join(" "), ...rest, children: [children, count != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-tool-count", children: count }))] }) })));
978
978
  exports.ToolbarTool.displayName = "ToolbarTool";
@@ -107,9 +107,33 @@ const importExportChrome = `
107
107
  // vs 14px) and its own mid-grey, distinct from the "Sorted by" muted tone.
108
108
  // TableToolbar-only, same reasoning as `filterChipChrome` above (RecordsTable
109
109
  // keeps its own older Sort label markup, not touched here).
110
+ // crisp#876: re-measured live -- the "+N" isn't just smaller/greyer text,
111
+ // it's a real chip: `background: rgba(0,0,0,.04)`, `box-shadow: inset 0 0 0
112
+ // 1px rgb(238,239,241)` (borderSubtle), `border-radius: 6px`, `padding: 0
113
+ // 4px`, 16px tall, colour `rgb(80,81,85)` -- bit-for-bit crisp's own Badge
114
+ // `tone="neutral" size="small"` shape, so this reuses those exact tokens
115
+ // (`fgNeutralSoft`, `borderSubtle`, `radius.sm`, `space["1"]`) rather than
116
+ // re-deriving new ones or leaving the old 73%-mix colour approximation.
110
117
  const sortedLabelChrome = `
111
118
  & .crisp-tabletoolbar-sorted-prefix { color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); }
112
- & .crisp-tabletoolbar-sorted-count { font-size: ${tokens_1.contract.text.sm}; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 73%, transparent); }`;
119
+ & .crisp-tabletoolbar-sorted-count { display: inline-flex; align-items: center; height: 16px; box-sizing: border-box;
120
+ padding: 0 ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.sm}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent);
121
+ box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderSubtle}; font-size: ${tokens_1.contract.text.sm}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgNeutralSoft}; }`;
122
+ // crisp#875: a general count-badge slot for `ToolbarTool` (a screen's own
123
+ // quick filter, e.g. "My tasks •5") -- the SAME measured shape as
124
+ // `sorted-count` above, independently re-confirmed on a second live Attio
125
+ // surface this round (a Kanban board's own stage-column count, "Idea 0":
126
+ // identical `data-variant="tertiary" data-size="16"` chip, same bg/ring/
127
+ // radius/padding/colour, plus `font-variant-numeric: tabular-nums` for
128
+ // digit alignment past a single character). Same reasoning as
129
+ // search/menu-input's own duplicated ring formula elsewhere in this file:
130
+ // a distinct class, not a shared one, so `sorted-count`'s own meaning
131
+ // doesn't get diluted by an unrelated consumer.
132
+ const toolCountChrome = `
133
+ & .crisp-tabletoolbar-tool-count { display: inline-flex; align-items: center; height: 16px; box-sizing: border-box;
134
+ padding: 0 ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.sm}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent);
135
+ box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderSubtle}; font-size: ${tokens_1.contract.text.sm}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgNeutralSoft};
136
+ font-variant-numeric: tabular-nums; }`;
113
137
  // The table toolbar, measured: 28px controls, the Sort/Filter buttons and
114
138
  // the menus they open, sort-condition rows, and the filter chips. The chrome
115
139
  // itself lives in ./tabletoolbar.chrome.ts because RecordsTable renders the
@@ -129,6 +153,7 @@ ${(0, tabletoolbar_chrome_1.toolbarChromeBody)("tabletoolbar")}
129
153
  ${filterChipChrome}
130
154
  ${importExportChrome}
131
155
  ${sortedLabelChrome}
156
+ ${toolCountChrome}
132
157
  ${core_1.rtlFlipCss}
133
158
  `,
134
159
  });
@@ -35,7 +35,9 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Tabs = Tabs;
37
37
  const jsx_runtime_1 = require("react/jsx-runtime");
38
+ const lucide_react_1 = require("lucide-react");
38
39
  const React = __importStar(require("react"));
40
+ const menu_1 = require("../menu");
39
41
  const tabs_recipe_1 = require("./tabs.recipe");
40
42
  const tabs_keyboard_1 = require("./tabs-keyboard");
41
43
  /**
@@ -44,7 +46,7 @@ const tabs_keyboard_1 = require("./tabs-keyboard");
44
46
  * Home/End jump); activation follows focus. Controlled via
45
47
  * `value`/`onValueChange`, or uncontrolled from `defaultValue`.
46
48
  */
47
- function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, className, fill, "aria-label": ariaLabel, ...rest }) {
49
+ function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, moreLabel = (n) => `+${n} more`, moreMenuLabel = "More tabs", className, fill, "aria-label": ariaLabel, ...rest }) {
48
50
  // Scoped to this instance: `tab-${value}` and `panel-${value}` are global,
49
51
  // so two tab bars sharing a value mint the same id twice and every
50
52
  // aria-labelledby resolves to the first one (#227).
@@ -77,16 +79,73 @@ function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, cla
77
79
  ro.observe(list);
78
80
  return () => ro.disconnect();
79
81
  }, [active, tabs]);
82
+ // crisp#863 -- responsive "+N more" overflow, measured live: Attio
83
+ // collapses the TRAILING tabs into a single trigger once the strip runs
84
+ // out of room, rather than letting the row wrap or overrun. Every tab
85
+ // (and the "+N more" trigger itself) stays permanently mounted -- an
86
+ // overflowed one gets `data-tabs-overflow-hidden` (position: absolute;
87
+ // visibility: hidden in the recipe), which removes it from the flex row
88
+ // WITHOUT ever making its `offsetWidth` unmeasurable the way `display:
89
+ // none` would. That's what lets this recompute purely from cached
90
+ // per-tab widths on every resize, with no flash: a naive
91
+ // measure-everything-visible-first pass would show the full unclipped
92
+ // row for one frame before collapsing it.
93
+ const [hiddenFrom, setHiddenFrom] = React.useState(null);
94
+ const moreRef = React.useRef(null);
95
+ React.useLayoutEffect(() => {
96
+ const list = listRef.current;
97
+ if (!list || tabs.length === 0)
98
+ return;
99
+ const recompute = () => {
100
+ const containerWidth = list.clientWidth;
101
+ // Not yet laid out (e.g. this render, or the test environment, which
102
+ // never computes real box metrics) -- never collapse everything on a
103
+ // 0-width read, that's a false "nothing fits" rather than "not
104
+ // measurable yet".
105
+ if (containerWidth <= 0)
106
+ return;
107
+ const els = tabs.map((t) => list.querySelector(`[data-value="${CSS.escape(t.value)}"]`));
108
+ if (els.some((el) => !el))
109
+ return;
110
+ const gap = 4; // ${c.space["1"]}, the list's own gap (recipe.ts)
111
+ const moreWidth = moreRef.current?.offsetWidth ?? 0;
112
+ let used = 0;
113
+ let cutoff = tabs.length;
114
+ for (let i = 0; i < els.length; i++) {
115
+ const w = (els[i]?.offsetWidth ?? 0) + (i > 0 ? gap : 0);
116
+ const reserve = i < tabs.length - 1 ? moreWidth + gap : 0;
117
+ if (used + w + reserve > containerWidth) {
118
+ cutoff = i;
119
+ break;
120
+ }
121
+ used += w;
122
+ }
123
+ // The active tab is never allowed to fall into the overflow menu --
124
+ // not itself measured live (Attio's own fixture never exercised
125
+ // navigating INTO an overflowed tab first), but "the tab you're on
126
+ // stays visible" is the safe, unsurprising default for any tab UI.
127
+ const activeIndex = tabs.findIndex((t) => t.value === active);
128
+ if (activeIndex >= 0 && activeIndex >= cutoff)
129
+ cutoff = activeIndex + 1;
130
+ setHiddenFrom(cutoff >= tabs.length ? null : cutoff);
131
+ };
132
+ recompute();
133
+ const ro = new ResizeObserver(recompute);
134
+ ro.observe(list);
135
+ return () => ro.disconnect();
136
+ }, [tabs, active]);
137
+ const hiddenTabs = hiddenFrom == null ? [] : tabs.slice(hiddenFrom);
80
138
  const { onKeyDown, tabIndexFor } = (0, tabs_keyboard_1.useTablistKeyboard)({
81
139
  tabs,
82
140
  active,
83
141
  select,
84
142
  listRef,
85
143
  });
86
- return ((0, jsx_runtime_1.jsxs)("div", { className: (0, tabs_recipe_1.tabsRecipe)({ fill, className }), ...rest, children: [(0, jsx_runtime_1.jsxs)("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && (0, jsx_runtime_1.jsx)("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t) => {
144
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, tabs_recipe_1.tabsRecipe)({ fill, className }), ...rest, children: [(0, jsx_runtime_1.jsxs)("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && (0, jsx_runtime_1.jsx)("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t, i) => {
87
145
  const selected = t.value === active;
88
- return ((0, jsx_runtime_1.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, (0, jsx_runtime_1.jsx)("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value));
89
- }), indicator && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabs-indicator", style: {
146
+ const overflowHidden = hiddenFrom != null && i >= hiddenFrom;
147
+ return ((0, jsx_runtime_1.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, (0, jsx_runtime_1.jsx)("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value));
148
+ }), (0, jsx_runtime_1.jsx)("span", { ref: moreRef, className: "crisp-tabs-more", "data-tabs-overflow-hidden": hiddenTabs.length === 0 ? "" : undefined, children: (0, jsx_runtime_1.jsx)(menu_1.Menu, { "aria-label": moreMenuLabel, trigger: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabs-tab", children: [(0, jsx_runtime_1.jsx)("span", { className: "tabs-tab-label", children: moreLabel(hiddenTabs.length) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }), children: hiddenTabs.map((t) => ((0, jsx_runtime_1.jsxs)(menu_1.MenuItem, { icon: t.icon, onSelect: () => select(t.value), children: [t.label, t.badge != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value))) }) }), indicator && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabs-indicator", style: {
90
149
  width: indicator.width,
91
150
  transform: `translateX(${indicator.left}px)`,
92
151
  } }))] }), children && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-tabs-panel", role: "tabpanel", id: panelId(active), "aria-labelledby": tabId(active),
@@ -57,6 +57,19 @@ const config = (0, core_1.defineRecipe)({
57
57
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 14px; height: 14px; padding: 0 ${tokens_1.contract.space["1"]}; border-radius: 4px; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderSubtle}; font-size: 10px; line-height: 14px; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgNeutralSoft}; }
58
58
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${tokens_1.contract.fgPrimary}; }
59
59
  & .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: ${tokens_1.contract.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; }
60
+ /* crisp#863: the "+N more" overflow trigger, measured live -- its
61
+ computed style is identical to a plain .crisp-tabs-tab (same pill,
62
+ same 6px icon/label gap the tab class already gives via
63
+ space["1.5"]), and the dropdown it opens is crisp's own Menu/
64
+ MenuItem chrome (already matches Attio's popover, nothing new to
65
+ style there). Only the wrapper needs a rule: an overflowed tab (or
66
+ the trigger itself, when nothing is hidden) is taken out of the
67
+ flex row and out of the a11y tree without ever making its own
68
+ offsetWidth unmeasurable the way display:none would -- that's what
69
+ lets the tabs.tsx effect recompute the cutoff from cached widths on
70
+ every resize with no flash of an unclipped row. */
71
+ & .crisp-tabs-more { display: inline-flex; }
72
+ & [data-tabs-overflow-hidden] { position: absolute; visibility: hidden; pointer-events: none; }
60
73
  & .crisp-tabs-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; padding-top: ${tokens_1.contract.space["8"]}; }
61
74
  & .crisp-tabs-panel:focus-visible { outline: none; }
62
75
  `,
@@ -36,13 +36,14 @@ const spec = (0, core_1.defineSpec)({
36
36
  muted: { color: tokens_1.contract.fgNeutralMuted },
37
37
  // #858: no new measurement -- both reuse tokens already measured
38
38
  // against Attio for the same colours in a different arrangement.
39
- // `brand` is `textlink`'s own reused brand-text colour (`bgBrandSolid`,
40
- // the same alias GRC's own `--color-fg-brand` resolves to). `danger`
41
- // is `fgDanger` ("solid critical/destructive TEXT... an overdue
42
- // due-date, a destructive menu item's label" -- see its own doc in
43
- // tokens/semantic.ts), not `fgDangerSoft` (that one's a soft Badge
44
- // pill's text, a different measured hex).
45
- brand: { color: tokens_1.contract.bgBrandSolid },
39
+ // `brand` is `textlink`'s own reused brand-text colour, now
40
+ // `fgBrandContrast` (your-moon/crisp#897: the plain accent,
41
+ // `bgBrandSolid`, only clears 3.23:1 as text on a white page).
42
+ // `danger` is `fgDanger` ("solid critical/destructive TEXT... an
43
+ // overdue due-date, a destructive menu item's label" -- see its own
44
+ // doc in tokens/semantic.ts), not `fgDangerSoft` (that one's a soft
45
+ // Badge pill's text, a different measured hex).
46
+ brand: { color: tokens_1.contract.fgBrandContrast },
46
47
  danger: { color: tokens_1.contract.fgDanger },
47
48
  },
48
49
  },
@@ -36,13 +36,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Textarea = void 0;
37
37
  const jsx_runtime_1 = require("react/jsx-runtime");
38
38
  const React = __importStar(require("react"));
39
+ const progress_1 = require("../progress");
39
40
  const textarea_recipe_1 = require("./textarea.recipe");
40
41
  /**
41
42
  * A multiline text field — measured from settings. Forwards every native
42
43
  * `<textarea>` prop; pass `showCount` with `maxLength` to render a live
43
44
  * character counter (which turns red if the value exceeds the limit).
44
45
  */
45
- exports.Textarea = React.forwardRef(({ showCount, maxLength, className, wrapperClassName, value, defaultValue, onChange,
46
+ exports.Textarea = React.forwardRef(({ showCount, showProgress, maxLength, className, wrapperClassName, value, defaultValue, onChange,
46
47
  // `style` is pulled out and put on the ROOT, the way Input does it.
47
48
  // Everything else belongs on the <textarea> -- id, name, form
48
49
  // association, aria -- but `style` sizes the visual box, and the box is
@@ -58,6 +59,6 @@ style, ...props }, ref) => {
58
59
  if (value === undefined)
59
60
  setCount(e.target.value.length);
60
61
  onChange?.(e);
61
- }, ...props }) }), counted && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-textarea-count", "data-over": len > (maxLength ?? 0) ? "" : undefined, "aria-hidden": "true", children: [len, " / ", maxLength] }))] }));
62
+ }, ...props }) }), counted && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-textarea-count", "data-over": len > (maxLength ?? 0) ? "" : undefined, "aria-hidden": "true", children: [showProgress && ((0, jsx_runtime_1.jsx)(progress_1.Progress, { variant: "circular", value: len, max: maxLength ?? 0, className: "crisp-textarea-progress" })), len, " / ", maxLength] }))] }));
62
63
  });
63
64
  exports.Textarea.displayName = "Textarea";
@@ -49,8 +49,9 @@ const config = (0, core_1.defineRecipe)({
49
49
  &[data-invalid] .crisp-textarea-wrap::after { border-color: ${tokens_1.contract.bgDanger}; }
50
50
  &[data-invalid] .crisp-textarea-wrap:focus-within::after { border-color: ${tokens_1.contract.bgDanger}; }
51
51
  &[data-counted] .crisp-textarea-field { padding-bottom: ${tokens_1.contract.space["5"]}; }
52
- & .crisp-textarea-count { position: absolute; inset-inline-end: 14px; bottom: ${tokens_1.contract.space["2.5"]}; font-size: ${tokens_1.contract.text.sm}; color: ${tokens_1.contract.fgMuted}; pointer-events: none; }
52
+ & .crisp-textarea-count { position: absolute; inset-inline-end: 14px; bottom: ${tokens_1.contract.space["2.5"]}; display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; font-size: ${tokens_1.contract.text.sm}; color: ${tokens_1.contract.fgMuted}; pointer-events: none; }
53
53
  & .crisp-textarea-count[data-over] { color: ${tokens_1.contract.bgDanger}; }
54
+ & .crisp-textarea-progress { flex-shrink: 0; }
54
55
  `,
55
56
  variants: {},
56
57
  defaultVariants: {},
@@ -10,7 +10,7 @@ const textlink_recipe_1 = require("./textlink.recipe");
10
10
  * `event.preventDefault()` pairs it with client-side routing (e.g.
11
11
  * react-router's own `Link`).
12
12
  */
13
- function TextLink({ tone = "muted", className, ...rest }) {
14
- return (0, jsx_runtime_1.jsx)("a", { className: (0, textlink_recipe_1.textlinkRecipe)({ tone, className }), ...rest });
13
+ function TextLink({ tone = "muted", size, className, ...rest }) {
14
+ return (0, jsx_runtime_1.jsx)("a", { className: (0, textlink_recipe_1.textlinkRecipe)({ tone, size, className }), ...rest });
15
15
  }
16
16
  TextLink.displayName = "TextLink";