@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.
- package/dist/cjs/components/menu/menu.js +18 -0
- package/dist/cjs/components/multiselect/multiselect.js +14 -2
- package/dist/cjs/components/popover/popover.js +17 -0
- package/dist/cjs/components/select/select.js +20 -2
- package/dist/cjs/components/tabs/tabs.recipe.js +14 -7
- package/dist/esm/components/menu/menu.js +18 -0
- package/dist/esm/components/multiselect/multiselect.js +14 -2
- package/dist/esm/components/popover/popover.js +17 -0
- package/dist/esm/components/select/select.js +20 -2
- package/dist/esm/components/tabs/tabs.recipe.js +14 -7
- package/dist/styles.css +3 -2
- package/dist/styles.layered.css +3 -2
- package/dist/tabs.css +3 -2
- package/dist/tabs.layered.css +3 -2
- package/package.json +1 -1
|
@@ -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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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:
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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:
|
|
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:
|
|
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
|
}
|
package/dist/styles.layered.css
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
}
|
package/dist/tabs.layered.css
CHANGED
|
@@ -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:
|
|
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
|
}
|