@crisp-ui-kit/crisp 0.16.0 → 0.17.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.
@@ -37,6 +37,7 @@ exports.MultiSelect = MultiSelect;
37
37
  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
+ const select_1 = require("../select");
40
41
  const multiselect_recipe_1 = require("./multiselect.recipe");
41
42
  const TONES = [
42
43
  "brand",
@@ -122,26 +123,24 @@ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, p
122
123
  setInternalValue(next);
123
124
  onValueChange?.(next);
124
125
  };
125
- const onNavKeys = (e) => {
126
- if (e.key === "ArrowDown") {
127
- e.preventDefault();
128
- setHighlight((h) => Math.min(h + 1, filtered.length - 1));
129
- }
130
- else if (e.key === "ArrowUp") {
131
- e.preventDefault();
132
- setHighlight((h) => Math.max(h - 1, 0));
133
- }
134
- else if (e.key === "Enter") {
135
- e.preventDefault();
126
+ // crisp's shared popup-listbox model (#198). Hand-rolling a second one is
127
+ // how MultiSelect ended up with no Home/End, arrows that dead-end instead of
128
+ // wrapping, and a popup left open behind a Tab — while Select, the same
129
+ // widget with single selection, had all three.
130
+ const { onKeyDown: onNavKeys } = (0, select_1.useListboxKeyboard)({
131
+ // every rendered option is selectable: MultiSelect has no disabled state
132
+ enabledIndexes: filtered.map((_, i) => i),
133
+ highlight,
134
+ setHighlight,
135
+ commit: () => {
136
136
  const opt = filtered[highlight];
137
137
  if (opt)
138
138
  toggle(opt.value);
139
- }
140
- else if (e.key === "Escape") {
141
- e.preventDefault();
142
- close();
143
- }
144
- };
139
+ },
140
+ close,
141
+ // Tab is already moving focus out; `close` would drag it back to the trigger
142
+ dismiss: () => setOpen(false),
143
+ });
145
144
  React.useEffect(() => {
146
145
  if (!open)
147
146
  return;
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.radiogroupRecipe = exports.RadioGroup = exports.Radio = void 0;
3
+ exports.useRadioGroupKeyboard = exports.radiogroupRecipe = exports.RadioGroup = exports.Radio = void 0;
4
4
  var radiogroup_1 = require("./radiogroup");
5
5
  Object.defineProperty(exports, "Radio", { enumerable: true, get: function () { return radiogroup_1.Radio; } });
6
6
  Object.defineProperty(exports, "RadioGroup", { enumerable: true, get: function () { return radiogroup_1.RadioGroup; } });
7
7
  var radiogroup_recipe_1 = require("./radiogroup.recipe");
8
8
  Object.defineProperty(exports, "radiogroupRecipe", { enumerable: true, get: function () { return radiogroup_recipe_1.radiogroupRecipe; } });
9
+ var radiogroup_keyboard_1 = require("./radiogroup-keyboard");
10
+ Object.defineProperty(exports, "useRadioGroupKeyboard", { enumerable: true, get: function () { return radiogroup_keyboard_1.useRadioGroupKeyboard; } });
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useRadioGroupKeyboard = useRadioGroupKeyboard;
4
+ /**
5
+ * The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
6
+ * previous enabled radio, wrapping, and check it on arrival. Home/End jump to
7
+ * the ends.
8
+ *
9
+ * Mount the returned handler on the element carrying `role="radiogroup"`. It
10
+ * reads the radios out of the DOM rather than an options array, so a surface
11
+ * that renders its own chrome — `Segmented`'s pill switcher — shares the model
12
+ * without sharing a data shape.
13
+ *
14
+ * Extracted from `RadioGroup`, whose version was the working one. `Segmented`
15
+ * mounted its handler per button and closed over that button's index, so it
16
+ * selected a neighbour without moving focus and every later press recomputed
17
+ * the same step (#200). The same reason `useMenuKeyboard`,
18
+ * `useTablistKeyboard` and `useListboxKeyboard` exist.
19
+ *
20
+ * Moving focus is not a detail: roving `tabIndex` follows the selection, so a
21
+ * handler that selects without focusing leaves focus on a `tabindex="-1"`
22
+ * element while the tab stop is somewhere else.
23
+ *
24
+ * Home/End are not in APG's radio group pattern. They are kept because
25
+ * `Segmented` already offered them, and the shared model should be the union
26
+ * rather than quietly dropping behaviour one surface had.
27
+ */
28
+ function useRadioGroupKeyboard({ select, }) {
29
+ const onKeyDown = (e) => {
30
+ const KEYS = [
31
+ "ArrowDown",
32
+ "ArrowRight",
33
+ "ArrowUp",
34
+ "ArrowLeft",
35
+ "Home",
36
+ "End",
37
+ ];
38
+ if (!KEYS.includes(e.key))
39
+ return;
40
+ const radios = Array.from(e.currentTarget.querySelectorAll('[role="radio"]:not([disabled])'));
41
+ if (!radios.length)
42
+ return;
43
+ const idx = radios.indexOf(document.activeElement);
44
+ let next;
45
+ if (e.key === "Home")
46
+ next = radios[0];
47
+ else if (e.key === "End")
48
+ next = radios[radios.length - 1];
49
+ else {
50
+ const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
51
+ // focus outside the group: enter at the near end rather than stepping
52
+ // off an index of -1, which would land two from the back going backwards
53
+ if (idx < 0)
54
+ next = fwd ? radios[0] : radios[radios.length - 1];
55
+ else
56
+ next = radios[(idx + (fwd ? 1 : -1) + radios.length) % radios.length];
57
+ }
58
+ if (!next)
59
+ return;
60
+ e.preventDefault();
61
+ next.focus();
62
+ const value = next.dataset.value;
63
+ if (value !== undefined)
64
+ select(value);
65
+ };
66
+ return { onKeyDown };
67
+ }
@@ -38,6 +38,7 @@ exports.Radio = Radio;
38
38
  const jsx_runtime_1 = require("react/jsx-runtime");
39
39
  const React = __importStar(require("react"));
40
40
  const radiogroup_recipe_1 = require("./radiogroup.recipe");
41
+ const radiogroup_keyboard_1 = require("./radiogroup-keyboard");
41
42
  const RadioContext = React.createContext(null);
42
43
  /**
43
44
  * A radio group — measured from Attio's settings. `default` renders rows (a ring
@@ -55,19 +56,7 @@ function RadioGroup({ value, defaultValue, onValueChange, variant = "default", n
55
56
  setInternal(v);
56
57
  onValueChange?.(v);
57
58
  }, [value, onValueChange]);
58
- const onKeyDown = (e) => {
59
- if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(e.key))
60
- return;
61
- const radios = Array.from(e.currentTarget.querySelectorAll('[role="radio"]:not([disabled])'));
62
- const idx = radios.indexOf(document.activeElement);
63
- const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
64
- const next = radios[(idx + (fwd ? 1 : -1) + radios.length) % radios.length];
65
- if (next) {
66
- e.preventDefault();
67
- next.focus();
68
- next.dataset.value && select(next.dataset.value);
69
- }
70
- };
59
+ const { onKeyDown } = (0, radiogroup_keyboard_1.useRadioGroupKeyboard)({ select });
71
60
  return ((0, jsx_runtime_1.jsx)(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: (0, jsx_runtime_1.jsx)("div", { role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: (0, radiogroup_recipe_1.radiogroupRecipe)({ className }), ...rest, onKeyDown: onKeyDown, children: children }) }));
72
61
  }
73
62
  RadioGroup.displayName = "RadioGroup";
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Segmented = Segmented;
37
37
  const jsx_runtime_1 = require("react/jsx-runtime");
38
38
  const React = __importStar(require("react"));
39
+ const radiogroup_1 = require("../radiogroup");
39
40
  const segmented_recipe_1 = require("./segmented.recipe");
40
41
  /**
41
42
  * A segmented control (single-choice toggle) — measured from Attio's view
@@ -64,32 +65,19 @@ function Segmented({ options, value, defaultValue, onValueChange, "aria-label":
64
65
  setInternal(v);
65
66
  onValueChange?.(v);
66
67
  };
67
- const onKeyDown = (e, i) => {
68
- const enabled = options.filter((o) => !o.disabled);
69
- const pos = enabled.findIndex((o) => o.value === options[i].value);
70
- let next = -1;
71
- if (e.key === "ArrowRight" || e.key === "ArrowDown")
72
- next = (pos + 1) % enabled.length;
73
- else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
74
- next = (pos - 1 + enabled.length) % enabled.length;
75
- else if (e.key === "Home")
76
- next = 0;
77
- else if (e.key === "End")
78
- next = enabled.length - 1;
79
- if (next < 0)
80
- return;
81
- e.preventDefault();
82
- select(enabled[next].value);
83
- };
84
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, segmented_recipe_1.segmentedRecipe)({ className }), ...rest, role: "radiogroup", "aria-label": ariaLabel, children: [thumb && ((0, jsx_runtime_1.jsx)("span", { className: "segmented-thumb", "aria-hidden": "true", style: {
68
+ // crisp's shared radio group model (#200). The per-button handler this
69
+ // replaces closed over its own index, so it selected a neighbour without
70
+ // moving focus and every press after the first recomputed the same step.
71
+ const { onKeyDown } = (0, radiogroup_1.useRadioGroupKeyboard)({ select });
72
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, segmented_recipe_1.segmentedRecipe)({ className }), ...rest, role: "radiogroup", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [thumb && ((0, jsx_runtime_1.jsx)("span", { className: "segmented-thumb", "aria-hidden": "true", style: {
85
73
  width: thumb.width,
86
74
  transform: `translateX(${thumb.left}px)`,
87
- } })), options.map((opt, i) => {
75
+ } })), options.map((opt) => {
88
76
  const on = opt.value === selected;
89
77
  const iconOnly = opt.label == null && opt.icon != null;
90
- return ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "radio", "aria-checked": on, "aria-label": opt["aria-label"], disabled: opt.disabled, "data-selected": on ? "" : undefined, tabIndex: on ? 0 : -1, className: iconOnly
78
+ return ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "radio", "aria-checked": on, "aria-label": opt["aria-label"], disabled: opt.disabled, "data-selected": on ? "" : undefined, "data-value": opt.value, tabIndex: on ? 0 : -1, className: iconOnly
91
79
  ? "segmented-item segmented-item--icon"
92
- : "segmented-item", onClick: () => select(opt.value), onKeyDown: (e) => onKeyDown(e, i), children: [opt.icon, opt.label] }, opt.value));
80
+ : "segmented-item", onClick: () => select(opt.value), children: [opt.icon, opt.label] }, opt.value));
93
81
  })] }));
94
82
  }
95
83
  Segmented.displayName = "Segmented";
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.selectRecipe = exports.Select = void 0;
3
+ exports.selectRecipe = exports.Select = exports.useListboxKeyboard = void 0;
4
+ var listbox_keyboard_1 = require("./listbox-keyboard");
5
+ Object.defineProperty(exports, "useListboxKeyboard", { enumerable: true, get: function () { return listbox_keyboard_1.useListboxKeyboard; } });
4
6
  var select_1 = require("./select");
5
7
  Object.defineProperty(exports, "Select", { enumerable: true, get: function () { return select_1.Select; } });
6
8
  var select_recipe_1 = require("./select.recipe");
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useListboxKeyboard = useListboxKeyboard;
4
+ /**
5
+ * The WAI-ARIA listbox keyboard model for a POPUP listbox: ↑/↓ to move the
6
+ * highlight (wrapping, skipping disabled options), Home/End to jump, Enter to
7
+ * choose, Escape to close, Tab to dismiss.
8
+ *
9
+ * Extracted from `Select`, whose set was the complete one, so `MultiSelect` —
10
+ * the same widget with multiple selection — stops being a second partial copy
11
+ * that had no Home/End and stayed open on Tab (#198). The same reason
12
+ * `useMenuKeyboard` and `useTablistKeyboard` exist.
13
+ *
14
+ * NOT for `WheelPicker`: that is an always-visible multi-column wheel that
15
+ * commits on move, so Enter, Escape and Tab have no meaning in it.
16
+ */
17
+ function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, commit, close, dismiss = close, }) {
18
+ const move = (dir) => {
19
+ if (enabledIndexes.length === 0)
20
+ return;
21
+ const pos = enabledIndexes.indexOf(highlight);
22
+ // nothing highlighted yet: ↓ enters at the top, ↑ at the bottom
23
+ const nextPos = pos < 0
24
+ ? dir > 0
25
+ ? 0
26
+ : enabledIndexes.length - 1
27
+ : (pos + dir + enabledIndexes.length) % enabledIndexes.length;
28
+ setHighlight(enabledIndexes[nextPos] ?? 0);
29
+ };
30
+ const onKeyDown = (e) => {
31
+ switch (e.key) {
32
+ case "ArrowDown":
33
+ e.preventDefault();
34
+ move(1);
35
+ break;
36
+ case "ArrowUp":
37
+ e.preventDefault();
38
+ move(-1);
39
+ break;
40
+ case "Home":
41
+ e.preventDefault();
42
+ // the `?? 0` these used to end in armed option 0 on a list with no
43
+ // options — the arrows guarded against that and Home/End did not
44
+ if (enabledIndexes.length)
45
+ setHighlight(enabledIndexes[0]);
46
+ break;
47
+ case "End":
48
+ e.preventDefault();
49
+ if (enabledIndexes.length)
50
+ setHighlight(enabledIndexes[enabledIndexes.length - 1]);
51
+ break;
52
+ case "Enter":
53
+ e.preventDefault();
54
+ commit();
55
+ break;
56
+ case "Escape":
57
+ e.preventDefault();
58
+ close();
59
+ break;
60
+ case "Tab":
61
+ // not preventDefault: Tab must still move focus, it just must not
62
+ // leave an open popup floating behind it
63
+ dismiss();
64
+ break;
65
+ }
66
+ };
67
+ return { onKeyDown };
68
+ }
@@ -37,6 +37,7 @@ exports.Select = Select;
37
37
  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
+ const listbox_keyboard_1 = require("./listbox-keyboard");
40
41
  const select_recipe_1 = require("./select.recipe");
41
42
  function optionText(o) {
42
43
  if (o.keywords)
@@ -116,48 +117,14 @@ function Select({ options, value, defaultValue, onValueChange, placeholder = "Se
116
117
  setOpen(false);
117
118
  triggerRef.current?.focus({ preventScroll: true });
118
119
  };
119
- const moveHighlight = (dir) => {
120
- if (enabledIndexes.length === 0)
121
- return;
122
- const pos = enabledIndexes.indexOf(highlight);
123
- const nextPos = pos < 0
124
- ? dir > 0
125
- ? 0
126
- : enabledIndexes.length - 1
127
- : (pos + dir + enabledIndexes.length) % enabledIndexes.length;
128
- setHighlight(enabledIndexes[nextPos] ?? 0);
129
- };
130
- const onNavKeys = (e) => {
131
- switch (e.key) {
132
- case "ArrowDown":
133
- e.preventDefault();
134
- moveHighlight(1);
135
- break;
136
- case "ArrowUp":
137
- e.preventDefault();
138
- moveHighlight(-1);
139
- break;
140
- case "Home":
141
- e.preventDefault();
142
- setHighlight(enabledIndexes[0] ?? 0);
143
- break;
144
- case "End":
145
- e.preventDefault();
146
- setHighlight(enabledIndexes[enabledIndexes.length - 1] ?? 0);
147
- break;
148
- case "Enter":
149
- e.preventDefault();
150
- pick(filtered[highlight]);
151
- break;
152
- case "Escape":
153
- e.preventDefault();
154
- close();
155
- break;
156
- case "Tab":
157
- setOpen(false);
158
- break;
159
- }
160
- };
120
+ const { onKeyDown: onNavKeys } = (0, listbox_keyboard_1.useListboxKeyboard)({
121
+ enabledIndexes,
122
+ highlight,
123
+ setHighlight,
124
+ commit: () => pick(filtered[highlight]),
125
+ close,
126
+ dismiss: () => setOpen(false),
127
+ });
161
128
  const onTriggerKeyDown = (e) => {
162
129
  if (!open) {
163
130
  if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, ChevronDown, Search } from "lucide-react";
3
3
  import * as React from "react";
4
+ import { useListboxKeyboard } from "../select/index.js";
4
5
  import { multiselectRecipe } from "./multiselect.recipe.js";
5
6
  const TONES = [
6
7
  "brand",
@@ -86,26 +87,24 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
86
87
  setInternalValue(next);
87
88
  onValueChange?.(next);
88
89
  };
89
- const onNavKeys = (e) => {
90
- if (e.key === "ArrowDown") {
91
- e.preventDefault();
92
- setHighlight((h) => Math.min(h + 1, filtered.length - 1));
93
- }
94
- else if (e.key === "ArrowUp") {
95
- e.preventDefault();
96
- setHighlight((h) => Math.max(h - 1, 0));
97
- }
98
- else if (e.key === "Enter") {
99
- e.preventDefault();
90
+ // crisp's shared popup-listbox model (#198). Hand-rolling a second one is
91
+ // how MultiSelect ended up with no Home/End, arrows that dead-end instead of
92
+ // wrapping, and a popup left open behind a Tab — while Select, the same
93
+ // widget with single selection, had all three.
94
+ const { onKeyDown: onNavKeys } = useListboxKeyboard({
95
+ // every rendered option is selectable: MultiSelect has no disabled state
96
+ enabledIndexes: filtered.map((_, i) => i),
97
+ highlight,
98
+ setHighlight,
99
+ commit: () => {
100
100
  const opt = filtered[highlight];
101
101
  if (opt)
102
102
  toggle(opt.value);
103
- }
104
- else if (e.key === "Escape") {
105
- e.preventDefault();
106
- close();
107
- }
108
- };
103
+ },
104
+ close,
105
+ // Tab is already moving focus out; `close` would drag it back to the trigger
106
+ dismiss: () => setOpen(false),
107
+ });
109
108
  React.useEffect(() => {
110
109
  if (!open)
111
110
  return;
@@ -1,2 +1,3 @@
1
1
  export { Radio, RadioGroup, } from "./radiogroup.js";
2
2
  export { radiogroupRecipe } from "./radiogroup.recipe.js";
3
+ export { useRadioGroupKeyboard, } from "./radiogroup-keyboard.js";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
3
+ * previous enabled radio, wrapping, and check it on arrival. Home/End jump to
4
+ * the ends.
5
+ *
6
+ * Mount the returned handler on the element carrying `role="radiogroup"`. It
7
+ * reads the radios out of the DOM rather than an options array, so a surface
8
+ * that renders its own chrome — `Segmented`'s pill switcher — shares the model
9
+ * without sharing a data shape.
10
+ *
11
+ * Extracted from `RadioGroup`, whose version was the working one. `Segmented`
12
+ * mounted its handler per button and closed over that button's index, so it
13
+ * selected a neighbour without moving focus and every later press recomputed
14
+ * the same step (#200). The same reason `useMenuKeyboard`,
15
+ * `useTablistKeyboard` and `useListboxKeyboard` exist.
16
+ *
17
+ * Moving focus is not a detail: roving `tabIndex` follows the selection, so a
18
+ * handler that selects without focusing leaves focus on a `tabindex="-1"`
19
+ * element while the tab stop is somewhere else.
20
+ *
21
+ * Home/End are not in APG's radio group pattern. They are kept because
22
+ * `Segmented` already offered them, and the shared model should be the union
23
+ * rather than quietly dropping behaviour one surface had.
24
+ */
25
+ export function useRadioGroupKeyboard({ select, }) {
26
+ const onKeyDown = (e) => {
27
+ const KEYS = [
28
+ "ArrowDown",
29
+ "ArrowRight",
30
+ "ArrowUp",
31
+ "ArrowLeft",
32
+ "Home",
33
+ "End",
34
+ ];
35
+ if (!KEYS.includes(e.key))
36
+ return;
37
+ const radios = Array.from(e.currentTarget.querySelectorAll('[role="radio"]:not([disabled])'));
38
+ if (!radios.length)
39
+ return;
40
+ const idx = radios.indexOf(document.activeElement);
41
+ let next;
42
+ if (e.key === "Home")
43
+ next = radios[0];
44
+ else if (e.key === "End")
45
+ next = radios[radios.length - 1];
46
+ else {
47
+ const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
48
+ // focus outside the group: enter at the near end rather than stepping
49
+ // off an index of -1, which would land two from the back going backwards
50
+ if (idx < 0)
51
+ next = fwd ? radios[0] : radios[radios.length - 1];
52
+ else
53
+ next = radios[(idx + (fwd ? 1 : -1) + radios.length) % radios.length];
54
+ }
55
+ if (!next)
56
+ return;
57
+ e.preventDefault();
58
+ next.focus();
59
+ const value = next.dataset.value;
60
+ if (value !== undefined)
61
+ select(value);
62
+ };
63
+ return { onKeyDown };
64
+ }
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { radiogroupRecipe } from "./radiogroup.recipe.js";
4
+ import { useRadioGroupKeyboard } from "./radiogroup-keyboard.js";
4
5
  const RadioContext = React.createContext(null);
5
6
  /**
6
7
  * A radio group — measured from Attio's settings. `default` renders rows (a ring
@@ -18,19 +19,7 @@ export function RadioGroup({ value, defaultValue, onValueChange, variant = "defa
18
19
  setInternal(v);
19
20
  onValueChange?.(v);
20
21
  }, [value, onValueChange]);
21
- const onKeyDown = (e) => {
22
- if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(e.key))
23
- return;
24
- const radios = Array.from(e.currentTarget.querySelectorAll('[role="radio"]:not([disabled])'));
25
- const idx = radios.indexOf(document.activeElement);
26
- const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
27
- const next = radios[(idx + (fwd ? 1 : -1) + radios.length) % radios.length];
28
- if (next) {
29
- e.preventDefault();
30
- next.focus();
31
- next.dataset.value && select(next.dataset.value);
32
- }
33
- };
22
+ const { onKeyDown } = useRadioGroupKeyboard({ select });
34
23
  return (_jsx(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: _jsx("div", { role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: radiogroupRecipe({ className }), ...rest, onKeyDown: onKeyDown, children: children }) }));
35
24
  }
36
25
  RadioGroup.displayName = "RadioGroup";
@@ -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 { useRadioGroupKeyboard } from "../radiogroup/index.js";
3
4
  import { segmentedRecipe } from "./segmented.recipe.js";
4
5
  /**
5
6
  * A segmented control (single-choice toggle) — measured from Attio's view
@@ -28,32 +29,19 @@ export function Segmented({ options, value, defaultValue, onValueChange, "aria-l
28
29
  setInternal(v);
29
30
  onValueChange?.(v);
30
31
  };
31
- const onKeyDown = (e, i) => {
32
- const enabled = options.filter((o) => !o.disabled);
33
- const pos = enabled.findIndex((o) => o.value === options[i].value);
34
- let next = -1;
35
- if (e.key === "ArrowRight" || e.key === "ArrowDown")
36
- next = (pos + 1) % enabled.length;
37
- else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
38
- next = (pos - 1 + enabled.length) % enabled.length;
39
- else if (e.key === "Home")
40
- next = 0;
41
- else if (e.key === "End")
42
- next = enabled.length - 1;
43
- if (next < 0)
44
- return;
45
- e.preventDefault();
46
- select(enabled[next].value);
47
- };
48
- return (_jsxs("div", { ref: rootRef, className: segmentedRecipe({ className }), ...rest, role: "radiogroup", "aria-label": ariaLabel, children: [thumb && (_jsx("span", { className: "segmented-thumb", "aria-hidden": "true", style: {
32
+ // crisp's shared radio group model (#200). The per-button handler this
33
+ // replaces closed over its own index, so it selected a neighbour without
34
+ // moving focus and every press after the first recomputed the same step.
35
+ const { onKeyDown } = useRadioGroupKeyboard({ select });
36
+ return (_jsxs("div", { ref: rootRef, className: segmentedRecipe({ className }), ...rest, role: "radiogroup", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [thumb && (_jsx("span", { className: "segmented-thumb", "aria-hidden": "true", style: {
49
37
  width: thumb.width,
50
38
  transform: `translateX(${thumb.left}px)`,
51
- } })), options.map((opt, i) => {
39
+ } })), options.map((opt) => {
52
40
  const on = opt.value === selected;
53
41
  const iconOnly = opt.label == null && opt.icon != null;
54
- return (_jsxs("button", { type: "button", role: "radio", "aria-checked": on, "aria-label": opt["aria-label"], disabled: opt.disabled, "data-selected": on ? "" : undefined, tabIndex: on ? 0 : -1, className: iconOnly
42
+ return (_jsxs("button", { type: "button", role: "radio", "aria-checked": on, "aria-label": opt["aria-label"], disabled: opt.disabled, "data-selected": on ? "" : undefined, "data-value": opt.value, tabIndex: on ? 0 : -1, className: iconOnly
55
43
  ? "segmented-item segmented-item--icon"
56
- : "segmented-item", onClick: () => select(opt.value), onKeyDown: (e) => onKeyDown(e, i), children: [opt.icon, opt.label] }, opt.value));
44
+ : "segmented-item", onClick: () => select(opt.value), children: [opt.icon, opt.label] }, opt.value));
57
45
  })] }));
58
46
  }
59
47
  Segmented.displayName = "Segmented";
@@ -1,2 +1,3 @@
1
+ export { useListboxKeyboard, } from "./listbox-keyboard.js";
1
2
  export { Select } from "./select.js";
2
3
  export { selectRecipe } from "./select.recipe.js";
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The WAI-ARIA listbox keyboard model for a POPUP listbox: ↑/↓ to move the
3
+ * highlight (wrapping, skipping disabled options), Home/End to jump, Enter to
4
+ * choose, Escape to close, Tab to dismiss.
5
+ *
6
+ * Extracted from `Select`, whose set was the complete one, so `MultiSelect` —
7
+ * the same widget with multiple selection — stops being a second partial copy
8
+ * that had no Home/End and stayed open on Tab (#198). The same reason
9
+ * `useMenuKeyboard` and `useTablistKeyboard` exist.
10
+ *
11
+ * NOT for `WheelPicker`: that is an always-visible multi-column wheel that
12
+ * commits on move, so Enter, Escape and Tab have no meaning in it.
13
+ */
14
+ export function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, commit, close, dismiss = close, }) {
15
+ const move = (dir) => {
16
+ if (enabledIndexes.length === 0)
17
+ return;
18
+ const pos = enabledIndexes.indexOf(highlight);
19
+ // nothing highlighted yet: ↓ enters at the top, ↑ at the bottom
20
+ const nextPos = pos < 0
21
+ ? dir > 0
22
+ ? 0
23
+ : enabledIndexes.length - 1
24
+ : (pos + dir + enabledIndexes.length) % enabledIndexes.length;
25
+ setHighlight(enabledIndexes[nextPos] ?? 0);
26
+ };
27
+ const onKeyDown = (e) => {
28
+ switch (e.key) {
29
+ case "ArrowDown":
30
+ e.preventDefault();
31
+ move(1);
32
+ break;
33
+ case "ArrowUp":
34
+ e.preventDefault();
35
+ move(-1);
36
+ break;
37
+ case "Home":
38
+ e.preventDefault();
39
+ // the `?? 0` these used to end in armed option 0 on a list with no
40
+ // options — the arrows guarded against that and Home/End did not
41
+ if (enabledIndexes.length)
42
+ setHighlight(enabledIndexes[0]);
43
+ break;
44
+ case "End":
45
+ e.preventDefault();
46
+ if (enabledIndexes.length)
47
+ setHighlight(enabledIndexes[enabledIndexes.length - 1]);
48
+ break;
49
+ case "Enter":
50
+ e.preventDefault();
51
+ commit();
52
+ break;
53
+ case "Escape":
54
+ e.preventDefault();
55
+ close();
56
+ break;
57
+ case "Tab":
58
+ // not preventDefault: Tab must still move focus, it just must not
59
+ // leave an open popup floating behind it
60
+ dismiss();
61
+ break;
62
+ }
63
+ };
64
+ return { onKeyDown };
65
+ }
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, ChevronDown, Search } from "lucide-react";
3
3
  import * as React from "react";
4
+ import { useListboxKeyboard } from "./listbox-keyboard.js";
4
5
  import { selectRecipe } from "./select.recipe.js";
5
6
  function optionText(o) {
6
7
  if (o.keywords)
@@ -80,48 +81,14 @@ export function Select({ options, value, defaultValue, onValueChange, placeholde
80
81
  setOpen(false);
81
82
  triggerRef.current?.focus({ preventScroll: true });
82
83
  };
83
- const moveHighlight = (dir) => {
84
- if (enabledIndexes.length === 0)
85
- return;
86
- const pos = enabledIndexes.indexOf(highlight);
87
- const nextPos = pos < 0
88
- ? dir > 0
89
- ? 0
90
- : enabledIndexes.length - 1
91
- : (pos + dir + enabledIndexes.length) % enabledIndexes.length;
92
- setHighlight(enabledIndexes[nextPos] ?? 0);
93
- };
94
- const onNavKeys = (e) => {
95
- switch (e.key) {
96
- case "ArrowDown":
97
- e.preventDefault();
98
- moveHighlight(1);
99
- break;
100
- case "ArrowUp":
101
- e.preventDefault();
102
- moveHighlight(-1);
103
- break;
104
- case "Home":
105
- e.preventDefault();
106
- setHighlight(enabledIndexes[0] ?? 0);
107
- break;
108
- case "End":
109
- e.preventDefault();
110
- setHighlight(enabledIndexes[enabledIndexes.length - 1] ?? 0);
111
- break;
112
- case "Enter":
113
- e.preventDefault();
114
- pick(filtered[highlight]);
115
- break;
116
- case "Escape":
117
- e.preventDefault();
118
- close();
119
- break;
120
- case "Tab":
121
- setOpen(false);
122
- break;
123
- }
124
- };
84
+ const { onKeyDown: onNavKeys } = useListboxKeyboard({
85
+ enabledIndexes,
86
+ highlight,
87
+ setHighlight,
88
+ commit: () => pick(filtered[highlight]),
89
+ close,
90
+ dismiss: () => setOpen(false),
91
+ });
125
92
  const onTriggerKeyDown = (e) => {
126
93
  if (!open) {
127
94
  if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
@@ -1,2 +1,3 @@
1
1
  export { Radio, RadioGroup, type RadioGroupProps, type RadioProps, } from "./radiogroup";
2
2
  export { radiogroupRecipe } from "./radiogroup.recipe";
3
+ export { type UseRadioGroupKeyboardOptions, useRadioGroupKeyboard, } from "./radiogroup-keyboard";
@@ -0,0 +1,32 @@
1
+ import type * as React from "react";
2
+ export type UseRadioGroupKeyboardOptions = {
3
+ /** Select a radio by its `data-value`. */
4
+ select: (value: string) => void;
5
+ };
6
+ /**
7
+ * The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
8
+ * previous enabled radio, wrapping, and check it on arrival. Home/End jump to
9
+ * the ends.
10
+ *
11
+ * Mount the returned handler on the element carrying `role="radiogroup"`. It
12
+ * reads the radios out of the DOM rather than an options array, so a surface
13
+ * that renders its own chrome — `Segmented`'s pill switcher — shares the model
14
+ * without sharing a data shape.
15
+ *
16
+ * Extracted from `RadioGroup`, whose version was the working one. `Segmented`
17
+ * mounted its handler per button and closed over that button's index, so it
18
+ * selected a neighbour without moving focus and every later press recomputed
19
+ * the same step (#200). The same reason `useMenuKeyboard`,
20
+ * `useTablistKeyboard` and `useListboxKeyboard` exist.
21
+ *
22
+ * Moving focus is not a detail: roving `tabIndex` follows the selection, so a
23
+ * handler that selects without focusing leaves focus on a `tabindex="-1"`
24
+ * element while the tab stop is somewhere else.
25
+ *
26
+ * Home/End are not in APG's radio group pattern. They are kept because
27
+ * `Segmented` already offered them, and the shared model should be the union
28
+ * rather than quietly dropping behaviour one surface had.
29
+ */
30
+ export declare function useRadioGroupKeyboard({ select, }: UseRadioGroupKeyboardOptions): {
31
+ onKeyDown: (e: React.KeyboardEvent) => void;
32
+ };
@@ -1,2 +1,3 @@
1
+ export { type UseListboxKeyboardOptions, useListboxKeyboard, } from "./listbox-keyboard";
1
2
  export { Select, type SelectOption, type SelectProps } from "./select";
2
3
  export { selectRecipe } from "./select.recipe";
@@ -0,0 +1,38 @@
1
+ import type * as React from "react";
2
+ export type UseListboxKeyboardOptions = {
3
+ /**
4
+ * Indexes into the rendered option list that may be highlighted, in order.
5
+ * Disabled options are left out, so the arrows step over them rather than
6
+ * landing on something that cannot be chosen.
7
+ */
8
+ enabledIndexes: number[];
9
+ /** The highlighted index, or -1 when nothing is highlighted yet. */
10
+ highlight: number;
11
+ setHighlight: (index: number) => void;
12
+ /** Choose the highlighted option. Enter. */
13
+ commit: () => void;
14
+ /** Close the popup. Escape. */
15
+ close: () => void;
16
+ /**
17
+ * Close WITHOUT the focus handling `close` does, for Tab — the browser is
18
+ * already moving focus to the next element, so anything that pulls it back
19
+ * lands the user in the wrong place. Defaults to `close`.
20
+ */
21
+ dismiss?: () => void;
22
+ };
23
+ /**
24
+ * The WAI-ARIA listbox keyboard model for a POPUP listbox: ↑/↓ to move the
25
+ * highlight (wrapping, skipping disabled options), Home/End to jump, Enter to
26
+ * choose, Escape to close, Tab to dismiss.
27
+ *
28
+ * Extracted from `Select`, whose set was the complete one, so `MultiSelect` —
29
+ * the same widget with multiple selection — stops being a second partial copy
30
+ * that had no Home/End and stayed open on Tab (#198). The same reason
31
+ * `useMenuKeyboard` and `useTablistKeyboard` exist.
32
+ *
33
+ * NOT for `WheelPicker`: that is an always-visible multi-column wheel that
34
+ * commits on move, so Enter, Escape and Tab have no meaning in it.
35
+ */
36
+ export declare function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, commit, close, dismiss, }: UseListboxKeyboardOptions): {
37
+ onKeyDown: (e: React.KeyboardEvent) => void;
38
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",