@crisp-ui-kit/crisp 0.14.0 → 0.16.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.
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ContextMenu = ContextMenu;
37
37
  const jsx_runtime_1 = require("react/jsx-runtime");
38
38
  const React = __importStar(require("react"));
39
+ const menu_1 = require("../menu");
39
40
  const contextmenu_recipe_1 = require("./contextmenu.recipe");
40
41
  /**
41
42
  * A right-click context menu — the popover surface positioned at the cursor.
@@ -63,16 +64,19 @@ function ContextMenu({ children, items, menuLabel = "Context menu", className, }
63
64
  returnFocusTo.current?.focus?.();
64
65
  returnFocusTo.current = null;
65
66
  }, []);
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
67
  // `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]);
68
+ // keys are the only navigation there is (#188). The model itself is crisp's
69
+ // shared one (#192) — Menu and all three RecordsTable surfaces run the same
70
+ // hook, and a fourth hand-written copy is how typeahead, menuitemcheckbox
71
+ // and the `[data-disabled]` spelling get lost one surface at a time.
72
+ const { onKeyDown: onMenuKeyDown } = (0, menu_1.useMenuKeyboard)({
73
+ open: !!pos,
74
+ close: () => close(true),
75
+ // Tab: the browser is already moving focus somewhere, so pulling it back
76
+ // to the trigger first would land the user in the wrong place
77
+ dismiss: () => close(),
78
+ contentRef: menuRef,
79
+ });
76
80
  React.useEffect(() => {
77
81
  if (!pos)
78
82
  return;
@@ -88,29 +92,6 @@ function ContextMenu({ children, items, menuLabel = "Context menu", className, }
88
92
  document.removeEventListener("keydown", onKey);
89
93
  };
90
94
  }, [pos, close]);
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
95
  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
115
96
  ? "contextmenu-item contextmenu-item--danger"
116
97
  : "contextmenu-item", tabIndex: -1, onClick: () => {
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.tabsRecipe = exports.Tabs = void 0;
3
+ exports.useTablistKeyboard = exports.tabsRecipe = exports.Tabs = void 0;
4
4
  var tabs_1 = require("./tabs");
5
5
  Object.defineProperty(exports, "Tabs", { enumerable: true, get: function () { return tabs_1.Tabs; } });
6
6
  var tabs_recipe_1 = require("./tabs.recipe");
7
7
  Object.defineProperty(exports, "tabsRecipe", { enumerable: true, get: function () { return tabs_recipe_1.tabsRecipe; } });
8
+ var tabs_keyboard_1 = require("./tabs-keyboard");
9
+ Object.defineProperty(exports, "useTablistKeyboard", { enumerable: true, get: function () { return tabs_keyboard_1.useTablistKeyboard; } });
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useTablistKeyboard = useTablistKeyboard;
4
+ /**
5
+ * The WAI-ARIA tablist keyboard model: ←/→ to move (wrapping), Home/End to
6
+ * jump, disabled tabs skipped, focus following selection.
7
+ *
8
+ * Extracted from `Tabs` so a surface that renders its own measured tab chrome —
9
+ * Inbox's flyout tabs, and whatever comes next — gets the identical model
10
+ * rather than a second partial one. The same reason `useMenuKeyboard` exists.
11
+ *
12
+ * Automatic activation (arrow selects, rather than arrow-then-Enter) is APG's
13
+ * recommendation when switching tabs is cheap, and it is what `Tabs` already
14
+ * did before this was extracted, so it stays the behaviour.
15
+ */
16
+ function useTablistKeyboard({ tabs, active, select, listRef, }) {
17
+ const onKeyDown = (e) => {
18
+ const enabled = tabs.filter((t) => !t.disabled);
19
+ if (!enabled.length)
20
+ return;
21
+ const idx = enabled.findIndex((t) => t.value === active);
22
+ let next = -1;
23
+ if (e.key === "ArrowRight")
24
+ next = (idx + 1) % enabled.length;
25
+ else if (e.key === "ArrowLeft")
26
+ next = (idx - 1 + enabled.length) % enabled.length;
27
+ else if (e.key === "Home")
28
+ next = 0;
29
+ else if (e.key === "End")
30
+ next = enabled.length - 1;
31
+ if (next < 0)
32
+ return;
33
+ e.preventDefault();
34
+ const nextValue = enabled[next]?.value;
35
+ if (!nextValue)
36
+ return;
37
+ select(nextValue);
38
+ listRef.current
39
+ ?.querySelector(`[data-value="${CSS.escape(nextValue)}"]`)
40
+ ?.focus();
41
+ };
42
+ /**
43
+ * A tablist is a SINGLE tab stop: Tab enters the group and leaves it, and the
44
+ * arrows move within. Without this a keyboard user walks through every tab
45
+ * one at a time, and the number of stops grows with the tab set.
46
+ */
47
+ const tabIndexFor = (value) => (value === active ? 0 : -1);
48
+ return { onKeyDown, tabIndexFor };
49
+ }
@@ -37,6 +37,7 @@ exports.Tabs = Tabs;
37
37
  const jsx_runtime_1 = require("react/jsx-runtime");
38
38
  const React = __importStar(require("react"));
39
39
  const tabs_recipe_1 = require("./tabs.recipe");
40
+ const tabs_keyboard_1 = require("./tabs-keyboard");
40
41
  /**
41
42
  * A row of text tabs over a sliding ink underline — the record-view tab bar,
42
43
  * measured from Attio. Roving-tabindex keyboard model (←/→ move + activate,
@@ -70,32 +71,15 @@ function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, cla
70
71
  ro.observe(list);
71
72
  return () => ro.disconnect();
72
73
  }, [active, tabs]);
73
- const onKeyDown = (e) => {
74
- const enabled = tabs.filter((t) => !t.disabled);
75
- const idx = enabled.findIndex((t) => t.value === active);
76
- let next = -1;
77
- if (e.key === "ArrowRight")
78
- next = (idx + 1) % enabled.length;
79
- else if (e.key === "ArrowLeft")
80
- next = (idx - 1 + enabled.length) % enabled.length;
81
- else if (e.key === "Home")
82
- next = 0;
83
- else if (e.key === "End")
84
- next = enabled.length - 1;
85
- if (next < 0)
86
- return;
87
- e.preventDefault();
88
- const nextValue = enabled[next]?.value;
89
- if (!nextValue)
90
- return;
91
- select(nextValue);
92
- listRef.current
93
- ?.querySelector(`[data-value="${CSS.escape(nextValue)}"]`)
94
- ?.focus();
95
- };
74
+ const { onKeyDown, tabIndexFor } = (0, tabs_keyboard_1.useTablistKeyboard)({
75
+ tabs,
76
+ active,
77
+ select,
78
+ listRef,
79
+ });
96
80
  return ((0, jsx_runtime_1.jsxs)("div", { className: (0, tabs_recipe_1.tabsRecipe)({ className }), ...rest, children: [(0, jsx_runtime_1.jsxs)("div", { ref: listRef, className: "tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && (0, jsx_runtime_1.jsx)("span", { className: "tabs-leading", children: leading }), tabs.map((t) => {
97
81
  const selected = t.value === active;
98
- return ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "tab", id: `tab-${t.value}`, "aria-selected": selected, "aria-controls": `panel-${t.value}`, tabIndex: selected ? 0 : -1, "data-value": t.value, "data-selected": selected ? "" : undefined, disabled: t.disabled, className: "tabs-tab", onClick: () => select(t.value), children: [t.icon, (0, jsx_runtime_1.jsx)("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && ((0, jsx_runtime_1.jsx)("span", { className: "tabs-tab-badge", children: t.badge }))] }, t.value));
82
+ return ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "tab", id: `tab-${t.value}`, "aria-selected": selected, "aria-controls": `panel-${t.value}`, tabIndex: tabIndexFor(t.value), "data-value": t.value, "data-selected": selected ? "" : undefined, disabled: t.disabled, className: "tabs-tab", onClick: () => select(t.value), children: [t.icon, (0, jsx_runtime_1.jsx)("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && ((0, jsx_runtime_1.jsx)("span", { className: "tabs-tab-badge", children: t.badge }))] }, t.value));
99
83
  }), indicator && ((0, jsx_runtime_1.jsx)("span", { className: "tabs-indicator", style: {
100
84
  width: indicator.width,
101
85
  transform: `translateX(${indicator.left}px)`,
@@ -1,8 +1,43 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.Inbox = Inbox;
4
37
  const jsx_runtime_1 = require("react/jsx-runtime");
5
38
  const lucide_react_1 = require("lucide-react");
39
+ const React = __importStar(require("react"));
40
+ const tabs_1 = require("../../components/tabs");
6
41
  const inbox_recipe_1 = require("./inbox.recipe");
7
42
  /**
8
43
  * A notifications inbox panel — measured from Attio's notification flyout. A
@@ -10,7 +45,17 @@ const inbox_recipe_1 = require("./inbox.recipe");
10
45
  * (avatar + message + time + unread dot) or a centred empty state. Put it inside
11
46
  * a Popover for the flyout.
12
47
  */
13
- function Inbox({ notifications = [], title = "Notifications", tabs, activeTab, onTabChange, emptyTitle = "No notifications", emptySubtitle = "Notifications such as mentions and important updates will be displayed here.", emptyAction, className, }) {
14
- return ((0, jsx_runtime_1.jsxs)("div", { className: (0, inbox_recipe_1.inboxRecipe)({ className }), children: [(0, jsx_runtime_1.jsxs)("div", { className: "inbox-head", children: [(0, jsx_runtime_1.jsx)("div", { className: "inbox-title", children: title }), tabs && tabs.length > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "inbox-tabs", role: "tablist", children: tabs.map((t) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "tab", "aria-selected": t.id === activeTab, className: "inbox-tab", "data-active": t.id === activeTab ? "" : undefined, onClick: () => onTabChange?.(t.id), children: [t.label, t.count != null && ((0, jsx_runtime_1.jsxs)("span", { className: "inbox-tab-count", children: ["(", t.count, ")"] }))] }, t.id))) }))] }), notifications.length === 0 ? ((0, jsx_runtime_1.jsxs)("div", { className: "inbox-empty", children: [(0, jsx_runtime_1.jsx)("span", { className: "inbox-empty-icon", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Bell, { size: 40, strokeWidth: 1.25 }) }), (0, jsx_runtime_1.jsx)("div", { className: "inbox-empty-title", children: emptyTitle }), emptySubtitle != null && ((0, jsx_runtime_1.jsx)("div", { className: "inbox-empty-sub", children: emptySubtitle })), emptyAction != null && ((0, jsx_runtime_1.jsx)("div", { className: "inbox-empty-action", children: emptyAction }))] })) : ((0, jsx_runtime_1.jsx)("div", { className: "inbox-list", children: notifications.map((n) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "inbox-row", onClick: n.onClick, children: [n.avatar != null && ((0, jsx_runtime_1.jsx)("span", { className: "inbox-row-avatar", children: n.avatar })), (0, jsx_runtime_1.jsxs)("span", { className: "inbox-row-body", children: [(0, jsx_runtime_1.jsx)("span", { className: "inbox-row-msg", children: n.message }), n.time != null && ((0, jsx_runtime_1.jsx)("span", { className: "inbox-row-time", children: n.time }))] }), n.unread && (0, jsx_runtime_1.jsx)("span", { className: "inbox-row-unread" })] }, n.id))) }))] }));
48
+ function Inbox({ notifications = [], title = "Notifications", tabs, activeTab, onTabChange, emptyTitle = "No notifications", emptySubtitle = "Notifications such as mentions and important updates will be displayed here.", emptyAction, tabsLabel = "Notification views", className, }) {
49
+ const listRef = React.useRef(null);
50
+ // crisp's shared tablist model, the one `Tabs` runs (#195). Hand-rolling a
51
+ // second one is how Inbox ended up with no arrow keys, no roving tabindex
52
+ // and no name while `Tabs` had all three.
53
+ const { onKeyDown, tabIndexFor } = (0, tabs_1.useTablistKeyboard)({
54
+ tabs: (tabs ?? []).map((t) => ({ value: t.id, disabled: t.disabled })),
55
+ active: activeTab ?? "",
56
+ select: (id) => onTabChange?.(id),
57
+ listRef,
58
+ });
59
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, inbox_recipe_1.inboxRecipe)({ className }), children: [(0, jsx_runtime_1.jsxs)("div", { className: "inbox-head", children: [(0, jsx_runtime_1.jsx)("div", { className: "inbox-title", children: title }), tabs && tabs.length > 0 && ((0, jsx_runtime_1.jsx)("div", { ref: listRef, className: "inbox-tabs", role: "tablist", "aria-label": tabsLabel, onKeyDown: onKeyDown, children: tabs.map((t) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "tab", "aria-selected": t.id === activeTab, tabIndex: tabIndexFor(t.id), "data-value": t.id, disabled: t.disabled, className: "inbox-tab", "data-active": t.id === activeTab ? "" : undefined, onClick: () => onTabChange?.(t.id), children: [t.label, t.count != null && ((0, jsx_runtime_1.jsxs)("span", { className: "inbox-tab-count", children: ["(", t.count, ")"] }))] }, t.id))) }))] }), notifications.length === 0 ? ((0, jsx_runtime_1.jsxs)("div", { className: "inbox-empty", children: [(0, jsx_runtime_1.jsx)("span", { className: "inbox-empty-icon", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Bell, { size: 40, strokeWidth: 1.25 }) }), (0, jsx_runtime_1.jsx)("div", { className: "inbox-empty-title", children: emptyTitle }), emptySubtitle != null && ((0, jsx_runtime_1.jsx)("div", { className: "inbox-empty-sub", children: emptySubtitle })), emptyAction != null && ((0, jsx_runtime_1.jsx)("div", { className: "inbox-empty-action", children: emptyAction }))] })) : ((0, jsx_runtime_1.jsx)("div", { className: "inbox-list", children: notifications.map((n) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "inbox-row", onClick: n.onClick, children: [n.avatar != null && ((0, jsx_runtime_1.jsx)("span", { className: "inbox-row-avatar", children: n.avatar })), (0, jsx_runtime_1.jsxs)("span", { className: "inbox-row-body", children: [(0, jsx_runtime_1.jsx)("span", { className: "inbox-row-msg", children: n.message }), n.time != null && ((0, jsx_runtime_1.jsx)("span", { className: "inbox-row-time", children: n.time }))] }), n.unread && (0, jsx_runtime_1.jsx)("span", { className: "inbox-row-unread" })] }, n.id))) }))] }));
15
60
  }
16
61
  Inbox.displayName = "Inbox";
@@ -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 { useMenuKeyboard } from "../menu/index.js";
3
4
  import { contextmenuRecipe } from "./contextmenu.recipe.js";
4
5
  /**
5
6
  * A right-click context menu — the popover surface positioned at the cursor.
@@ -27,16 +28,19 @@ export function ContextMenu({ children, items, menuLabel = "Context menu", class
27
28
  returnFocusTo.current?.focus?.();
28
29
  returnFocusTo.current = null;
29
30
  }, []);
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
31
  // `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]);
32
+ // keys are the only navigation there is (#188). The model itself is crisp's
33
+ // shared one (#192) — Menu and all three RecordsTable surfaces run the same
34
+ // hook, and a fourth hand-written copy is how typeahead, menuitemcheckbox
35
+ // and the `[data-disabled]` spelling get lost one surface at a time.
36
+ const { onKeyDown: onMenuKeyDown } = useMenuKeyboard({
37
+ open: !!pos,
38
+ close: () => close(true),
39
+ // Tab: the browser is already moving focus somewhere, so pulling it back
40
+ // to the trigger first would land the user in the wrong place
41
+ dismiss: () => close(),
42
+ contentRef: menuRef,
43
+ });
40
44
  React.useEffect(() => {
41
45
  if (!pos)
42
46
  return;
@@ -52,29 +56,6 @@ export function ContextMenu({ children, items, menuLabel = "Context menu", class
52
56
  document.removeEventListener("keydown", onKey);
53
57
  };
54
58
  }, [pos, close]);
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
59
  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
79
60
  ? "contextmenu-item contextmenu-item--danger"
80
61
  : "contextmenu-item", tabIndex: -1, onClick: () => {
@@ -1,2 +1,3 @@
1
1
  export { Tabs } from "./tabs.js";
2
2
  export { tabsRecipe } from "./tabs.recipe.js";
3
+ export { useTablistKeyboard, } from "./tabs-keyboard.js";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The WAI-ARIA tablist keyboard model: ←/→ to move (wrapping), Home/End to
3
+ * jump, disabled tabs skipped, focus following selection.
4
+ *
5
+ * Extracted from `Tabs` so a surface that renders its own measured tab chrome —
6
+ * Inbox's flyout tabs, and whatever comes next — gets the identical model
7
+ * rather than a second partial one. The same reason `useMenuKeyboard` exists.
8
+ *
9
+ * Automatic activation (arrow selects, rather than arrow-then-Enter) is APG's
10
+ * recommendation when switching tabs is cheap, and it is what `Tabs` already
11
+ * did before this was extracted, so it stays the behaviour.
12
+ */
13
+ export function useTablistKeyboard({ tabs, active, select, listRef, }) {
14
+ const onKeyDown = (e) => {
15
+ const enabled = tabs.filter((t) => !t.disabled);
16
+ if (!enabled.length)
17
+ return;
18
+ const idx = enabled.findIndex((t) => t.value === active);
19
+ let next = -1;
20
+ if (e.key === "ArrowRight")
21
+ next = (idx + 1) % enabled.length;
22
+ else if (e.key === "ArrowLeft")
23
+ next = (idx - 1 + enabled.length) % enabled.length;
24
+ else if (e.key === "Home")
25
+ next = 0;
26
+ else if (e.key === "End")
27
+ next = enabled.length - 1;
28
+ if (next < 0)
29
+ return;
30
+ e.preventDefault();
31
+ const nextValue = enabled[next]?.value;
32
+ if (!nextValue)
33
+ return;
34
+ select(nextValue);
35
+ listRef.current
36
+ ?.querySelector(`[data-value="${CSS.escape(nextValue)}"]`)
37
+ ?.focus();
38
+ };
39
+ /**
40
+ * A tablist is a SINGLE tab stop: Tab enters the group and leaves it, and the
41
+ * arrows move within. Without this a keyboard user walks through every tab
42
+ * one at a time, and the number of stops grows with the tab set.
43
+ */
44
+ const tabIndexFor = (value) => (value === active ? 0 : -1);
45
+ return { onKeyDown, tabIndexFor };
46
+ }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { tabsRecipe } from "./tabs.recipe.js";
4
+ import { useTablistKeyboard } from "./tabs-keyboard.js";
4
5
  /**
5
6
  * A row of text tabs over a sliding ink underline — the record-view tab bar,
6
7
  * measured from Attio. Roving-tabindex keyboard model (←/→ move + activate,
@@ -34,32 +35,15 @@ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, childr
34
35
  ro.observe(list);
35
36
  return () => ro.disconnect();
36
37
  }, [active, tabs]);
37
- const onKeyDown = (e) => {
38
- const enabled = tabs.filter((t) => !t.disabled);
39
- const idx = enabled.findIndex((t) => t.value === active);
40
- let next = -1;
41
- if (e.key === "ArrowRight")
42
- next = (idx + 1) % enabled.length;
43
- else if (e.key === "ArrowLeft")
44
- next = (idx - 1 + enabled.length) % enabled.length;
45
- else if (e.key === "Home")
46
- next = 0;
47
- else if (e.key === "End")
48
- next = enabled.length - 1;
49
- if (next < 0)
50
- return;
51
- e.preventDefault();
52
- const nextValue = enabled[next]?.value;
53
- if (!nextValue)
54
- return;
55
- select(nextValue);
56
- listRef.current
57
- ?.querySelector(`[data-value="${CSS.escape(nextValue)}"]`)
58
- ?.focus();
59
- };
38
+ const { onKeyDown, tabIndexFor } = useTablistKeyboard({
39
+ tabs,
40
+ active,
41
+ select,
42
+ listRef,
43
+ });
60
44
  return (_jsxs("div", { className: tabsRecipe({ className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "tabs-leading", children: leading }), tabs.map((t) => {
61
45
  const selected = t.value === active;
62
- return (_jsxs("button", { type: "button", role: "tab", id: `tab-${t.value}`, "aria-selected": selected, "aria-controls": `panel-${t.value}`, tabIndex: selected ? 0 : -1, "data-value": t.value, "data-selected": selected ? "" : undefined, disabled: t.disabled, className: "tabs-tab", onClick: () => select(t.value), children: [t.icon, _jsx("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && (_jsx("span", { className: "tabs-tab-badge", children: t.badge }))] }, t.value));
46
+ return (_jsxs("button", { type: "button", role: "tab", id: `tab-${t.value}`, "aria-selected": selected, "aria-controls": `panel-${t.value}`, tabIndex: tabIndexFor(t.value), "data-value": t.value, "data-selected": selected ? "" : undefined, disabled: t.disabled, className: "tabs-tab", onClick: () => select(t.value), children: [t.icon, _jsx("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && (_jsx("span", { className: "tabs-tab-badge", children: t.badge }))] }, t.value));
63
47
  }), indicator && (_jsx("span", { className: "tabs-indicator", style: {
64
48
  width: indicator.width,
65
49
  transform: `translateX(${indicator.left}px)`,
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Bell } from "lucide-react";
3
+ import * as React from "react";
4
+ import { useTablistKeyboard } from "../../components/tabs/index.js";
3
5
  import { inboxRecipe } from "./inbox.recipe.js";
4
6
  /**
5
7
  * A notifications inbox panel — measured from Attio's notification flyout. A
@@ -7,7 +9,17 @@ import { inboxRecipe } from "./inbox.recipe.js";
7
9
  * (avatar + message + time + unread dot) or a centred empty state. Put it inside
8
10
  * a Popover for the flyout.
9
11
  */
10
- export function Inbox({ notifications = [], title = "Notifications", tabs, activeTab, onTabChange, emptyTitle = "No notifications", emptySubtitle = "Notifications such as mentions and important updates will be displayed here.", emptyAction, className, }) {
11
- return (_jsxs("div", { className: inboxRecipe({ className }), children: [_jsxs("div", { className: "inbox-head", children: [_jsx("div", { className: "inbox-title", children: title }), tabs && tabs.length > 0 && (_jsx("div", { className: "inbox-tabs", role: "tablist", children: tabs.map((t) => (_jsxs("button", { type: "button", role: "tab", "aria-selected": t.id === activeTab, className: "inbox-tab", "data-active": t.id === activeTab ? "" : undefined, onClick: () => onTabChange?.(t.id), children: [t.label, t.count != null && (_jsxs("span", { className: "inbox-tab-count", children: ["(", t.count, ")"] }))] }, t.id))) }))] }), notifications.length === 0 ? (_jsxs("div", { className: "inbox-empty", children: [_jsx("span", { className: "inbox-empty-icon", children: _jsx(Bell, { size: 40, strokeWidth: 1.25 }) }), _jsx("div", { className: "inbox-empty-title", children: emptyTitle }), emptySubtitle != null && (_jsx("div", { className: "inbox-empty-sub", children: emptySubtitle })), emptyAction != null && (_jsx("div", { className: "inbox-empty-action", children: emptyAction }))] })) : (_jsx("div", { className: "inbox-list", children: notifications.map((n) => (_jsxs("button", { type: "button", className: "inbox-row", onClick: n.onClick, children: [n.avatar != null && (_jsx("span", { className: "inbox-row-avatar", children: n.avatar })), _jsxs("span", { className: "inbox-row-body", children: [_jsx("span", { className: "inbox-row-msg", children: n.message }), n.time != null && (_jsx("span", { className: "inbox-row-time", children: n.time }))] }), n.unread && _jsx("span", { className: "inbox-row-unread" })] }, n.id))) }))] }));
12
+ export function Inbox({ notifications = [], title = "Notifications", tabs, activeTab, onTabChange, emptyTitle = "No notifications", emptySubtitle = "Notifications such as mentions and important updates will be displayed here.", emptyAction, tabsLabel = "Notification views", className, }) {
13
+ const listRef = React.useRef(null);
14
+ // crisp's shared tablist model, the one `Tabs` runs (#195). Hand-rolling a
15
+ // second one is how Inbox ended up with no arrow keys, no roving tabindex
16
+ // and no name while `Tabs` had all three.
17
+ const { onKeyDown, tabIndexFor } = useTablistKeyboard({
18
+ tabs: (tabs ?? []).map((t) => ({ value: t.id, disabled: t.disabled })),
19
+ active: activeTab ?? "",
20
+ select: (id) => onTabChange?.(id),
21
+ listRef,
22
+ });
23
+ return (_jsxs("div", { className: inboxRecipe({ className }), children: [_jsxs("div", { className: "inbox-head", children: [_jsx("div", { className: "inbox-title", children: title }), tabs && tabs.length > 0 && (_jsx("div", { ref: listRef, className: "inbox-tabs", role: "tablist", "aria-label": tabsLabel, onKeyDown: onKeyDown, children: tabs.map((t) => (_jsxs("button", { type: "button", role: "tab", "aria-selected": t.id === activeTab, tabIndex: tabIndexFor(t.id), "data-value": t.id, disabled: t.disabled, className: "inbox-tab", "data-active": t.id === activeTab ? "" : undefined, onClick: () => onTabChange?.(t.id), children: [t.label, t.count != null && (_jsxs("span", { className: "inbox-tab-count", children: ["(", t.count, ")"] }))] }, t.id))) }))] }), notifications.length === 0 ? (_jsxs("div", { className: "inbox-empty", children: [_jsx("span", { className: "inbox-empty-icon", children: _jsx(Bell, { size: 40, strokeWidth: 1.25 }) }), _jsx("div", { className: "inbox-empty-title", children: emptyTitle }), emptySubtitle != null && (_jsx("div", { className: "inbox-empty-sub", children: emptySubtitle })), emptyAction != null && (_jsx("div", { className: "inbox-empty-action", children: emptyAction }))] })) : (_jsx("div", { className: "inbox-list", children: notifications.map((n) => (_jsxs("button", { type: "button", className: "inbox-row", onClick: n.onClick, children: [n.avatar != null && (_jsx("span", { className: "inbox-row-avatar", children: n.avatar })), _jsxs("span", { className: "inbox-row-body", children: [_jsx("span", { className: "inbox-row-msg", children: n.message }), n.time != null && (_jsx("span", { className: "inbox-row-time", children: n.time }))] }), n.unread && _jsx("span", { className: "inbox-row-unread" })] }, n.id))) }))] }));
12
24
  }
13
25
  Inbox.displayName = "Inbox";
@@ -1,2 +1,3 @@
1
1
  export { type TabItem, Tabs, type TabsProps } from "./tabs";
2
2
  export { tabsRecipe } from "./tabs.recipe";
3
+ export { type TablistEntry, type UseTablistKeyboardOptions, useTablistKeyboard, } from "./tabs-keyboard";
@@ -0,0 +1,35 @@
1
+ import type * as React from "react";
2
+ /**
3
+ * One entry in a tablist, as far as the keyboard model cares. Callers pass
4
+ * whatever richer shape they render — only these two fields are read.
5
+ */
6
+ export type TablistEntry = {
7
+ value: string;
8
+ disabled?: boolean;
9
+ };
10
+ export type UseTablistKeyboardOptions<T extends TablistEntry> = {
11
+ /** Every tab, in rendered order, including the disabled ones. */
12
+ tabs: T[];
13
+ /** The selected tab's value. */
14
+ active: string;
15
+ /** Select a tab. Arrow keys activate immediately, as APG's automatic mode. */
16
+ select: (value: string) => void;
17
+ /** The element carrying `role="tablist"`; used to move focus. */
18
+ listRef: React.RefObject<HTMLElement | null>;
19
+ };
20
+ /**
21
+ * The WAI-ARIA tablist keyboard model: ←/→ to move (wrapping), Home/End to
22
+ * jump, disabled tabs skipped, focus following selection.
23
+ *
24
+ * Extracted from `Tabs` so a surface that renders its own measured tab chrome —
25
+ * Inbox's flyout tabs, and whatever comes next — gets the identical model
26
+ * rather than a second partial one. The same reason `useMenuKeyboard` exists.
27
+ *
28
+ * Automatic activation (arrow selects, rather than arrow-then-Enter) is APG's
29
+ * recommendation when switching tabs is cheap, and it is what `Tabs` already
30
+ * did before this was extracted, so it stays the behaviour.
31
+ */
32
+ export declare function useTablistKeyboard<T extends TablistEntry>({ tabs, active, select, listRef, }: UseTablistKeyboardOptions<T>): {
33
+ onKeyDown: (e: React.KeyboardEvent) => void;
34
+ tabIndexFor: (value: string) => -1 | 0;
35
+ };
@@ -1,4 +1,4 @@
1
- import type * as React from "react";
1
+ import * as React from "react";
2
2
  export type InboxNotification = {
3
3
  id: string;
4
4
  /** Leading avatar or icon. */
@@ -14,6 +14,7 @@ export type InboxTab = {
14
14
  id: string;
15
15
  label: React.ReactNode;
16
16
  count?: number;
17
+ disabled?: boolean;
17
18
  };
18
19
  export type InboxProps = {
19
20
  notifications?: InboxNotification[];
@@ -27,6 +28,8 @@ export type InboxProps = {
27
28
  emptySubtitle?: React.ReactNode;
28
29
  /** Optional action under the empty state (e.g. a Settings button). */
29
30
  emptyAction?: React.ReactNode;
31
+ /** Accessible name for the tab list. */
32
+ tabsLabel?: string;
30
33
  className?: string;
31
34
  };
32
35
  /**
@@ -35,7 +38,7 @@ export type InboxProps = {
35
38
  * (avatar + message + time + unread dot) or a centred empty state. Put it inside
36
39
  * a Popover for the flyout.
37
40
  */
38
- export declare function Inbox({ notifications, title, tabs, activeTab, onTabChange, emptyTitle, emptySubtitle, emptyAction, className, }: InboxProps): React.JSX.Element;
41
+ export declare function Inbox({ notifications, title, tabs, activeTab, onTabChange, emptyTitle, emptySubtitle, emptyAction, tabsLabel, className, }: InboxProps): React.JSX.Element;
39
42
  export declare namespace Inbox {
40
43
  var displayName: string;
41
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",