@crisp-ui-kit/crisp 0.13.0 → 0.14.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.
@@ -42,17 +42,37 @@ const contextmenu_recipe_1 = require("./contextmenu.recipe");
42
42
  * Wrap the target; right-clicking it opens the menu (flipping at the viewport
43
43
  * edges), closing on select, outside pointer-down, and Escape.
44
44
  */
45
- function ContextMenu({ children, items, className }) {
45
+ function ContextMenu({ children, items, menuLabel = "Context menu", className, }) {
46
46
  const rootRef = React.useRef(null);
47
+ const menuRef = React.useRef(null);
48
+ const returnFocusTo = React.useRef(null);
47
49
  const [pos, setPos] = React.useState(null);
48
50
  const open = (e) => {
49
51
  e.preventDefault();
52
+ // where focus goes back to on close. The menu takes focus to be operable
53
+ // at all, so something has to remember what had it.
54
+ returnFocusTo.current = document.activeElement;
50
55
  // flip so the menu stays on-screen near the right/bottom edges
51
56
  const x = Math.min(e.clientX, window.innerWidth - 220);
52
57
  const y = Math.min(e.clientY, window.innerHeight - 12 - items.length * 34);
53
58
  setPos({ x: Math.max(8, x), y: Math.max(8, y) });
54
59
  };
55
- const close = React.useCallback(() => setPos(null), []);
60
+ const close = React.useCallback((restoreFocus = false) => {
61
+ setPos(null);
62
+ if (restoreFocus)
63
+ returnFocusTo.current?.focus?.();
64
+ returnFocusTo.current = null;
65
+ }, []);
66
+ /** The items a roving focus may land on: not separators, not disabled. */
67
+ const enabled = React.useCallback(() => Array.from(menuRef.current?.querySelectorAll('[role="menuitem"]:not([disabled])') ?? []), []);
68
+ // `role="menu"` puts a screen reader in application mode, where the arrow
69
+ // keys are the only navigation there is — so opening has to put focus on an
70
+ // item, or the role is a promise the component does not keep (#188).
71
+ React.useEffect(() => {
72
+ if (!pos)
73
+ return;
74
+ enabled()[0]?.focus();
75
+ }, [pos, enabled]);
56
76
  React.useEffect(() => {
57
77
  if (!pos)
58
78
  return;
@@ -60,7 +80,7 @@ function ContextMenu({ children, items, className }) {
60
80
  if (!rootRef.current?.contains(e.target))
61
81
  close();
62
82
  };
63
- const onKey = (e) => e.key === "Escape" && close();
83
+ const onKey = (e) => e.key === "Escape" && close(true);
64
84
  document.addEventListener("pointerdown", onDown);
65
85
  document.addEventListener("keydown", onKey);
66
86
  return () => {
@@ -68,11 +88,34 @@ function ContextMenu({ children, items, className }) {
68
88
  document.removeEventListener("keydown", onKey);
69
89
  };
70
90
  }, [pos, close]);
71
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, contextmenu_recipe_1.contextmenuRecipe)({ className }), children: [(0, jsx_runtime_1.jsx)("div", { onContextMenu: open, children: children }), pos && ((0, jsx_runtime_1.jsx)("div", { className: "contextmenu-content", role: "menu", style: { left: pos.x, top: pos.y }, children: items.map((item, i) => item.separator ? ((0, jsx_runtime_1.jsx)("hr", { className: "contextmenu-sep" }, i)) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
91
+ const onMenuKeyDown = (e) => {
92
+ // a menu is not a focus trap: Tab leaves, and leaving means closing
93
+ if (e.key === "Tab") {
94
+ close(true);
95
+ return;
96
+ }
97
+ const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
98
+ const items = enabled();
99
+ if (!items.length)
100
+ return;
101
+ const at = items.indexOf(document.activeElement);
102
+ let next = null;
103
+ if (step)
104
+ next = (at + step + items.length) % items.length;
105
+ else if (e.key === "Home")
106
+ next = 0;
107
+ else if (e.key === "End")
108
+ next = items.length - 1;
109
+ if (next === null)
110
+ return;
111
+ e.preventDefault();
112
+ items[next]?.focus();
113
+ };
114
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, contextmenu_recipe_1.contextmenuRecipe)({ className }), children: [(0, jsx_runtime_1.jsx)("div", { onContextMenu: open, children: children }), pos && ((0, jsx_runtime_1.jsx)("div", { ref: menuRef, className: "contextmenu-content", role: "menu", "aria-label": menuLabel, style: { left: pos.x, top: pos.y }, onKeyDown: onMenuKeyDown, children: items.map((item, i) => item.separator ? ((0, jsx_runtime_1.jsx)("hr", { className: "contextmenu-sep" }, i)) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
72
115
  ? "contextmenu-item contextmenu-item--danger"
73
- : "contextmenu-item", onClick: () => {
116
+ : "contextmenu-item", tabIndex: -1, onClick: () => {
74
117
  item.onSelect?.();
75
- close();
118
+ close(true);
76
119
  }, children: [item.icon, item.label, item.shortcut && ((0, jsx_runtime_1.jsx)("span", { className: "contextmenu-shortcut", children: item.shortcut }))] }, i))) }))] }));
77
120
  }
78
121
  ContextMenu.displayName = "ContextMenu";
@@ -62,6 +62,13 @@ const asText = (v) => {
62
62
  return "";
63
63
  if (Array.isArray(v))
64
64
  return v.join(", ");
65
+ // Before the generic object branch, which would look for `.name`, find none
66
+ // and return "" — turning every Date into the empty string (#185). The ISO
67
+ // date is stable and locale-independent, so `contains "2026-06"` works; it
68
+ // will not match "16/06/2026", and a date-aware operator set is Attio's
69
+ // date-picker UI, which has to be measured before it can be built.
70
+ if (v instanceof Date)
71
+ return Number.isNaN(v.getTime()) ? "" : v.toISOString().slice(0, 10);
65
72
  if (typeof v === "object") {
66
73
  const name = v.name;
67
74
  return typeof name === "string" ? name : "";
@@ -104,7 +111,12 @@ function applySorts(rows, sorts, get = (row, key) => row[key]) {
104
111
  const av = get(a, s.key);
105
112
  const bv = get(b, s.key);
106
113
  let d = 0;
107
- if (typeof av === "number" && typeof bv === "number")
114
+ // Dates compare by instant, the way two numbers do. Falling through to
115
+ // text here is what made a date column sort identically ascending and
116
+ // descending — every comparison was "" against "" (#185).
117
+ if (av instanceof Date && bv instanceof Date)
118
+ d = av.getTime() - bv.getTime();
119
+ else if (typeof av === "number" && typeof bv === "number")
108
120
  d = av - bv;
109
121
  else
110
122
  d = asText(av).localeCompare(asText(bv));
@@ -6,17 +6,37 @@ import { contextmenuRecipe } from "./contextmenu.recipe.js";
6
6
  * Wrap the target; right-clicking it opens the menu (flipping at the viewport
7
7
  * edges), closing on select, outside pointer-down, and Escape.
8
8
  */
9
- export function ContextMenu({ children, items, className }) {
9
+ export function ContextMenu({ children, items, menuLabel = "Context menu", className, }) {
10
10
  const rootRef = React.useRef(null);
11
+ const menuRef = React.useRef(null);
12
+ const returnFocusTo = React.useRef(null);
11
13
  const [pos, setPos] = React.useState(null);
12
14
  const open = (e) => {
13
15
  e.preventDefault();
16
+ // where focus goes back to on close. The menu takes focus to be operable
17
+ // at all, so something has to remember what had it.
18
+ returnFocusTo.current = document.activeElement;
14
19
  // flip so the menu stays on-screen near the right/bottom edges
15
20
  const x = Math.min(e.clientX, window.innerWidth - 220);
16
21
  const y = Math.min(e.clientY, window.innerHeight - 12 - items.length * 34);
17
22
  setPos({ x: Math.max(8, x), y: Math.max(8, y) });
18
23
  };
19
- const close = React.useCallback(() => setPos(null), []);
24
+ const close = React.useCallback((restoreFocus = false) => {
25
+ setPos(null);
26
+ if (restoreFocus)
27
+ returnFocusTo.current?.focus?.();
28
+ returnFocusTo.current = null;
29
+ }, []);
30
+ /** The items a roving focus may land on: not separators, not disabled. */
31
+ const enabled = React.useCallback(() => Array.from(menuRef.current?.querySelectorAll('[role="menuitem"]:not([disabled])') ?? []), []);
32
+ // `role="menu"` puts a screen reader in application mode, where the arrow
33
+ // keys are the only navigation there is — so opening has to put focus on an
34
+ // item, or the role is a promise the component does not keep (#188).
35
+ React.useEffect(() => {
36
+ if (!pos)
37
+ return;
38
+ enabled()[0]?.focus();
39
+ }, [pos, enabled]);
20
40
  React.useEffect(() => {
21
41
  if (!pos)
22
42
  return;
@@ -24,7 +44,7 @@ export function ContextMenu({ children, items, className }) {
24
44
  if (!rootRef.current?.contains(e.target))
25
45
  close();
26
46
  };
27
- const onKey = (e) => e.key === "Escape" && close();
47
+ const onKey = (e) => e.key === "Escape" && close(true);
28
48
  document.addEventListener("pointerdown", onDown);
29
49
  document.addEventListener("keydown", onKey);
30
50
  return () => {
@@ -32,11 +52,34 @@ export function ContextMenu({ children, items, className }) {
32
52
  document.removeEventListener("keydown", onKey);
33
53
  };
34
54
  }, [pos, close]);
35
- return (_jsxs("div", { ref: rootRef, className: contextmenuRecipe({ className }), children: [_jsx("div", { onContextMenu: open, children: children }), pos && (_jsx("div", { className: "contextmenu-content", role: "menu", style: { left: pos.x, top: pos.y }, children: items.map((item, i) => item.separator ? (_jsx("hr", { className: "contextmenu-sep" }, i)) : (_jsxs("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
55
+ const onMenuKeyDown = (e) => {
56
+ // a menu is not a focus trap: Tab leaves, and leaving means closing
57
+ if (e.key === "Tab") {
58
+ close(true);
59
+ return;
60
+ }
61
+ const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
62
+ const items = enabled();
63
+ if (!items.length)
64
+ return;
65
+ const at = items.indexOf(document.activeElement);
66
+ let next = null;
67
+ if (step)
68
+ next = (at + step + items.length) % items.length;
69
+ else if (e.key === "Home")
70
+ next = 0;
71
+ else if (e.key === "End")
72
+ next = items.length - 1;
73
+ if (next === null)
74
+ return;
75
+ e.preventDefault();
76
+ items[next]?.focus();
77
+ };
78
+ return (_jsxs("div", { ref: rootRef, className: contextmenuRecipe({ className }), children: [_jsx("div", { onContextMenu: open, children: children }), pos && (_jsx("div", { ref: menuRef, className: "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: "contextmenu-sep" }, i)) : (_jsxs("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
36
79
  ? "contextmenu-item contextmenu-item--danger"
37
- : "contextmenu-item", onClick: () => {
80
+ : "contextmenu-item", tabIndex: -1, onClick: () => {
38
81
  item.onSelect?.();
39
- close();
82
+ close(true);
40
83
  }, children: [item.icon, item.label, item.shortcut && (_jsx("span", { className: "contextmenu-shortcut", children: item.shortcut }))] }, i))) }))] }));
41
84
  }
42
85
  ContextMenu.displayName = "ContextMenu";
@@ -24,6 +24,13 @@ const asText = (v) => {
24
24
  return "";
25
25
  if (Array.isArray(v))
26
26
  return v.join(", ");
27
+ // Before the generic object branch, which would look for `.name`, find none
28
+ // and return "" — turning every Date into the empty string (#185). The ISO
29
+ // date is stable and locale-independent, so `contains "2026-06"` works; it
30
+ // will not match "16/06/2026", and a date-aware operator set is Attio's
31
+ // date-picker UI, which has to be measured before it can be built.
32
+ if (v instanceof Date)
33
+ return Number.isNaN(v.getTime()) ? "" : v.toISOString().slice(0, 10);
27
34
  if (typeof v === "object") {
28
35
  const name = v.name;
29
36
  return typeof name === "string" ? name : "";
@@ -66,7 +73,12 @@ export function applySorts(rows, sorts, get = (row, key) => row[key]) {
66
73
  const av = get(a, s.key);
67
74
  const bv = get(b, s.key);
68
75
  let d = 0;
69
- if (typeof av === "number" && typeof bv === "number")
76
+ // Dates compare by instant, the way two numbers do. Falling through to
77
+ // text here is what made a date column sort identically ascending and
78
+ // descending — every comparison was "" against "" (#185).
79
+ if (av instanceof Date && bv instanceof Date)
80
+ d = av.getTime() - bv.getTime();
81
+ else if (typeof av === "number" && typeof bv === "number")
70
82
  d = av - bv;
71
83
  else
72
84
  d = asText(av).localeCompare(asText(bv));
@@ -14,6 +14,8 @@ export type ContextMenuProps = {
14
14
  /** The area that opens the menu on right-click. */
15
15
  children: React.ReactNode;
16
16
  items: ContextMenuOption[];
17
+ /** Accessible name for the menu surface. */
18
+ menuLabel?: string;
17
19
  className?: string;
18
20
  };
19
21
  /**
@@ -21,7 +23,7 @@ export type ContextMenuProps = {
21
23
  * Wrap the target; right-clicking it opens the menu (flipping at the viewport
22
24
  * edges), closing on select, outside pointer-down, and Escape.
23
25
  */
24
- export declare function ContextMenu({ children, items, className }: ContextMenuProps): React.JSX.Element;
26
+ export declare function ContextMenu({ children, items, menuLabel, className, }: ContextMenuProps): React.JSX.Element;
25
27
  export declare namespace ContextMenu {
26
28
  var displayName: string;
27
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",