@crisp-ui-kit/crisp 0.39.0 → 0.40.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.
@@ -72,6 +72,18 @@ function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-label": ar
72
72
  // search), rather than the close button; fall back to the machine default.
73
73
  initialFocusEl: () => document.querySelector(`[data-crisp-dialog="${id}"] .crisp-dialog-body :where(input, textarea, select, [contenteditable]):not([disabled])`),
74
74
  "aria-label": typeof title === "string" ? title : ariaLabel,
75
+ // Defer to a nested Menu/Select/Popover/ContextMenu's own Escape
76
+ // handling (#361): none of those five are Zag machines, so this
77
+ // machine's own dismissable-layer stack has no visibility into them --
78
+ // pressing Escape to close one opened from inside this Dialog closed
79
+ // the Dialog too, since both listen for the same keypress independently.
80
+ // Zag only calls its own dismiss when the event isn't already
81
+ // prevented (`@zag-js/dismissable`), so this cleanly defers instead of
82
+ // racing it.
83
+ onEscapeKeyDown(event) {
84
+ if ((0, core_1.hasOpenFloatingLayer)())
85
+ event.preventDefault();
86
+ },
75
87
  });
76
88
  const api = dialog.connect(service, react_1.normalizeProps);
77
89
  if (!api.open)
@@ -66,6 +66,13 @@ function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-label":
66
66
  onOpenChange(details.open);
67
67
  },
68
68
  "aria-label": typeof title === "string" ? title : ariaLabel,
69
+ // Defer to a nested Menu/Select/Popover/ContextMenu's own Escape
70
+ // handling (#361) -- see Dialog's identical comment; this composes the
71
+ // same Zag machine.
72
+ onEscapeKeyDown(event) {
73
+ if ((0, core_1.hasOpenFloatingLayer)())
74
+ event.preventDefault();
75
+ },
69
76
  });
70
77
  const api = dialog.connect(service, react_1.normalizeProps);
71
78
  if (!api.open)
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerFloatingLayer = registerFloatingLayer;
4
4
  exports.isInsideLaterFloatingLayer = isInsideLaterFloatingLayer;
5
+ exports.hasOpenFloatingLayer = hasOpenFloatingLayer;
5
6
  /**
6
7
  * Tracks every currently-open `FloatingPortal` layer, in open order, so a
7
8
  * layer's outside-click check can tell "outside me" from "inside a layer I
@@ -52,3 +53,23 @@ function isInsideLaterFloatingLayer(target, self) {
52
53
  }
53
54
  return false;
54
55
  }
56
+ /**
57
+ * True when at least one `Select`/`MultiSelect`/`Menu`/`Popover`/
58
+ * `ContextMenu` is currently open, anywhere (#361).
59
+ *
60
+ * A dismissable container that closes on Escape via its OWN raw keydown
61
+ * listener or a library's dismissable-layer machine (`RecordPanel`,
62
+ * `Dialog`, `Drawer`) has no visibility into these five: none of them are
63
+ * built on the same machine, so a Menu opened from inside a `Dialog`
64
+ * registers here, but the Dialog's own Escape handling doesn't know to
65
+ * check for it. Both listeners fire on the same Escape keydown, so both
66
+ * close — the outer container closing was never the point of that
67
+ * keypress, which was meant for the nested layer alone. Call this before
68
+ * closing on Escape (or in a dismissable-layer's `onEscapeKeyDown`, calling
69
+ * `event.preventDefault()` when it's true) to defer instead: the nested
70
+ * layer's own listener still runs and closes it, and the outer container
71
+ * gets its own, uncontested Escape on the next press.
72
+ */
73
+ function hasOpenFloatingLayer() {
74
+ return stack.length > 0;
75
+ }
@@ -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.registerFloatingLayer = exports.isInsideLaterFloatingLayer = exports.FloatingPortal = 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.hasOpenFloatingLayer = 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; } });
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "isRtlElement", { enumerable: true, get: function
29
29
  var floating_portal_1 = require("./floating-portal");
30
30
  Object.defineProperty(exports, "FloatingPortal", { enumerable: true, get: function () { return floating_portal_1.FloatingPortal; } });
31
31
  var floating_stack_1 = require("./floating-stack");
32
+ Object.defineProperty(exports, "hasOpenFloatingLayer", { enumerable: true, get: function () { return floating_stack_1.hasOpenFloatingLayer; } });
32
33
  Object.defineProperty(exports, "isInsideLaterFloatingLayer", { enumerable: true, get: function () { return floating_stack_1.isInsideLaterFloatingLayer; } });
33
34
  Object.defineProperty(exports, "registerFloatingLayer", { enumerable: true, get: function () { return floating_stack_1.registerFloatingLayer; } });
34
35
  var form_value_1 = require("./form-value");
@@ -38,6 +38,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
38
38
  const lucide_react_1 = require("lucide-react");
39
39
  const React = __importStar(require("react"));
40
40
  const avatar_1 = require("../../components/avatar");
41
+ const core_1 = require("../../core");
41
42
  const recordstable_1 = require("../recordstable");
42
43
  const recordpanel_recipe_1 = require("./recordpanel.recipe");
43
44
  function FieldValue({ col, row, emptyLabel, }) {
@@ -64,11 +65,21 @@ function FieldValue({ col, row, emptyLabel, }) {
64
65
  */
65
66
  function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", title, detailsLabel = "Details", emptyLabel = "Empty", footer, ...rest }) {
66
67
  const titleId = React.useId();
67
- // close on Escape while open
68
+ // close on Escape while open -- but not when a Menu/Select/Popover/
69
+ // ContextMenu opened from inside the panel (e.g. a footer "More actions"
70
+ // menu) is itself open (#361): that Escape belongs to the nested layer,
71
+ // which has its own listener and will close itself. Without this check
72
+ // both close on the same keypress, since neither knows about the other --
73
+ // none of the five are built on this listener or registered anywhere it
74
+ // can see, only on the shared floating-stack this checks instead.
68
75
  React.useEffect(() => {
69
76
  if (!record)
70
77
  return;
71
- const onKey = (e) => e.key === "Escape" && onClose();
78
+ const onKey = (e) => {
79
+ if (e.key !== "Escape" || (0, core_1.hasOpenFloatingLayer)())
80
+ return;
81
+ onClose();
82
+ };
72
83
  document.addEventListener("keydown", onKey);
73
84
  return () => document.removeEventListener("keydown", onKey);
74
85
  }, [record, onClose]);
@@ -3,7 +3,7 @@ import * as dialog from "@zag-js/dialog";
3
3
  import { normalizeProps, Portal, useMachine } from "@zag-js/react";
4
4
  import { X } from "lucide-react";
5
5
  import * as React from "react";
6
- import { cx, withDescribedBy } from "../../core/index.js";
6
+ import { cx, hasOpenFloatingLayer, withDescribedBy } from "../../core/index.js";
7
7
  import { dialogRecipe } from "./dialog.recipe.js";
8
8
  // A globally unique id per mounted instance (see command.tsx for the rationale —
9
9
  // the machine defers its dismissal listeners a frame, so tree-relative useId
@@ -34,6 +34,18 @@ export function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-lab
34
34
  // search), rather than the close button; fall back to the machine default.
35
35
  initialFocusEl: () => document.querySelector(`[data-crisp-dialog="${id}"] .crisp-dialog-body :where(input, textarea, select, [contenteditable]):not([disabled])`),
36
36
  "aria-label": typeof title === "string" ? title : ariaLabel,
37
+ // Defer to a nested Menu/Select/Popover/ContextMenu's own Escape
38
+ // handling (#361): none of those five are Zag machines, so this
39
+ // machine's own dismissable-layer stack has no visibility into them --
40
+ // pressing Escape to close one opened from inside this Dialog closed
41
+ // the Dialog too, since both listen for the same keypress independently.
42
+ // Zag only calls its own dismiss when the event isn't already
43
+ // prevented (`@zag-js/dismissable`), so this cleanly defers instead of
44
+ // racing it.
45
+ onEscapeKeyDown(event) {
46
+ if (hasOpenFloatingLayer())
47
+ event.preventDefault();
48
+ },
37
49
  });
38
50
  const api = dialog.connect(service, normalizeProps);
39
51
  if (!api.open)
@@ -3,7 +3,7 @@ import * as dialog from "@zag-js/dialog";
3
3
  import { normalizeProps, Portal, useMachine } from "@zag-js/react";
4
4
  import { X } from "lucide-react";
5
5
  import * as React from "react";
6
- import { cx, withDescribedBy } from "../../core/index.js";
6
+ import { cx, hasOpenFloatingLayer, withDescribedBy } from "../../core/index.js";
7
7
  import { drawerRecipe } from "./drawer.recipe.js";
8
8
  let instanceCounter = 0;
9
9
  function useDrawerInstanceId() {
@@ -28,6 +28,13 @@ export function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-
28
28
  onOpenChange(details.open);
29
29
  },
30
30
  "aria-label": typeof title === "string" ? title : ariaLabel,
31
+ // Defer to a nested Menu/Select/Popover/ContextMenu's own Escape
32
+ // handling (#361) -- see Dialog's identical comment; this composes the
33
+ // same Zag machine.
34
+ onEscapeKeyDown(event) {
35
+ if (hasOpenFloatingLayer())
36
+ event.preventDefault();
37
+ },
31
38
  });
32
39
  const api = dialog.connect(service, normalizeProps);
33
40
  if (!api.open)
@@ -48,3 +48,23 @@ export function isInsideLaterFloatingLayer(target, self) {
48
48
  }
49
49
  return false;
50
50
  }
51
+ /**
52
+ * True when at least one `Select`/`MultiSelect`/`Menu`/`Popover`/
53
+ * `ContextMenu` is currently open, anywhere (#361).
54
+ *
55
+ * A dismissable container that closes on Escape via its OWN raw keydown
56
+ * listener or a library's dismissable-layer machine (`RecordPanel`,
57
+ * `Dialog`, `Drawer`) has no visibility into these five: none of them are
58
+ * built on the same machine, so a Menu opened from inside a `Dialog`
59
+ * registers here, but the Dialog's own Escape handling doesn't know to
60
+ * check for it. Both listeners fire on the same Escape keydown, so both
61
+ * close — the outer container closing was never the point of that
62
+ * keypress, which was meant for the nested layer alone. Call this before
63
+ * closing on Escape (or in a dismissable-layer's `onEscapeKeyDown`, calling
64
+ * `event.preventDefault()` when it's true) to defer instead: the nested
65
+ * layer's own listener still runs and closes it, and the outer container
66
+ * gets its own, uncontested Escape on the next press.
67
+ */
68
+ export function hasOpenFloatingLayer() {
69
+ return stack.length > 0;
70
+ }
@@ -4,7 +4,7 @@ export { defineRecipe } from "./define.js";
4
4
  export { withDescribedBy } from "./dialog-description.js";
5
5
  export { isRtlElement } from "./direction.js";
6
6
  export { FloatingPortal } from "./floating-portal.js";
7
- export { isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack.js";
7
+ export { hasOpenFloatingLayer, isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack.js";
8
8
  export { formValueStyle } from "./form-value.js";
9
9
  export { placeFloating, } from "./place-floating.js";
10
10
  export { Primitive } from "./primitive.js";
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { X } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { Avatar } from "../../components/avatar/index.js";
5
+ import { hasOpenFloatingLayer } from "../../core/index.js";
5
6
  import { tagTone, } from "../recordstable/index.js";
6
7
  import { recordpanelRecipe } from "./recordpanel.recipe.js";
7
8
  function FieldValue({ col, row, emptyLabel, }) {
@@ -28,11 +29,21 @@ function FieldValue({ col, row, emptyLabel, }) {
28
29
  */
29
30
  export function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", title, detailsLabel = "Details", emptyLabel = "Empty", footer, ...rest }) {
30
31
  const titleId = React.useId();
31
- // close on Escape while open
32
+ // close on Escape while open -- but not when a Menu/Select/Popover/
33
+ // ContextMenu opened from inside the panel (e.g. a footer "More actions"
34
+ // menu) is itself open (#361): that Escape belongs to the nested layer,
35
+ // which has its own listener and will close itself. Without this check
36
+ // both close on the same keypress, since neither knows about the other --
37
+ // none of the five are built on this listener or registered anywhere it
38
+ // can see, only on the shared floating-stack this checks instead.
32
39
  React.useEffect(() => {
33
40
  if (!record)
34
41
  return;
35
- const onKey = (e) => e.key === "Escape" && onClose();
42
+ const onKey = (e) => {
43
+ if (e.key !== "Escape" || hasOpenFloatingLayer())
44
+ return;
45
+ onClose();
46
+ };
36
47
  document.addEventListener("keydown", onKey);
37
48
  return () => document.removeEventListener("keydown", onKey);
38
49
  }, [record, onClose]);
@@ -11,3 +11,21 @@ export declare function registerFloatingLayer(el: HTMLElement): () => void;
11
11
  * right query for "was this click inside some floating child I opened".
12
12
  */
13
13
  export declare function isInsideLaterFloatingLayer(target: Node, self: HTMLElement | null): boolean;
14
+ /**
15
+ * True when at least one `Select`/`MultiSelect`/`Menu`/`Popover`/
16
+ * `ContextMenu` is currently open, anywhere (#361).
17
+ *
18
+ * A dismissable container that closes on Escape via its OWN raw keydown
19
+ * listener or a library's dismissable-layer machine (`RecordPanel`,
20
+ * `Dialog`, `Drawer`) has no visibility into these five: none of them are
21
+ * built on the same machine, so a Menu opened from inside a `Dialog`
22
+ * registers here, but the Dialog's own Escape handling doesn't know to
23
+ * check for it. Both listeners fire on the same Escape keydown, so both
24
+ * close — the outer container closing was never the point of that
25
+ * keypress, which was meant for the nested layer alone. Call this before
26
+ * closing on Escape (or in a dismissable-layer's `onEscapeKeyDown`, calling
27
+ * `event.preventDefault()` when it's true) to defer instead: the nested
28
+ * layer's own listener still runs and closes it, and the outer container
29
+ * gets its own, uncontested Escape on the next press.
30
+ */
31
+ export declare function hasOpenFloatingLayer(): boolean;
@@ -4,7 +4,7 @@ export { defineRecipe } from "./define";
4
4
  export { withDescribedBy } from "./dialog-description";
5
5
  export { isRtlElement } from "./direction";
6
6
  export { FloatingPortal } from "./floating-portal";
7
- export { isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack";
7
+ export { hasOpenFloatingLayer, isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack";
8
8
  export { formValueStyle } from "./form-value";
9
9
  export { type FloatingAlign, type PlaceFloatingOptions, placeFloating, } from "./place-floating";
10
10
  export { Primitive } from "./primitive";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",