@crisp-ui-kit/crisp 0.36.0 → 0.38.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/badge.css +5 -5
- package/dist/badge.layered.css +5 -5
- package/dist/cjs/components/badge/badge.recipe.js +11 -5
- package/dist/cjs/components/contextmenu/contextmenu.js +25 -8
- package/dist/cjs/components/contextmenu/contextmenu.recipe.js +5 -2
- package/dist/cjs/components/dialog/dialog.js +1 -1
- package/dist/cjs/components/dialog/dialog.recipe.js +1 -1
- package/dist/cjs/components/fileupload/fileupload.js +2 -2
- package/dist/cjs/components/menu/menu.js +20 -3
- package/dist/cjs/components/menu/menu.recipe.js +4 -3
- package/dist/cjs/components/multiselect/multiselect.js +29 -12
- package/dist/cjs/components/multiselect/multiselect.recipe.js +17 -5
- package/dist/cjs/components/popover/popover.js +25 -8
- package/dist/cjs/components/popover/popover.recipe.js +5 -3
- package/dist/cjs/components/select/select.js +32 -11
- package/dist/cjs/components/select/select.recipe.js +19 -4
- package/dist/cjs/components/tabletoolbar/tabletoolbar.js +50 -6
- package/dist/cjs/core/floating-portal.js +28 -0
- package/dist/cjs/core/floating-stack.js +54 -0
- package/dist/cjs/core/index.js +6 -1
- package/dist/cjs/product/recordstable/recordstable.js +15 -4
- package/dist/cjs/tokens/palette.js +23 -0
- package/dist/cjs/tokens/semantic.js +7 -0
- package/dist/contextmenu.css +1 -1
- package/dist/contextmenu.layered.css +1 -1
- package/dist/dialog.css +1 -1
- package/dist/dialog.layered.css +1 -1
- package/dist/esm/components/badge/badge.recipe.js +11 -5
- package/dist/esm/components/contextmenu/contextmenu.js +25 -8
- package/dist/esm/components/contextmenu/contextmenu.recipe.js +5 -2
- package/dist/esm/components/dialog/dialog.js +1 -1
- package/dist/esm/components/dialog/dialog.recipe.js +1 -1
- package/dist/esm/components/fileupload/fileupload.js +2 -2
- package/dist/esm/components/menu/menu.js +21 -4
- package/dist/esm/components/menu/menu.recipe.js +4 -3
- package/dist/esm/components/multiselect/multiselect.js +30 -13
- package/dist/esm/components/multiselect/multiselect.recipe.js +17 -5
- package/dist/esm/components/popover/popover.js +26 -9
- package/dist/esm/components/popover/popover.recipe.js +5 -3
- package/dist/esm/components/select/select.js +33 -12
- package/dist/esm/components/select/select.recipe.js +19 -4
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +51 -7
- package/dist/esm/core/floating-portal.js +25 -0
- package/dist/esm/core/floating-stack.js +50 -0
- package/dist/esm/core/index.js +2 -0
- package/dist/esm/product/recordstable/recordstable.js +16 -5
- package/dist/esm/tokens/palette.js +23 -0
- package/dist/esm/tokens/semantic.js +7 -0
- package/dist/menu.css +1 -1
- package/dist/menu.layered.css +1 -1
- package/dist/multiselect.css +7 -1
- package/dist/multiselect.d.ts +2 -2
- package/dist/multiselect.layered.css +7 -1
- package/dist/native/tokens.d.ts +1 -1
- package/dist/native/tokens.js +24 -4
- package/dist/popover.css +1 -1
- package/dist/popover.layered.css +1 -1
- package/dist/select.css +7 -1
- package/dist/select.d.ts +2 -1
- package/dist/select.layered.css +7 -1
- package/dist/styles.css +33 -11
- package/dist/styles.layered.css +33 -11
- package/dist/tokens.css +10 -0
- package/dist/tokens.json +24 -4
- package/dist/types/components/fileupload/fileupload.d.ts +1 -1
- package/dist/types/components/multiselect/multiselect.d.ts +3 -1
- package/dist/types/components/multiselect/multiselect.recipe.d.ts +18 -3
- package/dist/types/components/select/select.d.ts +4 -1
- package/dist/types/components/select/select.recipe.d.ts +12 -0
- package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +10 -1
- package/dist/types/core/floating-portal.d.ts +25 -0
- package/dist/types/core/floating-stack.d.ts +13 -0
- package/dist/types/core/index.d.ts +2 -0
- package/dist/types/product/sidebar/index.d.ts +1 -1
- package/dist/types/tokens/contract.d.ts +5 -0
- package/dist/types/tokens/palette.d.ts +20 -0
- package/dist/types/tokens/semantic.d.ts +7 -0
- package/package.json +1 -1
|
@@ -7,8 +7,10 @@ const tokens_1 = require("../../tokens");
|
|
|
7
7
|
// (radius 9, value + a 16px chevron) over a listbox — radius 12, 4px padding, a
|
|
8
8
|
// layered soft shadow — of 32px options (radius 8, 6/8 padding) that show a
|
|
9
9
|
// check on the selected one and highlight on hover / keyboard. The listbox is
|
|
10
|
-
// portaled
|
|
11
|
-
//
|
|
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.
|
|
12
14
|
const config = (0, core_1.defineRecipe)({
|
|
13
15
|
name: "select",
|
|
14
16
|
base: `
|
|
@@ -31,7 +33,7 @@ const config = (0, core_1.defineRecipe)({
|
|
|
31
33
|
& .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); }
|
|
32
34
|
|
|
33
35
|
& .crisp-select-listbox {
|
|
34
|
-
position: fixed; z-index:
|
|
36
|
+
position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px};
|
|
35
37
|
max-height: 280px; overflow-y: auto; padding: ${tokens_1.contract.space["1"]};
|
|
36
38
|
border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover};
|
|
37
39
|
box-shadow: ${tokens_1.elevation.floating};
|
|
@@ -71,8 +73,21 @@ const config = (0, core_1.defineRecipe)({
|
|
|
71
73
|
full: `width: 100%;`,
|
|
72
74
|
auto: `width: auto;`,
|
|
73
75
|
},
|
|
76
|
+
// Same ring Input's `invalid` draws, adapted to Select's box-shadow-ring
|
|
77
|
+
// trigger (a real <button>, so it needs no ::after overlay the way
|
|
78
|
+
// Input/Textarea do). Outranks hover/open/focus, same reasoning as
|
|
79
|
+
// Input: a field being corrected stays visibly wrong while it is.
|
|
80
|
+
invalid: {
|
|
81
|
+
true: `
|
|
82
|
+
& .crisp-select-trigger,
|
|
83
|
+
& .crisp-select-trigger:hover,
|
|
84
|
+
& .crisp-select-trigger[data-open],
|
|
85
|
+
& .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgDanger}; }
|
|
86
|
+
`,
|
|
87
|
+
false: ``,
|
|
88
|
+
},
|
|
74
89
|
},
|
|
75
|
-
defaultVariants: { variant: "default", width: "full" },
|
|
90
|
+
defaultVariants: { variant: "default", width: "full", invalid: false },
|
|
76
91
|
compoundVariants: [],
|
|
77
92
|
});
|
|
78
93
|
exports.selectRecipe = Object.assign((0, core_1.resolveRecipe)(config), { config });
|
|
@@ -40,12 +40,14 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
40
40
|
const lucide_react_1 = require("lucide-react");
|
|
41
41
|
const React = __importStar(require("react"));
|
|
42
42
|
const core_1 = require("../../core");
|
|
43
|
+
const menu_1 = require("../menu");
|
|
43
44
|
const select_1 = require("../select");
|
|
44
45
|
const tabletoolbar_recipe_1 = require("./tabletoolbar.recipe");
|
|
45
46
|
const OP_LABELS = {
|
|
46
47
|
contains: "contains",
|
|
47
48
|
is: "is",
|
|
48
49
|
isnot: "is not",
|
|
50
|
+
isanyof: "is any of",
|
|
49
51
|
empty: "is empty",
|
|
50
52
|
notempty: "is not empty",
|
|
51
53
|
};
|
|
@@ -56,7 +58,16 @@ const OP_ORDER = [
|
|
|
56
58
|
"empty",
|
|
57
59
|
"notempty",
|
|
58
60
|
];
|
|
61
|
+
// "isanyof" is appended only for a column that supplies `values` — there is
|
|
62
|
+
// nothing to check off otherwise.
|
|
63
|
+
const opsFor = (col) => col?.values?.length ? [...OP_ORDER, "isanyof"] : OP_ORDER;
|
|
59
64
|
const opNeedsValue = (op) => op === "contains" || op === "is" || op === "isnot";
|
|
65
|
+
const opNeedsValues = (op) => op === "isanyof";
|
|
66
|
+
// "Department is any of IT, Finance" past two values becomes "… +N": the
|
|
67
|
+
// chip is one line, and a field can have far more than two facet values.
|
|
68
|
+
const isAnyOfSummary = (values) => values.length <= 2
|
|
69
|
+
? values.join(", ")
|
|
70
|
+
: `${values.slice(0, 2).join(", ")} +${values.length - 2}`;
|
|
60
71
|
const isEmptyValue = (v) => v == null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
61
72
|
const asText = (v) => {
|
|
62
73
|
if (isEmptyValue(v))
|
|
@@ -82,7 +93,8 @@ const asText = (v) => {
|
|
|
82
93
|
* display string, so a grouped-thousands "$1,200" still matches "1200".
|
|
83
94
|
*/
|
|
84
95
|
function applyFilters(rows, filters, get = (row, key) => row[key]) {
|
|
85
|
-
const active = filters.filter((f) => !opNeedsValue(f.op) || f.value.trim() !== "")
|
|
96
|
+
const active = filters.filter((f) => (!opNeedsValue(f.op) || f.value.trim() !== "") &&
|
|
97
|
+
(!opNeedsValues(f.op) || (f.values?.length ?? 0) > 0));
|
|
86
98
|
if (!active.length)
|
|
87
99
|
return rows;
|
|
88
100
|
return rows.filter((row) => active.every((f) => {
|
|
@@ -92,6 +104,8 @@ function applyFilters(rows, filters, get = (row, key) => row[key]) {
|
|
|
92
104
|
if (f.op === "notempty")
|
|
93
105
|
return !isEmptyValue(raw);
|
|
94
106
|
const t = asText(raw).toLowerCase();
|
|
107
|
+
if (f.op === "isanyof")
|
|
108
|
+
return (f.values ?? []).some((v) => v.toLowerCase() === t);
|
|
95
109
|
const v = f.value.trim().toLowerCase();
|
|
96
110
|
if (f.op === "contains")
|
|
97
111
|
return t.includes(v);
|
|
@@ -153,6 +167,8 @@ function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsChange,
|
|
|
153
167
|
const [openMenu, setOpenMenu] = React.useState(null);
|
|
154
168
|
const [dragSort, setDragSort] = React.useState(null);
|
|
155
169
|
const [filterQuery, setFilterQuery] = React.useState("");
|
|
170
|
+
// filters the "isanyof" checkbox list, same lifetime as filterQuery above
|
|
171
|
+
const [anyOfQuery, setAnyOfQuery] = React.useState("");
|
|
156
172
|
// close any open menu on outside click (anything not inside a menu or its
|
|
157
173
|
// trigger) or Escape
|
|
158
174
|
React.useEffect(() => {
|
|
@@ -160,8 +176,15 @@ function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsChange,
|
|
|
160
176
|
return;
|
|
161
177
|
const onDown = (e) => {
|
|
162
178
|
const t = e.target;
|
|
163
|
-
if (
|
|
164
|
-
|
|
179
|
+
if (t.closest(".crisp-tabletoolbar-menu, [data-menu-trigger]"))
|
|
180
|
+
return;
|
|
181
|
+
// #339: a Select opened from inside this popover (the sort row's
|
|
182
|
+
// direction control) now portals to document.body, so it's no longer
|
|
183
|
+
// inside .crisp-tabletoolbar-menu by DOM containment -- check the
|
|
184
|
+
// shared floating-layer stack too.
|
|
185
|
+
if ((0, core_1.isInsideLaterFloatingLayer)(t, null))
|
|
186
|
+
return;
|
|
187
|
+
setOpenMenu(null);
|
|
165
188
|
};
|
|
166
189
|
const onKey = (e) => e.key === "Escape" && setOpenMenu(null);
|
|
167
190
|
document.addEventListener("mousedown", onDown);
|
|
@@ -251,9 +274,30 @@ function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsChange,
|
|
|
251
274
|
.map((col) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => addFilter(col.key), children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)("span", { style: { width: 15 } })), col.label] }, col.key)))] }))] }), filters.map((cond) => {
|
|
252
275
|
const col = columns.find((c) => c.key === cond.key);
|
|
253
276
|
const open = openMenu === `fcond:${cond.id}`;
|
|
254
|
-
const hasValue = !opNeedsValue(cond.op) || cond.value.trim()
|
|
255
|
-
|
|
277
|
+
const hasValue = (!opNeedsValue(cond.op) || cond.value.trim()) &&
|
|
278
|
+
(!opNeedsValues(cond.op) || (cond.values?.length ?? 0) > 0);
|
|
279
|
+
return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-fchip", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-fchip-main", "data-menu-trigger": true, "aria-haspopup": "dialog", onClick: () => setOpenMenu(open ? null : `fcond:${cond.id}`), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-field", children: col?.label }), hasValue ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-op", children: OP_LABELS[cond.op] }), opNeedsValue(cond.op) && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-val", children: cond.value })), cond.op === "isanyof" && cond.values && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-val", children: isAnyOfSummary(cond.values) }))] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-op", children: selectConditionLabel }))] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-tabletoolbar-fchip-x", "aria-label": `Remove ${col?.label} filter`, onClick: () => removeFilter(cond.id), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2 }) })] }), open && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": `${col?.label} condition`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu-head", children: [col?.label, " condition"] }), opsFor(col).map((op) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", "data-on": cond.op === op ? "" : undefined, onClick: () => patchFilter(cond.id, { op }), children: [OP_LABELS[op], cond.op === op ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter value`,
|
|
256
280
|
// biome-ignore lint/a11y/noAutofocus: focus the value field
|
|
257
|
-
autoFocus: true, value: cond.value, onChange: (e) => patchFilter(cond.id, { value: e.target.value }) }))
|
|
281
|
+
autoFocus: true, value: cond.value, onChange: (e) => patchFilter(cond.id, { value: e.target.value }) })), cond.op === "isanyof" && col?.values && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter values`,
|
|
282
|
+
// biome-ignore lint/a11y/noAutofocus: focus the search field
|
|
283
|
+
autoFocus: true, value: anyOfQuery, onChange: (e) => setAnyOfQuery(e.target.value) }), col.values
|
|
284
|
+
.filter((v) => v
|
|
285
|
+
.toLowerCase()
|
|
286
|
+
.includes(anyOfQuery.trim().toLowerCase()))
|
|
287
|
+
.map((v) => {
|
|
288
|
+
const checked = (cond.values ?? []).includes(v);
|
|
289
|
+
return ((0, jsx_runtime_1.jsx)(menu_1.MenuCheckboxItem, { checked: checked, onCheckedChange: (next) => {
|
|
290
|
+
const set = new Set(cond.values ?? []);
|
|
291
|
+
if (next)
|
|
292
|
+
set.add(v);
|
|
293
|
+
else
|
|
294
|
+
set.delete(v);
|
|
295
|
+
// the column's own order, not click order —
|
|
296
|
+
// what the chip summary reads out
|
|
297
|
+
patchFilter(cond.id, {
|
|
298
|
+
values: col.values?.filter((x) => set.has(x)),
|
|
299
|
+
});
|
|
300
|
+
}, children: v }, v));
|
|
301
|
+
})] }))] }))] }, cond.id));
|
|
258
302
|
}), (0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-spacer" }), count != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-count", children: count })), children] }) }));
|
|
259
303
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FloatingPortal = FloatingPortal;
|
|
4
|
+
const react_dom_1 = require("react-dom");
|
|
5
|
+
/**
|
|
6
|
+
* Portals a floating layer (a `position: fixed` listbox/menu/popover
|
|
7
|
+
* content) to `document.body` — exactly what Tooltip already did with a
|
|
8
|
+
* bare `createPortal` call, pulled out so `Select`, `MultiSelect`, `Menu`,
|
|
9
|
+
* `Popover` and `ContextMenu` share the one implementation (#339).
|
|
10
|
+
*
|
|
11
|
+
* Needed because a `position: fixed` box resolves against the nearest
|
|
12
|
+
* ancestor that establishes a containing block for it — not necessarily the
|
|
13
|
+
* viewport. `transform` (even the identity `scale(1)`), `filter` and
|
|
14
|
+
* `backdrop-filter` all qualify, and `Dialog`'s own measured surface sets
|
|
15
|
+
* both a resting `transform: scale(1)` (so its `@starting-style` entrance
|
|
16
|
+
* has something to animate from) and `backdrop-filter: blur(15px)` (Attio's
|
|
17
|
+
* frosted-glass modal). A floating layer opened from a trigger inside a
|
|
18
|
+
* `Dialog` was rendering offset by the dialog's own box instead of anchored
|
|
19
|
+
* to its trigger.
|
|
20
|
+
*
|
|
21
|
+
* Every caller renders this conditionally on its own `open` state, which
|
|
22
|
+
* starts `false` and is driven only by client interaction — never a prop a
|
|
23
|
+
* fixture sets true at mount (unlike Dialog/Drawer/Command) — so this never
|
|
24
|
+
* touches `document` during SSR, the same safety Tooltip already had.
|
|
25
|
+
*/
|
|
26
|
+
function FloatingPortal({ children, }) {
|
|
27
|
+
return (0, react_dom_1.createPortal)(children, document.body);
|
|
28
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerFloatingLayer = registerFloatingLayer;
|
|
4
|
+
exports.isInsideLaterFloatingLayer = isInsideLaterFloatingLayer;
|
|
5
|
+
/**
|
|
6
|
+
* Tracks every currently-open `FloatingPortal` layer, in open order, so a
|
|
7
|
+
* layer's outside-click check can tell "outside me" from "inside a layer I
|
|
8
|
+
* spawned" (#339).
|
|
9
|
+
*
|
|
10
|
+
* Portaling `Select`/`MultiSelect`/`Menu`/`Popover`/`ContextMenu` to
|
|
11
|
+
* `document.body` fixed their own mis-positioning inside a `Dialog`, but it
|
|
12
|
+
* broke a composition each of them supports: one of these opened INSIDE
|
|
13
|
+
* another (TableToolbar's sort popover is a `Menu` holding a per-row
|
|
14
|
+
* `Select`). Before portaling, the inner layer was a DOM descendant of the
|
|
15
|
+
* outer one, so the outer's `rootRef.contains(target)` check saw a click
|
|
16
|
+
* inside the inner layer as "inside". Once both portal independently to
|
|
17
|
+
* `document.body`, they become DOM SIBLINGS — nested in the React tree, not
|
|
18
|
+
* the DOM tree — so that same check reads the inner layer's own pointerdown
|
|
19
|
+
* as "outside", and the outer layer closes itself, unmounting the inner one
|
|
20
|
+
* mid-interaction.
|
|
21
|
+
*
|
|
22
|
+
* The fix: each layer registers its content node here on open (removed on
|
|
23
|
+
* close/unmount), and a layer's outside-click check also accepts a target
|
|
24
|
+
* inside any layer registered AFTER it — i.e. any layer that could only have
|
|
25
|
+
* opened from inside it.
|
|
26
|
+
*/
|
|
27
|
+
const stack = [];
|
|
28
|
+
/** Call when a layer opens; call the returned cleanup when it closes. */
|
|
29
|
+
function registerFloatingLayer(el) {
|
|
30
|
+
stack.push(el);
|
|
31
|
+
return () => {
|
|
32
|
+
const i = stack.indexOf(el);
|
|
33
|
+
if (i >= 0)
|
|
34
|
+
stack.splice(i, 1);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* True when `target` sits inside a layer that was registered strictly after
|
|
39
|
+
* `self` — a layer that could only have been spawned from inside `self`,
|
|
40
|
+
* directly or through further nesting.
|
|
41
|
+
*
|
|
42
|
+
* Pass `null` for `self` when the caller is a hand-rolled dropdown that
|
|
43
|
+
* doesn't register itself on this stack (RecordsTable's and TableToolbar's
|
|
44
|
+
* own popovers, e.g.) — every registered layer then counts, which is the
|
|
45
|
+
* right query for "was this click inside some floating child I opened".
|
|
46
|
+
*/
|
|
47
|
+
function isInsideLaterFloatingLayer(target, self) {
|
|
48
|
+
const selfIndex = self ? stack.indexOf(self) : -1;
|
|
49
|
+
for (let i = selfIndex + 1; i < stack.length; i++) {
|
|
50
|
+
if (stack[i]?.contains(target))
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
package/dist/cjs/core/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.usePrefersReducedMotion = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.formValueStyle = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = void 0;
|
|
17
|
+
exports.usePrefersReducedMotion = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.formValueStyle = exports.registerFloatingLayer = exports.isInsideLaterFloatingLayer = exports.FloatingPortal = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = void 0;
|
|
18
18
|
var cell_date_1 = require("./cell-date");
|
|
19
19
|
Object.defineProperty(exports, "formatCellDate", { enumerable: true, get: function () { return cell_date_1.formatCellDate; } });
|
|
20
20
|
Object.defineProperty(exports, "parseCellDate", { enumerable: true, get: function () { return cell_date_1.parseCellDate; } });
|
|
@@ -26,6 +26,11 @@ var dialog_description_1 = require("./dialog-description");
|
|
|
26
26
|
Object.defineProperty(exports, "withDescribedBy", { enumerable: true, get: function () { return dialog_description_1.withDescribedBy; } });
|
|
27
27
|
var direction_1 = require("./direction");
|
|
28
28
|
Object.defineProperty(exports, "isRtlElement", { enumerable: true, get: function () { return direction_1.isRtlElement; } });
|
|
29
|
+
var floating_portal_1 = require("./floating-portal");
|
|
30
|
+
Object.defineProperty(exports, "FloatingPortal", { enumerable: true, get: function () { return floating_portal_1.FloatingPortal; } });
|
|
31
|
+
var floating_stack_1 = require("./floating-stack");
|
|
32
|
+
Object.defineProperty(exports, "isInsideLaterFloatingLayer", { enumerable: true, get: function () { return floating_stack_1.isInsideLaterFloatingLayer; } });
|
|
33
|
+
Object.defineProperty(exports, "registerFloatingLayer", { enumerable: true, get: function () { return floating_stack_1.registerFloatingLayer; } });
|
|
29
34
|
var form_value_1 = require("./form-value");
|
|
30
35
|
Object.defineProperty(exports, "formValueStyle", { enumerable: true, get: function () { return form_value_1.formValueStyle; } });
|
|
31
36
|
var place_floating_1 = require("./place-floating");
|
|
@@ -499,8 +499,15 @@ function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, r
|
|
|
499
499
|
return;
|
|
500
500
|
const onDown = (e) => {
|
|
501
501
|
const t = e.target;
|
|
502
|
-
if (
|
|
503
|
-
|
|
502
|
+
if (t.closest(".crisp-recordstable-menu, [data-menu-trigger]"))
|
|
503
|
+
return;
|
|
504
|
+
// #339: a Select/MultiSelect/Menu opened from inside this popover now
|
|
505
|
+
// portals to document.body, so it's no longer inside
|
|
506
|
+
// .crisp-recordstable-menu by DOM containment -- check the shared
|
|
507
|
+
// floating-layer stack too.
|
|
508
|
+
if ((0, core_1.isInsideLaterFloatingLayer)(t, null))
|
|
509
|
+
return;
|
|
510
|
+
setOpenMenu(null);
|
|
504
511
|
};
|
|
505
512
|
const onKey = (e) => e.key === "Escape" && setOpenMenu(null);
|
|
506
513
|
document.addEventListener("mousedown", onDown);
|
|
@@ -515,8 +522,12 @@ function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, r
|
|
|
515
522
|
if (!ctxMenu)
|
|
516
523
|
return;
|
|
517
524
|
const onDown = (e) => {
|
|
518
|
-
|
|
519
|
-
|
|
525
|
+
const t = e.target;
|
|
526
|
+
if (t.closest(".crisp-recordstable-ctx"))
|
|
527
|
+
return;
|
|
528
|
+
if ((0, core_1.isInsideLaterFloatingLayer)(t, null))
|
|
529
|
+
return;
|
|
530
|
+
setCtxMenu(null);
|
|
520
531
|
};
|
|
521
532
|
const onKey = (e) => e.key === "Escape" && setCtxMenu(null);
|
|
522
533
|
document.addEventListener("mousedown", onDown);
|
|
@@ -60,4 +60,27 @@ exports.palette = {
|
|
|
60
60
|
cyan600: { light: "#00b9eb", dark: "#00b9eb" },
|
|
61
61
|
purple600: { light: "#9b69ff", dark: "#9b69ff" },
|
|
62
62
|
graySidebar: { light: "#fbfbfb", dark: "#0e0f10" },
|
|
63
|
+
// Soft Badge's text colour (#323): the *600 tones above, used directly as
|
|
64
|
+
// soft-badge text, fail AA on their own soft tint in light mode (2.97-3.87)
|
|
65
|
+
// and brand fails in dark too (3.83) -- everything else already brightens
|
|
66
|
+
// enough in dark to pass (measured: 5.60-6.77). Attio's own soft tag pills
|
|
67
|
+
// (a company's Categories field, app.attio.com, 2026-09-23) don't reuse the
|
|
68
|
+
// accent colour as text either: they shift it toward black (light) or white
|
|
69
|
+
// (dark) at roughly the same relative luminance regardless of hue --
|
|
70
|
+
// measured 5 different category colours in dark mode (L 0.61-0.75) and two
|
|
71
|
+
// in light (L 0.08-0.10), a tight enough cluster to generalise the SHIFT,
|
|
72
|
+
// not the exact colour, to crisp's own four accent hues. Computed to clear
|
|
73
|
+
// AA with the same margin Attio's pills measured at (5.5-6.5:1) against
|
|
74
|
+
// crisp's EXISTING soft-tint background -- the tint itself is unchanged.
|
|
75
|
+
// Dark values that already passed keep their *600 dark value unchanged.
|
|
76
|
+
softBrandText: { light: "#2057bd", dark: "#568df3" },
|
|
77
|
+
softSuccessText: { light: "#186b3d", dark: "#2ea968" },
|
|
78
|
+
softWarningText: { light: "#80520d", dark: "#e0982a" },
|
|
79
|
+
softDangerText: { light: "#a33539", dark: "#ff5a5f" },
|
|
80
|
+
// Same reasoning, for Badge's neutral tone: fg-muted on bg-raised measures
|
|
81
|
+
// 4.40 in light (just under AA) and 6.77 in dark (already fine). Unlike
|
|
82
|
+
// fg-muted/bg-raised themselves (system-wide, measured from Attio, kept
|
|
83
|
+
// as-is per build/spec-check.mjs's own note), this token exists only for
|
|
84
|
+
// Badge's soft-neutral text, so darkening it doesn't move any other surface.
|
|
85
|
+
softNeutralText: { light: "#686870", dark: "#9a9aa2" },
|
|
63
86
|
};
|
|
@@ -54,4 +54,11 @@ exports.semantic = {
|
|
|
54
54
|
// #fbfbfb — the subtle raised fill; the active tab pill uses the same
|
|
55
55
|
// value as the sidebar, so it shares the palette entry under a neutral name.
|
|
56
56
|
bgSubtle: "graySidebar",
|
|
57
|
+
/** Soft Badge's text colour (#323) — see palette.ts's comment on the
|
|
58
|
+
* soft*Text entries for the Attio measurement this is derived from. */
|
|
59
|
+
fgBrandSoft: "softBrandText",
|
|
60
|
+
fgSuccessSoft: "softSuccessText",
|
|
61
|
+
fgWarningSoft: "softWarningText",
|
|
62
|
+
fgDangerSoft: "softDangerText",
|
|
63
|
+
fgNeutralSoft: "softNeutralText",
|
|
57
64
|
};
|
package/dist/contextmenu.css
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
.crisp-contextmenu {
|
|
2
2
|
display: contents; font-family: var(--crisp-font-sans);
|
|
3
|
-
& .crisp-contextmenu-content { position: fixed; z-index:
|
|
3
|
+
& .crisp-contextmenu-content { position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: var(--crisp-space-1); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-popover); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
|
|
4
4
|
& .crisp-contextmenu-item { display: flex; align-items: center; gap: var(--crisp-space-2); width: 100%; height: var(--crisp-size-menuRow); box-sizing: border-box; padding: 0 var(--crisp-space-2); margin: 0; border: none; border-radius: var(--crisp-radius-md); background: none; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); text-align: start; white-space: nowrap; cursor: pointer; }
|
|
5
5
|
& .crisp-contextmenu-item svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
|
|
6
6
|
& .crisp-contextmenu-item:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
@layer crisp-components {
|
|
2
2
|
.crisp-contextmenu {
|
|
3
3
|
display: contents; font-family: var(--crisp-font-sans);
|
|
4
|
-
& .crisp-contextmenu-content { position: fixed; z-index:
|
|
4
|
+
& .crisp-contextmenu-content { position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: var(--crisp-space-1); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-popover); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
|
|
5
5
|
& .crisp-contextmenu-item { display: flex; align-items: center; gap: var(--crisp-space-2); width: 100%; height: var(--crisp-size-menuRow); box-sizing: border-box; padding: 0 var(--crisp-space-2); margin: 0; border: none; border-radius: var(--crisp-radius-md); background: none; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); text-align: start; white-space: nowrap; cursor: pointer; }
|
|
6
6
|
& .crisp-contextmenu-item svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
|
|
7
7
|
& .crisp-contextmenu-item:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); }
|
package/dist/dialog.css
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
& .crisp-dialog-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-surface); box-shadow: 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); overflow: hidden; }
|
|
7
7
|
& .crisp-dialog-header { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; height: 52px; padding: 0 var(--crisp-space-2) 0 var(--crisp-space-4); border-bottom: 1px solid var(--crisp-border-subtle); }
|
|
8
8
|
& .crisp-dialog-header svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
|
|
9
|
-
& .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); }
|
|
9
|
+
& .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); margin: 0; }
|
|
10
10
|
& .crisp-dialog-close { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: var(--crisp-radius-md); background: none; color: var(--crisp-fg-muted); font-family: inherit; cursor: pointer; }
|
|
11
11
|
& .crisp-dialog-close:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); color: var(--crisp-fg-primary); }
|
|
12
12
|
& .crisp-dialog-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: var(--crisp-space-4); padding: var(--crisp-space-5) var(--crisp-space-4); }
|
package/dist/dialog.layered.css
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
& .crisp-dialog-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-surface); box-shadow: 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); overflow: hidden; }
|
|
8
8
|
& .crisp-dialog-header { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; height: 52px; padding: 0 var(--crisp-space-2) 0 var(--crisp-space-4); border-bottom: 1px solid var(--crisp-border-subtle); }
|
|
9
9
|
& .crisp-dialog-header svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
|
|
10
|
-
& .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); }
|
|
10
|
+
& .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); margin: 0; }
|
|
11
11
|
& .crisp-dialog-close { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: var(--crisp-radius-md); background: none; color: var(--crisp-fg-muted); font-family: inherit; cursor: pointer; }
|
|
12
12
|
& .crisp-dialog-close:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); color: var(--crisp-fg-primary); }
|
|
13
13
|
& .crisp-dialog-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: var(--crisp-space-4); padding: var(--crisp-space-5) var(--crisp-space-4); }
|
|
@@ -29,29 +29,35 @@ const spec = defineSpec({
|
|
|
29
29
|
],
|
|
30
30
|
variants: {
|
|
31
31
|
tone: {
|
|
32
|
+
// #323: the soft text colour is a dedicated fg*Soft token, not the
|
|
33
|
+
// tone's own accent (fgMuted/bgBrandSolid/bgSuccess/bgWarning/bgDanger)
|
|
34
|
+
// — reusing the accent as soft text is exactly what measured below AA
|
|
35
|
+
// (2.97-4.40 in light; brand also failed in dark, 3.83). Background and
|
|
36
|
+
// ring are unchanged: they were never the problem, and neither is a
|
|
37
|
+
// measured Attio value that moving would help — only the text is.
|
|
32
38
|
neutral: {
|
|
33
39
|
background: c.bgRaised,
|
|
34
|
-
color: c.
|
|
40
|
+
color: c.fgNeutralSoft,
|
|
35
41
|
boxShadow: `inset 0 0 0 1px ${c.borderDefault}`,
|
|
36
42
|
},
|
|
37
43
|
brand: {
|
|
38
44
|
background: soft(c.bgBrandSolid, 14),
|
|
39
|
-
color: c.
|
|
45
|
+
color: c.fgBrandSoft,
|
|
40
46
|
boxShadow: ring(c.bgBrandSolid, 30),
|
|
41
47
|
},
|
|
42
48
|
success: {
|
|
43
49
|
background: soft(c.bgSuccess, 14),
|
|
44
|
-
color: c.
|
|
50
|
+
color: c.fgSuccessSoft,
|
|
45
51
|
boxShadow: ring(c.bgSuccess, 30),
|
|
46
52
|
},
|
|
47
53
|
warning: {
|
|
48
54
|
background: soft(c.bgWarning, 16),
|
|
49
|
-
color: c.
|
|
55
|
+
color: c.fgWarningSoft,
|
|
50
56
|
boxShadow: ring(c.bgWarning, 32),
|
|
51
57
|
},
|
|
52
58
|
danger: {
|
|
53
59
|
background: soft(c.bgDanger, 14),
|
|
54
|
-
color: c.
|
|
60
|
+
color: c.fgDangerSoft,
|
|
55
61
|
boxShadow: ring(c.bgDanger, 30),
|
|
56
62
|
},
|
|
57
63
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import * as React from "react";
|
|
3
|
+
import { FloatingPortal, isInsideLaterFloatingLayer, registerFloatingLayer, } from "../../core/index.js";
|
|
3
4
|
import { useMenuKeyboard } from "../menu/index.js";
|
|
4
5
|
import { contextmenuRecipe } from "./contextmenu.recipe.js";
|
|
5
6
|
/**
|
|
@@ -41,12 +42,28 @@ export function ContextMenu({ children, items, menuLabel = "Context menu", class
|
|
|
41
42
|
dismiss: () => close(),
|
|
42
43
|
contentRef: menuRef,
|
|
43
44
|
});
|
|
45
|
+
// Registers the (now-portaled) menu on the shared floating-layer stack
|
|
46
|
+
// (#339) so this doesn't get closed out from under itself when it holds
|
|
47
|
+
// another floating layer -- see core/floating-stack.ts.
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (!pos || !menuRef.current)
|
|
50
|
+
return;
|
|
51
|
+
return registerFloatingLayer(menuRef.current);
|
|
52
|
+
}, [pos]);
|
|
44
53
|
React.useEffect(() => {
|
|
45
54
|
if (!pos)
|
|
46
55
|
return;
|
|
47
56
|
const onDown = (e) => {
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
// menuRef too (#339): the menu content portals to document.body, so
|
|
58
|
+
// it's no longer a descendant of rootRef.
|
|
59
|
+
const target = e.target;
|
|
60
|
+
if (rootRef.current?.contains(target))
|
|
61
|
+
return;
|
|
62
|
+
if (menuRef.current?.contains(target))
|
|
63
|
+
return;
|
|
64
|
+
if (isInsideLaterFloatingLayer(target, menuRef.current))
|
|
65
|
+
return;
|
|
66
|
+
close();
|
|
50
67
|
};
|
|
51
68
|
const onKey = (e) => e.key === "Escape" && close(true);
|
|
52
69
|
document.addEventListener("pointerdown", onDown);
|
|
@@ -56,11 +73,11 @@ export function ContextMenu({ children, items, menuLabel = "Context menu", class
|
|
|
56
73
|
document.removeEventListener("keydown", onKey);
|
|
57
74
|
};
|
|
58
75
|
}, [pos, close]);
|
|
59
|
-
return (_jsxs("div", { ref: rootRef, className: contextmenuRecipe({ className }), children: [_jsx("div", { onContextMenu: open, children: children }), pos && (_jsx("div", { ref: menuRef, className: "crisp-contextmenu-content", role: "menu", "aria-label": menuLabel, style: { left: pos.x, top: pos.y }, onKeyDown: onMenuKeyDown, children: items.map((item, i) => item.separator ? (_jsx("hr", { className: "crisp-contextmenu-sep" }, i)) : (_jsxs("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
return (_jsxs("div", { ref: rootRef, className: contextmenuRecipe({ className }), children: [_jsx("div", { onContextMenu: open, children: children }), pos && (_jsx(FloatingPortal, { children: _jsx("div", { ref: menuRef, className: "crisp-contextmenu-content", role: "menu", "aria-label": menuLabel, style: { left: pos.x, top: pos.y }, onKeyDown: onMenuKeyDown, children: items.map((item, i) => item.separator ? (_jsx("hr", { className: "crisp-contextmenu-sep" }, i)) : (_jsxs("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
|
|
77
|
+
? "crisp-contextmenu-item crisp-contextmenu-item--danger"
|
|
78
|
+
: "crisp-contextmenu-item", tabIndex: -1, onClick: () => {
|
|
79
|
+
item.onSelect?.();
|
|
80
|
+
close(true);
|
|
81
|
+
}, children: [item.icon, item.label, item.shortcut && (_jsx("span", { className: "crisp-contextmenu-shortcut", children: item.shortcut }))] }, i))) }) }))] }));
|
|
65
82
|
}
|
|
66
83
|
ContextMenu.displayName = "ContextMenu";
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { defineRecipe, resolveRecipe } from "../../core/index.js";
|
|
2
2
|
import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
3
|
-
// A right-click menu on the measured popover surface, positioned at the
|
|
3
|
+
// A right-click menu on the measured popover surface, positioned at the
|
|
4
|
+
// cursor. The content portals to document.body (#339) so no ancestor's
|
|
5
|
+
// transform/backdrop-filter can capture its position: fixed box; z-index 70
|
|
6
|
+
// keeps it above Dialog/Drawer's 60/61.
|
|
4
7
|
const config = defineRecipe({
|
|
5
8
|
name: "contextmenu",
|
|
6
9
|
base: `display: contents; font-family: ${c.font.sans};
|
|
7
|
-
& .crisp-contextmenu-content { position: fixed; z-index:
|
|
10
|
+
& .crisp-contextmenu-content { position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: ${c.bgPopover}; box-shadow: ${e.floating}; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
|
|
8
11
|
& .crisp-contextmenu-item { display: flex; align-items: center; gap: ${c.space["2"]}; width: 100%; height: ${c.size.menuRow}; box-sizing: border-box; padding: 0 ${c.space["2"]}; margin: 0; border: none; border-radius: ${c.radius.md}; background: none; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.lg}; text-align: start; white-space: nowrap; cursor: pointer; }
|
|
9
12
|
& .crisp-contextmenu-item svg { flex-shrink: 0; color: ${c.fgMuted}; }
|
|
10
13
|
& .crisp-contextmenu-item:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
|
|
@@ -41,7 +41,7 @@ export function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-lab
|
|
|
41
41
|
return (_jsx(Portal, { children: _jsxs("div", { className: dialogRecipe(), children: [_jsx("div", { ...api.getBackdropProps(), className: "crisp-dialog-backdrop" }), _jsx("div", { ...api.getPositionerProps(), className: "crisp-dialog-positioner", children: _jsx("div", { ...rest, ...withDescribedBy(api.getContentProps(), describedBy), className: cx("crisp-dialog-content", className), "data-crisp-dialog": id,
|
|
42
42
|
// `width` is the component's own knob, so it wins over a passed
|
|
43
43
|
// maxWidth; everything else a consumer sets survives
|
|
44
|
-
style: { ...style, maxWidth: `min(${width}px, 90vw)` }, children: _jsxs("div", { className: "crisp-dialog-card", children: [title != null && (_jsxs("div", { className: "crisp-dialog-header", children: [icon, _jsx("
|
|
44
|
+
style: { ...style, maxWidth: `min(${width}px, 90vw)` }, children: _jsxs("div", { className: "crisp-dialog-card", children: [title != null && (_jsxs("div", { className: "crisp-dialog-header", children: [icon, _jsx("h2", { ...api.getTitleProps(), className: "crisp-dialog-title", children: title }), _jsx("button", { type: "button", ...api.getCloseTriggerProps(), className: "crisp-dialog-close", "aria-label": closeLabel, children: _jsx(X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] })), children] }) }) })] }) }));
|
|
45
45
|
}
|
|
46
46
|
Dialog.displayName = "Dialog";
|
|
47
47
|
export function DialogBody({ children, className, }) {
|
|
@@ -14,7 +14,7 @@ const config = defineRecipe({
|
|
|
14
14
|
& .crisp-dialog-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 8%, transparent); overflow: hidden; }
|
|
15
15
|
& .crisp-dialog-header { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; height: 52px; padding: 0 ${c.space["2"]} 0 ${c.space["4"]}; border-bottom: 1px solid ${c.borderSubtle}; }
|
|
16
16
|
& .crisp-dialog-header svg { flex-shrink: 0; color: ${c.fgMuted}; }
|
|
17
|
-
& .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; }
|
|
17
|
+
& .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; margin: 0; }
|
|
18
18
|
& .crisp-dialog-close { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.md}; background: none; color: ${c.fgMuted}; font-family: inherit; cursor: pointer; }
|
|
19
19
|
& .crisp-dialog-close:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: ${c.fgPrimary}; }
|
|
20
20
|
& .crisp-dialog-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: ${c.space["4"]}; padding: ${c.space["5"]} ${c.space["4"]}; }
|
|
@@ -36,7 +36,7 @@ function formatSize(bytes) {
|
|
|
36
36
|
* is theirs by construction. Controlled or uncontrolled, the same way every
|
|
37
37
|
* other crisp component is.
|
|
38
38
|
*/
|
|
39
|
-
export function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
|
|
39
|
+
export function FileUpload({ className, labels, "aria-label": ariaLabel, id, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
|
|
40
40
|
const props = {
|
|
41
41
|
accept,
|
|
42
42
|
disabled: disabledProp,
|
|
@@ -87,7 +87,7 @@ export function FileUpload({ className, labels, "aria-label": ariaLabel, accept,
|
|
|
87
87
|
// focus should land next.
|
|
88
88
|
const rootRef = React.useRef(null);
|
|
89
89
|
const focusAfterRemoval = useFocusAfterRemoval(rootRef, ".crisp-fileupload-remove", dropzoneRef);
|
|
90
|
-
return (_jsxs("div", { ref: rootRef, ...rest, className: fileuploadRecipe({ className }), children: [_jsxs("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [_jsx("span", { className: "crisp-fileupload-title", children: t.title }), _jsx("span", { className: "crisp-fileupload-hint", children: t.hint })] }), _jsx("input", { ...hiddenInputProps, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && (_jsx("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => (_jsxs("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [_jsx("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && (_jsx("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: _jsx("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), _jsx("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), _jsx("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
|
|
90
|
+
return (_jsxs("div", { ref: rootRef, ...rest, className: fileuploadRecipe({ className }), children: [_jsxs("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [_jsx("span", { className: "crisp-fileupload-title", children: t.title }), _jsx("span", { className: "crisp-fileupload-hint", children: t.hint })] }), _jsx("input", { ...hiddenInputProps, id: id ?? hiddenInputProps.id, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && (_jsx("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => (_jsxs("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [_jsx("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && (_jsx("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: _jsx("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), _jsx("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), _jsx("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
|
|
91
91
|
focusAfterRemoval(i);
|
|
92
92
|
removeFileEntry(entry.id);
|
|
93
93
|
}, children: _jsx(X, { size: 13, strokeWidth: 2 }) })] }, entry.id))) })), _jsx("ul", { className: "crisp-fileupload-errors", "aria-live": "polite", children: rejections.map((r) => (_jsx("li", { className: "crisp-fileupload-error", children: `${r.file.name}: ${r.errors.map((e) => t.errors[e] ?? e).join(" ")}` }, r.file.name))) })] }));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Check } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { placeFloating } from "../../core/index.js";
|
|
4
|
+
import { FloatingPortal, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, } from "../../core/index.js";
|
|
5
5
|
import { menuRecipe } from "./menu.recipe.js";
|
|
6
6
|
import { useMenuKeyboard } from "./menu-keyboard.js";
|
|
7
7
|
const MenuContext = React.createContext(null);
|
|
@@ -58,13 +58,30 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
|
|
|
58
58
|
setOpen(false);
|
|
59
59
|
triggerRef.current?.focus?.();
|
|
60
60
|
}, [setOpen]);
|
|
61
|
+
// Registers the (now-portaled) content on the shared floating-layer stack
|
|
62
|
+
// (#339) so this doesn't get closed out from under itself when it holds
|
|
63
|
+
// another floating layer (a Select in a sort popover, e.g.) -- see
|
|
64
|
+
// core/floating-stack.ts.
|
|
65
|
+
React.useEffect(() => {
|
|
66
|
+
if (!open || !contentRef.current)
|
|
67
|
+
return;
|
|
68
|
+
return registerFloatingLayer(contentRef.current);
|
|
69
|
+
}, [open]);
|
|
61
70
|
// close on outside pointer-down
|
|
62
71
|
React.useEffect(() => {
|
|
63
72
|
if (!open)
|
|
64
73
|
return;
|
|
65
74
|
const onDown = (e) => {
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
// contentRef too (#339): the menu content portals to document.body, so
|
|
76
|
+
// it's no longer a descendant of rootRef.
|
|
77
|
+
const target = e.target;
|
|
78
|
+
if (rootRef.current?.contains(target))
|
|
79
|
+
return;
|
|
80
|
+
if (contentRef.current?.contains(target))
|
|
81
|
+
return;
|
|
82
|
+
if (isInsideLaterFloatingLayer(target, contentRef.current))
|
|
83
|
+
return;
|
|
84
|
+
setOpen(false);
|
|
68
85
|
};
|
|
69
86
|
document.addEventListener("pointerdown", onDown);
|
|
70
87
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
@@ -88,7 +105,7 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
|
|
|
88
105
|
setOpen(!open);
|
|
89
106
|
},
|
|
90
107
|
});
|
|
91
|
-
return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(MenuContext.Provider, { value: { close }, children: _jsx("div", { ref: contentRef, className: "crisp-menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: children }) }))] }));
|
|
108
|
+
return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, { children: _jsx(MenuContext.Provider, { value: { close }, children: _jsx("div", { ref: contentRef, className: "crisp-menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: children }) }) }))] }));
|
|
92
109
|
}
|
|
93
110
|
Menu.displayName = "Menu";
|
|
94
111
|
export function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
|