@crisp-ui-kit/crisp 0.45.0 → 0.46.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.
@@ -126,6 +126,24 @@ function Menu({ trigger, children, align = "start", open: openProp, defaultOpen,
126
126
  document.addEventListener("pointerdown", onDown);
127
127
  return () => document.removeEventListener("pointerdown", onDown);
128
128
  }, [open, setOpen]);
129
+ // A document-level fallback for Escape (#376 come back). The content's own
130
+ // Escape handling (`useMenuKeyboard`'s onKeyDown, below) only fires once
131
+ // focus is actually inside `.crisp-menu-content` -- but focus lands there
132
+ // via a `requestAnimationFrame` scheduled on open (menu-keyboard.ts), not
133
+ // synchronously. An Escape pressed before that frame paints reaches
134
+ // whatever still has focus (the trigger button), which is outside the
135
+ // content div's subtree, so `onContentKeyDown` never sees it and nothing
136
+ // closes. This listener doesn't depend on focus location, so it closes
137
+ // regardless -- the same pattern `ContextMenu` already uses for the
138
+ // identical reason (its own comment: "Escape is handled twice on
139
+ // purpose").
140
+ React.useEffect(() => {
141
+ if (!open)
142
+ return;
143
+ const onKey = (e) => e.key === "Escape" && close();
144
+ document.addEventListener("keydown", onKey);
145
+ return () => document.removeEventListener("keydown", onKey);
146
+ }, [open, close]);
129
147
  const { onKeyDown: onContentKeyDown } = (0, menu_keyboard_1.useMenuKeyboard)({
130
148
  open,
131
149
  close,
@@ -125,10 +125,10 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
125
125
  if (searchable)
126
126
  requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
127
127
  };
128
- const close = () => {
128
+ const close = React.useCallback(() => {
129
129
  setOpen(false);
130
130
  triggerRef.current?.focus({ preventScroll: true });
131
- };
131
+ }, []);
132
132
  const activeId = open && highlight >= 0 && highlight < filtered.length
133
133
  ? optionId(highlight)
134
134
  : undefined;
@@ -182,6 +182,18 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
182
182
  document.addEventListener("pointerdown", onDown);
183
183
  return () => document.removeEventListener("pointerdown", onDown);
184
184
  }, [open]);
185
+ // A document-level fallback for Escape (#376 come back) -- see Select's
186
+ // matching comment: the listbox's own Escape handling depends on focus
187
+ // already being inside it, which a requestAnimationFrame moves there
188
+ // asynchronously after open, so an Escape pressed before that frame
189
+ // paints is missed entirely. This closes regardless of focus location.
190
+ React.useEffect(() => {
191
+ if (!open)
192
+ return;
193
+ const onKey = (e) => e.key === "Escape" && close();
194
+ document.addEventListener("keydown", onKey);
195
+ return () => document.removeEventListener("keydown", onKey);
196
+ }, [open, close]);
185
197
  return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ invalid, className }), ...rest, children: [name &&
186
198
  (value.length > 0 ? value : required ? [""] : []).map((v) => ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
187
199
  if (open && !searchable)
@@ -109,6 +109,23 @@ function Popover({ trigger, children, align = "start", open: openProp, defaultOp
109
109
  document.addEventListener("pointerdown", onDown);
110
110
  return () => document.removeEventListener("pointerdown", onDown);
111
111
  }, [open, setOpen]);
112
+ // A document-level fallback for Escape (#376 come back). The content's own
113
+ // Escape handling (its `onKeyDown`, below) only fires when focus is
114
+ // already inside `.crisp-popover-content` -- but Popover, unlike Menu,
115
+ // never moves focus there on open at all, so that handler only ever fires
116
+ // if a consumer's own content happens to contain something already
117
+ // focused. In the common case (trigger stays focused after the click that
118
+ // opened it) Escape reached nothing and did nothing. This listener
119
+ // doesn't depend on focus location, so it closes regardless -- the same
120
+ // pattern `ContextMenu` already uses for the identical reason (its own
121
+ // comment: "Escape is handled twice on purpose").
122
+ React.useEffect(() => {
123
+ if (!open)
124
+ return;
125
+ const onKey = (e) => e.key === "Escape" && setOpen(false);
126
+ document.addEventListener("keydown", onKey);
127
+ return () => document.removeEventListener("keydown", onKey);
128
+ }, [open, setOpen]);
112
129
  const triggerEl = React.cloneElement(trigger, {
113
130
  ref: triggerRef,
114
131
  id: triggerId,
@@ -117,10 +117,10 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
117
117
  if (searchable)
118
118
  requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
119
119
  };
120
- const close = () => {
120
+ const close = React.useCallback(() => {
121
121
  setOpen(false);
122
122
  triggerRef.current?.focus({ preventScroll: true });
123
- };
123
+ }, []);
124
124
  const pick = (opt) => {
125
125
  if (!opt || opt.disabled)
126
126
  return;
@@ -177,6 +177,24 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
177
177
  document.addEventListener("pointerdown", onDown);
178
178
  return () => document.removeEventListener("pointerdown", onDown);
179
179
  }, [open]);
180
+ // A document-level fallback for Escape (#376 come back): the listbox's own
181
+ // Escape handling lives on `useListboxKeyboard`'s `onKeyDown`, which only
182
+ // fires once focus is actually inside the listbox -- but focus lands there
183
+ // via a `requestAnimationFrame` scheduled on open (below), not
184
+ // synchronously. Escape pressed before that frame paints reaches whatever
185
+ // still has focus (the trigger, or the search input before ITS OWN focus
186
+ // lands), which is outside the listbox's own subtree, so its handler never
187
+ // sees the keydown at all and nothing closes. This listener doesn't depend
188
+ // on focus location, so it closes regardless -- the same pattern
189
+ // `ContextMenu` already uses for the identical reason (its own comment:
190
+ // "Escape is handled twice on purpose").
191
+ React.useEffect(() => {
192
+ if (!open)
193
+ return;
194
+ const onKey = (e) => e.key === "Escape" && close();
195
+ document.addEventListener("keydown", onKey);
196
+ return () => document.removeEventListener("keydown", onKey);
197
+ }, [open, close]);
180
198
  return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
181
199
  // controlled by the listbox above; `readOnly` would bar it from
182
200
  // validation, which is the only reason this input is not hidden
@@ -4,18 +4,25 @@ exports.tabsRecipe = void 0;
4
4
  const core_1 = require("../../core");
5
5
  const tokens_1 = require("../../tokens");
6
6
  // Record-view tabs: a row of segmented tab buttons over a 1px baseline.
7
- // The active tab is a light pill — bg #fbfbfb + inset ring borderSubtle, r8 —
8
- // with a 2px ink underline that slides along the baseline; inactive tabs are
9
- // muted and fill to a subtle pill on hover. Each tab carries a 16px leading icon
10
- // and an optional count badge. Measured from the live app: 28px pills, 4/8
11
- // padding, 6px icon-label gap, 14/500 labels, 10px badges (rgba(0,0,0,.04)).
7
+ // The list itself is the 36px bar — the 28px tab row plus the 8px the
8
+ // baseline needs, reserved as bottom padding so it's real box height and not
9
+ // paint that trailing content can sit on top of. The bar carries 12px inline
10
+ // padding too, and draws its own full-width `::after` baseline in
11
+ // borderSubtle at its bottom edge, spanning that padding. The active tab is
12
+ // a light pill — bg #fbfbfb + inset ring borderSubtle, r8 — with a 1px ink
13
+ // underline that sits on the baseline (painted above it) and slides along it;
14
+ // inactive tabs are muted and fill to a subtle pill on hover. Each tab carries
15
+ // a 16px leading icon and an optional count badge. Measured from the live
16
+ // app: bar `padding: 0 12px 8px`, 28px pills, 4/8 padding, 6px icon-label
17
+ // gap, 14/500 labels, 10px badges (rgba(0,0,0,.04)).
12
18
  const config = (0, core_1.defineRecipe)({
13
19
  name: "tabs",
14
20
  base: `
15
21
  display: flex; flex-direction: column; min-height: 0; font-family: ${tokens_1.contract.font.sans};
16
22
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
17
23
 
18
- & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.row}; flex-shrink: 0; }
24
+ & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; box-sizing: content-box; height: ${tokens_1.contract.size.row}; padding: 0 ${tokens_1.contract.space["3"]} ${tokens_1.contract.space["2"]}; flex-shrink: 0; }
25
+ & .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: ${tokens_1.contract.borderSubtle}; }
19
26
  & .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: ${tokens_1.contract.space["1"]}; }
20
27
  & .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; height: 28px; margin: 0; padding: 0 ${tokens_1.contract.space["2"]}; border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: ${tokens_1.contract.radius.md}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; letter-spacing: -0.14px; color: ${tokens_1.contract.fgMuted}; cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
21
28
  & .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
@@ -24,7 +31,7 @@ const config = (0, core_1.defineRecipe)({
24
31
  & .crisp-tabs-tab:focus-visible { outline: 2px solid ${tokens_1.contract.bgBrandSolid}; outline-offset: 1px; }
25
32
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.sm}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 6%, transparent); font-size: ${tokens_1.contract.text.xs}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgMuted}; }
26
33
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${tokens_1.contract.fgPrimary}; }
27
- & .crisp-tabs-indicator { position: absolute; bottom: calc(-1 * ${tokens_1.contract.space["2"]}); left: 0; 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; }
34
+ & .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; }
28
35
  & .crisp-tabs-panel { flex: 1; min-height: 0; }
29
36
  & .crisp-tabs-panel:focus-visible { outline: none; }
30
37
  `,
@@ -86,6 +86,24 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
86
86
  document.addEventListener("pointerdown", onDown);
87
87
  return () => document.removeEventListener("pointerdown", onDown);
88
88
  }, [open, setOpen]);
89
+ // A document-level fallback for Escape (#376 come back). The content's own
90
+ // Escape handling (`useMenuKeyboard`'s onKeyDown, below) only fires once
91
+ // focus is actually inside `.crisp-menu-content` -- but focus lands there
92
+ // via a `requestAnimationFrame` scheduled on open (menu-keyboard.ts), not
93
+ // synchronously. An Escape pressed before that frame paints reaches
94
+ // whatever still has focus (the trigger button), which is outside the
95
+ // content div's subtree, so `onContentKeyDown` never sees it and nothing
96
+ // closes. This listener doesn't depend on focus location, so it closes
97
+ // regardless -- the same pattern `ContextMenu` already uses for the
98
+ // identical reason (its own comment: "Escape is handled twice on
99
+ // purpose").
100
+ React.useEffect(() => {
101
+ if (!open)
102
+ return;
103
+ const onKey = (e) => e.key === "Escape" && close();
104
+ document.addEventListener("keydown", onKey);
105
+ return () => document.removeEventListener("keydown", onKey);
106
+ }, [open, close]);
89
107
  const { onKeyDown: onContentKeyDown } = useMenuKeyboard({
90
108
  open,
91
109
  close,
@@ -89,10 +89,10 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
89
89
  if (searchable)
90
90
  requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
91
91
  };
92
- const close = () => {
92
+ const close = React.useCallback(() => {
93
93
  setOpen(false);
94
94
  triggerRef.current?.focus({ preventScroll: true });
95
- };
95
+ }, []);
96
96
  const activeId = open && highlight >= 0 && highlight < filtered.length
97
97
  ? optionId(highlight)
98
98
  : undefined;
@@ -146,6 +146,18 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
146
146
  document.addEventListener("pointerdown", onDown);
147
147
  return () => document.removeEventListener("pointerdown", onDown);
148
148
  }, [open]);
149
+ // A document-level fallback for Escape (#376 come back) -- see Select's
150
+ // matching comment: the listbox's own Escape handling depends on focus
151
+ // already being inside it, which a requestAnimationFrame moves there
152
+ // asynchronously after open, so an Escape pressed before that frame
153
+ // paints is missed entirely. This closes regardless of focus location.
154
+ React.useEffect(() => {
155
+ if (!open)
156
+ return;
157
+ const onKey = (e) => e.key === "Escape" && close();
158
+ document.addEventListener("keydown", onKey);
159
+ return () => document.removeEventListener("keydown", onKey);
160
+ }, [open, close]);
149
161
  return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ invalid, className }), ...rest, children: [name &&
150
162
  (value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
151
163
  if (open && !searchable)
@@ -73,6 +73,23 @@ export function Popover({ trigger, children, align = "start", open: openProp, de
73
73
  document.addEventListener("pointerdown", onDown);
74
74
  return () => document.removeEventListener("pointerdown", onDown);
75
75
  }, [open, setOpen]);
76
+ // A document-level fallback for Escape (#376 come back). The content's own
77
+ // Escape handling (its `onKeyDown`, below) only fires when focus is
78
+ // already inside `.crisp-popover-content` -- but Popover, unlike Menu,
79
+ // never moves focus there on open at all, so that handler only ever fires
80
+ // if a consumer's own content happens to contain something already
81
+ // focused. In the common case (trigger stays focused after the click that
82
+ // opened it) Escape reached nothing and did nothing. This listener
83
+ // doesn't depend on focus location, so it closes regardless -- the same
84
+ // pattern `ContextMenu` already uses for the identical reason (its own
85
+ // comment: "Escape is handled twice on purpose").
86
+ React.useEffect(() => {
87
+ if (!open)
88
+ return;
89
+ const onKey = (e) => e.key === "Escape" && setOpen(false);
90
+ document.addEventListener("keydown", onKey);
91
+ return () => document.removeEventListener("keydown", onKey);
92
+ }, [open, setOpen]);
76
93
  const triggerEl = React.cloneElement(trigger, {
77
94
  ref: triggerRef,
78
95
  id: triggerId,
@@ -81,10 +81,10 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
81
81
  if (searchable)
82
82
  requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
83
83
  };
84
- const close = () => {
84
+ const close = React.useCallback(() => {
85
85
  setOpen(false);
86
86
  triggerRef.current?.focus({ preventScroll: true });
87
- };
87
+ }, []);
88
88
  const pick = (opt) => {
89
89
  if (!opt || opt.disabled)
90
90
  return;
@@ -141,6 +141,24 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
141
141
  document.addEventListener("pointerdown", onDown);
142
142
  return () => document.removeEventListener("pointerdown", onDown);
143
143
  }, [open]);
144
+ // A document-level fallback for Escape (#376 come back): the listbox's own
145
+ // Escape handling lives on `useListboxKeyboard`'s `onKeyDown`, which only
146
+ // fires once focus is actually inside the listbox -- but focus lands there
147
+ // via a `requestAnimationFrame` scheduled on open (below), not
148
+ // synchronously. Escape pressed before that frame paints reaches whatever
149
+ // still has focus (the trigger, or the search input before ITS OWN focus
150
+ // lands), which is outside the listbox's own subtree, so its handler never
151
+ // sees the keydown at all and nothing closes. This listener doesn't depend
152
+ // on focus location, so it closes regardless -- the same pattern
153
+ // `ContextMenu` already uses for the identical reason (its own comment:
154
+ // "Escape is handled twice on purpose").
155
+ React.useEffect(() => {
156
+ if (!open)
157
+ return;
158
+ const onKey = (e) => e.key === "Escape" && close();
159
+ document.addEventListener("keydown", onKey);
160
+ return () => document.removeEventListener("keydown", onKey);
161
+ }, [open, close]);
144
162
  return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
145
163
  // controlled by the listbox above; `readOnly` would bar it from
146
164
  // validation, which is the only reason this input is not hidden
@@ -1,18 +1,25 @@
1
1
  import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
2
  import { contract as c } from "../../tokens/index.js";
3
3
  // Record-view tabs: a row of segmented tab buttons over a 1px baseline.
4
- // The active tab is a light pill — bg #fbfbfb + inset ring borderSubtle, r8 —
5
- // with a 2px ink underline that slides along the baseline; inactive tabs are
6
- // muted and fill to a subtle pill on hover. Each tab carries a 16px leading icon
7
- // and an optional count badge. Measured from the live app: 28px pills, 4/8
8
- // padding, 6px icon-label gap, 14/500 labels, 10px badges (rgba(0,0,0,.04)).
4
+ // The list itself is the 36px bar — the 28px tab row plus the 8px the
5
+ // baseline needs, reserved as bottom padding so it's real box height and not
6
+ // paint that trailing content can sit on top of. The bar carries 12px inline
7
+ // padding too, and draws its own full-width `::after` baseline in
8
+ // borderSubtle at its bottom edge, spanning that padding. The active tab is
9
+ // a light pill — bg #fbfbfb + inset ring borderSubtle, r8 — with a 1px ink
10
+ // underline that sits on the baseline (painted above it) and slides along it;
11
+ // inactive tabs are muted and fill to a subtle pill on hover. Each tab carries
12
+ // a 16px leading icon and an optional count badge. Measured from the live
13
+ // app: bar `padding: 0 12px 8px`, 28px pills, 4/8 padding, 6px icon-label
14
+ // gap, 14/500 labels, 10px badges (rgba(0,0,0,.04)).
9
15
  const config = defineRecipe({
10
16
  name: "tabs",
11
17
  base: `
12
18
  display: flex; flex-direction: column; min-height: 0; font-family: ${c.font.sans};
13
19
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
14
20
 
15
- & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.row}; flex-shrink: 0; }
21
+ & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: ${c.space["1"]}; box-sizing: content-box; height: ${c.size.row}; padding: 0 ${c.space["3"]} ${c.space["2"]}; flex-shrink: 0; }
22
+ & .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: ${c.borderSubtle}; }
16
23
  & .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: ${c.space["1"]}; }
17
24
  & .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; height: 28px; margin: 0; padding: 0 ${c.space["2"]}; border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: ${c.radius.md}; font-family: inherit; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; letter-spacing: -0.14px; color: ${c.fgMuted}; cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
18
25
  & .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
@@ -21,7 +28,7 @@ const config = defineRecipe({
21
28
  & .crisp-tabs-tab:focus-visible { outline: 2px solid ${c.bgBrandSolid}; outline-offset: 1px; }
22
29
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); font-size: ${c.text.xs}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
23
30
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${c.fgPrimary}; }
24
- & .crisp-tabs-indicator { position: absolute; bottom: calc(-1 * ${c.space["2"]}); left: 0; 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; }
31
+ & .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; }
25
32
  & .crisp-tabs-panel { flex: 1; min-height: 0; }
26
33
  & .crisp-tabs-panel:focus-visible { outline: none; }
27
34
  `,
package/dist/styles.css CHANGED
@@ -1625,7 +1625,8 @@
1625
1625
 
1626
1626
  .crisp-tabs {
1627
1627
  display: flex; flex-direction: column; min-height: 0; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
1628
- & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-row); flex-shrink: 0; }
1628
+ & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); box-sizing: content-box; height: var(--crisp-size-row); padding: 0 var(--crisp-space-3) var(--crisp-space-2); flex-shrink: 0; }
1629
+ & .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--crisp-border-subtle); }
1629
1630
  & .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: var(--crisp-space-1); }
1630
1631
  & .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); height: 28px; margin: 0; padding: 0 var(--crisp-space-2); border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: var(--crisp-radius-md); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: var(--crisp-fg-muted); cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
1631
1632
  & .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
@@ -1634,7 +1635,7 @@
1634
1635
  & .crisp-tabs-tab:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
1635
1636
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); }
1636
1637
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: var(--crisp-fg-primary); }
1637
- & .crisp-tabs-indicator { position: absolute; bottom: calc(-1 * var(--crisp-space-2)); left: 0; height: 1px; background: var(--crisp-fg-primary); 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; }
1638
+ & .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: var(--crisp-fg-primary); 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; }
1638
1639
  & .crisp-tabs-panel { flex: 1; min-height: 0; }
1639
1640
  & .crisp-tabs-panel:focus-visible { outline: none; }
1640
1641
  }
@@ -1626,7 +1626,8 @@
1626
1626
 
1627
1627
  .crisp-tabs {
1628
1628
  display: flex; flex-direction: column; min-height: 0; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
1629
- & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-row); flex-shrink: 0; }
1629
+ & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); box-sizing: content-box; height: var(--crisp-size-row); padding: 0 var(--crisp-space-3) var(--crisp-space-2); flex-shrink: 0; }
1630
+ & .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--crisp-border-subtle); }
1630
1631
  & .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: var(--crisp-space-1); }
1631
1632
  & .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); height: 28px; margin: 0; padding: 0 var(--crisp-space-2); border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: var(--crisp-radius-md); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: var(--crisp-fg-muted); cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
1632
1633
  & .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
@@ -1635,7 +1636,7 @@
1635
1636
  & .crisp-tabs-tab:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
1636
1637
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); }
1637
1638
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: var(--crisp-fg-primary); }
1638
- & .crisp-tabs-indicator { position: absolute; bottom: calc(-1 * var(--crisp-space-2)); left: 0; height: 1px; background: var(--crisp-fg-primary); 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; }
1639
+ & .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: var(--crisp-fg-primary); 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; }
1639
1640
  & .crisp-tabs-panel { flex: 1; min-height: 0; }
1640
1641
  & .crisp-tabs-panel:focus-visible { outline: none; }
1641
1642
  }
package/dist/tabs.css CHANGED
@@ -1,6 +1,7 @@
1
1
  .crisp-tabs {
2
2
  display: flex; flex-direction: column; min-height: 0; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
3
- & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-row); flex-shrink: 0; }
3
+ & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); box-sizing: content-box; height: var(--crisp-size-row); padding: 0 var(--crisp-space-3) var(--crisp-space-2); flex-shrink: 0; }
4
+ & .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--crisp-border-subtle); }
4
5
  & .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: var(--crisp-space-1); }
5
6
  & .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); height: 28px; margin: 0; padding: 0 var(--crisp-space-2); border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: var(--crisp-radius-md); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: var(--crisp-fg-muted); cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
6
7
  & .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
@@ -9,7 +10,7 @@
9
10
  & .crisp-tabs-tab:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
10
11
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); }
11
12
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: var(--crisp-fg-primary); }
12
- & .crisp-tabs-indicator { position: absolute; bottom: calc(-1 * var(--crisp-space-2)); left: 0; height: 1px; background: var(--crisp-fg-primary); 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; }
13
+ & .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: var(--crisp-fg-primary); 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; }
13
14
  & .crisp-tabs-panel { flex: 1; min-height: 0; }
14
15
  & .crisp-tabs-panel:focus-visible { outline: none; }
15
16
  }
@@ -1,7 +1,8 @@
1
1
  @layer crisp-components {
2
2
  .crisp-tabs {
3
3
  display: flex; flex-direction: column; min-height: 0; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
4
- & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-row); flex-shrink: 0; }
4
+ & .crisp-tabs-list { position: relative; display: flex; align-items: center; gap: var(--crisp-space-1); box-sizing: content-box; height: var(--crisp-size-row); padding: 0 var(--crisp-space-3) var(--crisp-space-2); flex-shrink: 0; }
5
+ & .crisp-tabs-list::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--crisp-border-subtle); }
5
6
  & .crisp-tabs-leading { display: inline-flex; align-items: center; margin-inline-end: var(--crisp-space-1); }
6
7
  & .crisp-tabs-tab { position: relative; display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); height: 28px; margin: 0; padding: 0 var(--crisp-space-2); border: none; background: none; box-shadow: inset 0 0 0 1px transparent; border-radius: var(--crisp-radius-md); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); letter-spacing: -0.14px; color: var(--crisp-fg-muted); cursor: pointer; white-space: nowrap; transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; }
7
8
  & .crisp-tabs-tab svg { flex-shrink: 0; opacity: 0.85; }
@@ -10,7 +11,7 @@
10
11
  & .crisp-tabs-tab:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
11
12
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); }
12
13
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: var(--crisp-fg-primary); }
13
- & .crisp-tabs-indicator { position: absolute; bottom: calc(-1 * var(--crisp-space-2)); left: 0; height: 1px; background: var(--crisp-fg-primary); 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; }
14
+ & .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: var(--crisp-fg-primary); 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; }
14
15
  & .crisp-tabs-panel { flex: 1; min-height: 0; }
15
16
  & .crisp-tabs-panel:focus-visible { outline: none; }
16
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",