@crisp-ui-kit/crisp 0.24.0 → 0.25.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.
@@ -1,6 +1,40 @@
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.useRadioGroupKeyboard = useRadioGroupKeyboard;
37
+ const React = __importStar(require("react"));
4
38
  /**
5
39
  * The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
6
40
  * previous enabled radio, wrapping, and check it on arrival. Home/End jump to
@@ -25,7 +59,39 @@ exports.useRadioGroupKeyboard = useRadioGroupKeyboard;
25
59
  * `Segmented` already offered them, and the shared model should be the union
26
60
  * rather than quietly dropping behaviour one surface had.
27
61
  */
28
- function useRadioGroupKeyboard({ select, }) {
62
+ function useRadioGroupKeyboard({ select, groupRef: externalRef, }) {
63
+ /**
64
+ * The group's one tab stop. `tabIndex={checked ? 0 : -1}` in the markup is
65
+ * right for every case but the one that matters most: a group with NOTHING
66
+ * selected gives every radio -1, so Tab skips the whole control and an
67
+ * unanswered question can never be answered from the keyboard (#237). APG
68
+ * says the first radio is tabbable when none is checked, and RadioGroup's
69
+ * own comment said so too — "the selected (or first, when none) radio is
70
+ * tabbable" — while the code did only the first half.
71
+ *
72
+ * Owned here rather than in the markup because the group renders arbitrary
73
+ * children: only the DOM knows which radio comes first, which is the same
74
+ * reason the arrow keys read the radios out of the DOM.
75
+ *
76
+ * It runs after EVERY render, and sets both ends. React will not undo a
77
+ * mutation it did not make — when the selection moves, the newly checked
78
+ * radio changes 0 from React's side while the stale one keeps the 0 written
79
+ * here — so this has to own the whole row, not just add to it.
80
+ */
81
+ const ownRef = React.useRef(null);
82
+ const groupRef = externalRef ?? ownRef;
83
+ React.useLayoutEffect(() => {
84
+ const root = groupRef.current;
85
+ if (!root)
86
+ return;
87
+ const radios = Array.from(root.querySelectorAll('[role="radio"]:not([disabled])'));
88
+ if (!radios.length)
89
+ return;
90
+ const checked = radios.find((r) => r.getAttribute("aria-checked") === "true");
91
+ const stop = checked ?? radios[0];
92
+ for (const r of radios)
93
+ r.tabIndex = r === stop ? 0 : -1;
94
+ });
29
95
  const onKeyDown = (e) => {
30
96
  const KEYS = [
31
97
  "ArrowDown",
@@ -63,5 +129,5 @@ function useRadioGroupKeyboard({ select, }) {
63
129
  if (value !== undefined)
64
130
  select(value);
65
131
  };
66
- return { onKeyDown };
132
+ return { onKeyDown, groupRef };
67
133
  }
@@ -56,8 +56,8 @@ function RadioGroup({ value, defaultValue, onValueChange, variant = "default", n
56
56
  setInternal(v);
57
57
  onValueChange?.(v);
58
58
  }, [value, onValueChange]);
59
- const { onKeyDown } = (0, radiogroup_keyboard_1.useRadioGroupKeyboard)({ select });
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 }) }));
59
+ const { onKeyDown, groupRef } = (0, radiogroup_keyboard_1.useRadioGroupKeyboard)({ select });
60
+ return ((0, jsx_runtime_1.jsx)(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: (0, jsx_runtime_1.jsx)("div", { ref: groupRef, role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: (0, radiogroup_recipe_1.radiogroupRecipe)({ className }), ...rest, onKeyDown: onKeyDown, children: children }) }));
61
61
  }
62
62
  RadioGroup.displayName = "RadioGroup";
63
63
  function Radio({ value, label, children, disabled }) {
@@ -68,7 +68,7 @@ function Segmented({ options, value, defaultValue, onValueChange, "aria-label":
68
68
  // crisp's shared radio group model (#200). The per-button handler this
69
69
  // replaces closed over its own index, so it selected a neighbour without
70
70
  // moving focus and every press after the first recomputed the same step.
71
- const { onKeyDown } = (0, radiogroup_1.useRadioGroupKeyboard)({ select });
71
+ const { onKeyDown } = (0, radiogroup_1.useRadioGroupKeyboard)({ select, groupRef: rootRef });
72
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: {
73
73
  width: thumb.width,
74
74
  transform: `translateX(${thumb.left}px)`,
@@ -44,6 +44,15 @@ function useTablistKeyboard({ tabs, active, select, listRef, }) {
44
44
  * arrows move within. Without this a keyboard user walks through every tab
45
45
  * one at a time, and the number of stops grows with the tab set.
46
46
  */
47
- const tabIndexFor = (value) => (value === active ? 0 : -1);
47
+ const tabIndexFor = (value) => {
48
+ // ...and a group with no tab stop at all is worse than one with too many:
49
+ // a controlled `value` that matches no tab gave every tab -1, so Tab
50
+ // skipped the whole strip (#237). Fall back to the first enabled tab,
51
+ // which is where focus would land anyway.
52
+ const stop = tabs.some((t) => t.value === active && !t.disabled)
53
+ ? active
54
+ : tabs.find((t) => !t.disabled)?.value;
55
+ return value === stop ? 0 : -1;
56
+ };
48
57
  return { onKeyDown, tabIndexFor };
49
58
  }
@@ -1,3 +1,4 @@
1
+ import * as React from "react";
1
2
  /**
2
3
  * The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
3
4
  * previous enabled radio, wrapping, and check it on arrival. Home/End jump to
@@ -22,7 +23,39 @@
22
23
  * `Segmented` already offered them, and the shared model should be the union
23
24
  * rather than quietly dropping behaviour one surface had.
24
25
  */
25
- export function useRadioGroupKeyboard({ select, }) {
26
+ export function useRadioGroupKeyboard({ select, groupRef: externalRef, }) {
27
+ /**
28
+ * The group's one tab stop. `tabIndex={checked ? 0 : -1}` in the markup is
29
+ * right for every case but the one that matters most: a group with NOTHING
30
+ * selected gives every radio -1, so Tab skips the whole control and an
31
+ * unanswered question can never be answered from the keyboard (#237). APG
32
+ * says the first radio is tabbable when none is checked, and RadioGroup's
33
+ * own comment said so too — "the selected (or first, when none) radio is
34
+ * tabbable" — while the code did only the first half.
35
+ *
36
+ * Owned here rather than in the markup because the group renders arbitrary
37
+ * children: only the DOM knows which radio comes first, which is the same
38
+ * reason the arrow keys read the radios out of the DOM.
39
+ *
40
+ * It runs after EVERY render, and sets both ends. React will not undo a
41
+ * mutation it did not make — when the selection moves, the newly checked
42
+ * radio changes 0 from React's side while the stale one keeps the 0 written
43
+ * here — so this has to own the whole row, not just add to it.
44
+ */
45
+ const ownRef = React.useRef(null);
46
+ const groupRef = externalRef ?? ownRef;
47
+ React.useLayoutEffect(() => {
48
+ const root = groupRef.current;
49
+ if (!root)
50
+ return;
51
+ const radios = Array.from(root.querySelectorAll('[role="radio"]:not([disabled])'));
52
+ if (!radios.length)
53
+ return;
54
+ const checked = radios.find((r) => r.getAttribute("aria-checked") === "true");
55
+ const stop = checked ?? radios[0];
56
+ for (const r of radios)
57
+ r.tabIndex = r === stop ? 0 : -1;
58
+ });
26
59
  const onKeyDown = (e) => {
27
60
  const KEYS = [
28
61
  "ArrowDown",
@@ -60,5 +93,5 @@ export function useRadioGroupKeyboard({ select, }) {
60
93
  if (value !== undefined)
61
94
  select(value);
62
95
  };
63
- return { onKeyDown };
96
+ return { onKeyDown, groupRef };
64
97
  }
@@ -19,8 +19,8 @@ export function RadioGroup({ value, defaultValue, onValueChange, variant = "defa
19
19
  setInternal(v);
20
20
  onValueChange?.(v);
21
21
  }, [value, onValueChange]);
22
- const { onKeyDown } = useRadioGroupKeyboard({ select });
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 }) }));
22
+ const { onKeyDown, groupRef } = useRadioGroupKeyboard({ select });
23
+ return (_jsx(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: _jsx("div", { ref: groupRef, role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: radiogroupRecipe({ className }), ...rest, onKeyDown: onKeyDown, children: children }) }));
24
24
  }
25
25
  RadioGroup.displayName = "RadioGroup";
26
26
  export function Radio({ value, label, children, disabled }) {
@@ -32,7 +32,7 @@ export function Segmented({ options, value, defaultValue, onValueChange, "aria-l
32
32
  // crisp's shared radio group model (#200). The per-button handler this
33
33
  // replaces closed over its own index, so it selected a neighbour without
34
34
  // moving focus and every press after the first recomputed the same step.
35
- const { onKeyDown } = useRadioGroupKeyboard({ select });
35
+ const { onKeyDown } = useRadioGroupKeyboard({ select, groupRef: rootRef });
36
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: {
37
37
  width: thumb.width,
38
38
  transform: `translateX(${thumb.left}px)`,
@@ -41,6 +41,15 @@ export function useTablistKeyboard({ tabs, active, select, listRef, }) {
41
41
  * arrows move within. Without this a keyboard user walks through every tab
42
42
  * one at a time, and the number of stops grows with the tab set.
43
43
  */
44
- const tabIndexFor = (value) => (value === active ? 0 : -1);
44
+ const tabIndexFor = (value) => {
45
+ // ...and a group with no tab stop at all is worse than one with too many:
46
+ // a controlled `value` that matches no tab gave every tab -1, so Tab
47
+ // skipped the whole strip (#237). Fall back to the first enabled tab,
48
+ // which is where focus would land anyway.
49
+ const stop = tabs.some((t) => t.value === active && !t.disabled)
50
+ ? active
51
+ : tabs.find((t) => !t.disabled)?.value;
52
+ return value === stop ? 0 : -1;
53
+ };
45
54
  return { onKeyDown, tabIndexFor };
46
55
  }
@@ -1,7 +1,13 @@
1
- import type * as React from "react";
1
+ import * as React from "react";
2
2
  export type UseRadioGroupKeyboardOptions = {
3
3
  /** Select a radio by its `data-value`. */
4
4
  select: (value: string) => void;
5
+ /**
6
+ * The element carrying `role="radiogroup"`, when the surface already keeps a
7
+ * ref to it (Segmented measures its thumb from one). Omit it and the hook
8
+ * hands one back to attach.
9
+ */
10
+ groupRef?: React.RefObject<HTMLElement | null>;
5
11
  };
6
12
  /**
7
13
  * The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
@@ -27,6 +33,7 @@ export type UseRadioGroupKeyboardOptions = {
27
33
  * `Segmented` already offered them, and the shared model should be the union
28
34
  * rather than quietly dropping behaviour one surface had.
29
35
  */
30
- export declare function useRadioGroupKeyboard({ select, }: UseRadioGroupKeyboardOptions): {
36
+ export declare function useRadioGroupKeyboard({ select, groupRef: externalRef, }: UseRadioGroupKeyboardOptions): {
31
37
  onKeyDown: (e: React.KeyboardEvent) => void;
38
+ groupRef: React.RefObject<HTMLElement | null>;
32
39
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",