@crisp-ui-kit/crisp 0.28.0 → 0.30.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.
@@ -65,7 +65,11 @@ function Chip({ opt }) {
65
65
  * popover with a search field over rows of "[chip] … [checkbox]" that toggle on
66
66
  * click (the menu stays open). Keyboard: ↑/↓ move, Enter toggles, Esc closes.
67
67
  */
68
- function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
68
+ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name,
69
+ // The id belongs on the trigger, not the wrapper: `<label for>` only names a
70
+ // LABELABLE element, so an id left on the root div meant a FormField label
71
+ // above this control named nothing (#247). Select already did this.
72
+ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
69
73
  // Virtual focus: DOM focus stays on the trigger (or the search field) and the
70
74
  // active option is named by `aria-activedescendant`. `data-highlighted` is a
71
75
  // styling hook only -- a screen reader cannot see it.
@@ -162,7 +166,7 @@ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, n
162
166
  return () => document.removeEventListener("pointerdown", onDown);
163
167
  }, [open]);
164
168
  return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ className }), ...rest, children: [name &&
165
- value.map((v) => (0, jsx_runtime_1.jsx)("input", { type: "hidden", name: name, value: v }, v)), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
169
+ value.map((v) => (0, jsx_runtime_1.jsx)("input", { type: "hidden", name: name, value: v }, v)), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
166
170
  if (open && !searchable)
167
171
  onNavKeys(e);
168
172
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
@@ -1,10 +1,45 @@
1
1
  "use strict";
2
2
  "use client";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
3
36
  Object.defineProperty(exports, "__esModule", { value: true });
4
37
  exports.QuantityPicker = QuantityPicker;
5
38
  const jsx_runtime_1 = require("react/jsx-runtime");
6
39
  const react_quantity_picker_1 = require("@seed-design/react-quantity-picker");
7
40
  const lucide_react_1 = require("lucide-react");
41
+ const React = __importStar(require("react"));
42
+ const core_1 = require("../../core");
8
43
  const spinner_1 = require("../spinner");
9
44
  const quantitypicker_recipe_1 = require("./quantitypicker.recipe");
10
45
  /** seed's icon box per size, read from their stylesheet. */
@@ -57,6 +92,9 @@ function IncrementIcon({ size }) {
57
92
  * reverses its visible children rather than relying on CSS.
58
93
  */
59
94
  function QuantityPicker({ size = "medium", fill = false, className, name, decrementLabel = "Decrease quantity", incrementLabel = "Increase quantity", ...props }) {
60
- return ((0, jsx_runtime_1.jsxs)(react_quantity_picker_1.QuantityPickerRoot, { ...props, className: (0, quantitypicker_recipe_1.quantitypickerRecipe)({ size, fill, className }), children: [(0, jsx_runtime_1.jsx)(react_quantity_picker_1.QuantityPickerDecrementButton, { className: "quantitypicker-button", "aria-label": decrementLabel, children: (0, jsx_runtime_1.jsx)(DecrementIcon, { size: size }) }), (0, jsx_runtime_1.jsx)("span", { className: "quantitypicker-divider" }), (0, jsx_runtime_1.jsx)(Value, {}), (0, jsx_runtime_1.jsx)("span", { className: "quantitypicker-divider" }), (0, jsx_runtime_1.jsx)(react_quantity_picker_1.QuantityPickerIncrementButton, { className: "quantitypicker-button", "aria-label": incrementLabel, children: (0, jsx_runtime_1.jsx)(IncrementIcon, { size: size }) }), name ? (0, jsx_runtime_1.jsx)(react_quantity_picker_1.QuantityPickerHiddenInput, { name: name }) : null] }));
95
+ // A `<label for>` cannot name a div, and this root is a role="group" (#247).
96
+ const rootRef = React.useRef(null);
97
+ (0, core_1.useLabelAssociation)(rootRef);
98
+ return ((0, jsx_runtime_1.jsxs)(react_quantity_picker_1.QuantityPickerRoot, { ref: rootRef, ...props, className: (0, quantitypicker_recipe_1.quantitypickerRecipe)({ size, fill, className }), children: [(0, jsx_runtime_1.jsx)(react_quantity_picker_1.QuantityPickerDecrementButton, { className: "quantitypicker-button", "aria-label": decrementLabel, children: (0, jsx_runtime_1.jsx)(DecrementIcon, { size: size }) }), (0, jsx_runtime_1.jsx)("span", { className: "quantitypicker-divider" }), (0, jsx_runtime_1.jsx)(Value, {}), (0, jsx_runtime_1.jsx)("span", { className: "quantitypicker-divider" }), (0, jsx_runtime_1.jsx)(react_quantity_picker_1.QuantityPickerIncrementButton, { className: "quantitypicker-button", "aria-label": incrementLabel, children: (0, jsx_runtime_1.jsx)(IncrementIcon, { size: size }) }), name ? (0, jsx_runtime_1.jsx)(react_quantity_picker_1.QuantityPickerHiddenInput, { name: name }) : null] }));
61
99
  }
62
100
  QuantityPicker.displayName = "QuantityPicker";
@@ -61,6 +61,9 @@ function RadioGroup({ value, defaultValue, onValueChange, variant = "default", n
61
61
  // `form.reset()` restores native controls; a group of role="radio" buttons
62
62
  // holds its answer in React, so it has to listen (#245). A controlled group
63
63
  // is left alone — the value is the consumer's.
64
+ // A `<label for>` cannot name a div, so a FormField above this group named
65
+ // nothing until now (#247).
66
+ (0, core_1.useLabelAssociation)(groupRef);
64
67
  (0, core_1.useFormReset)(groupRef, () => {
65
68
  if (value === undefined)
66
69
  setInternal(defaultValue);
@@ -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 core_1 = require("../../core");
39
40
  const radiogroup_1 = require("../radiogroup");
40
41
  const segmented_recipe_1 = require("./segmented.recipe");
41
42
  /**
@@ -69,6 +70,9 @@ function Segmented({ options, value, defaultValue, onValueChange, "aria-label":
69
70
  // replaces closed over its own index, so it selected a neighbour without
70
71
  // moving focus and every press after the first recomputed the same step.
71
72
  const { onKeyDown } = (0, radiogroup_1.useRadioGroupKeyboard)({ select, groupRef: rootRef });
73
+ // A `<label for>` cannot name a div, and this root is a role="radiogroup"
74
+ // (#247).
75
+ (0, core_1.useLabelAssociation)(rootRef);
72
76
  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
77
  width: thumb.width,
74
78
  transform: `translateX(${thumb.left}px)`,
@@ -1,22 +1,105 @@
1
1
  "use strict";
2
2
  "use client";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
3
36
  Object.defineProperty(exports, "__esModule", { value: true });
4
37
  exports.Slider = Slider;
5
38
  const jsx_runtime_1 = require("react/jsx-runtime");
6
39
  const react_slider_1 = require("@seed-design/react-slider");
40
+ const React = __importStar(require("react"));
7
41
  const slider_recipe_1 = require("./slider.recipe");
8
- /** Reads the thumb count from the engine rather than re-deriving it from props. */
9
- function Thumbs() {
42
+ const defaultThumbLabel = (index, count) => count === 2 ? (index === 0 ? "minimum" : "maximum") : String(index + 1);
43
+ /**
44
+ * The name for each thumb.
45
+ *
46
+ * The thumbs are what carry `role="slider"`, and they are rendered by the
47
+ * engine, so a name put on the crisp root — an `aria-label` prop, or a
48
+ * FormField's `<label for>` — reached nothing at all (#248). The root is a
49
+ * plain `<div>`: it has no role to name.
50
+ *
51
+ * Both sources are threaded down. Text (`aria-label`) is composed directly;
52
+ * an id reference (`aria-labelledby`, or the label found for the root's own
53
+ * id) is concatenated with a hidden span per thumb, because that is how ARIA
54
+ * builds a name out of parts without inventing a second copy of the label.
55
+ */
56
+ function Thumbs({ ariaLabel, labelledBy, thumbLabel, idBase, }) {
10
57
  const { values } = (0, react_slider_1.useSliderContext)();
11
- return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: values.map((_, i) => ((0, jsx_runtime_1.jsx)(react_slider_1.SliderThumb
12
- // biome-ignore lint/suspicious/noArrayIndexKey: thumbs are positional — the index IS the identity the engine addresses them by
13
- , { thumbIndex: i, className: "slider-thumb" }, i))) }));
58
+ const count = values.length;
59
+ return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: values.map((_, i) => {
60
+ const suffix = count > 1 ? thumbLabel(i, count) : undefined;
61
+ const suffixId = `${idBase}-thumb-${i}`;
62
+ return ((0, jsx_runtime_1.jsxs)(React.Fragment
63
+ // biome-ignore lint/suspicious/noArrayIndexKey: thumbs are positional — the index IS the identity the engine addresses them by
64
+ , { children: [suffix && labelledBy && !ariaLabel && ((0, jsx_runtime_1.jsx)("span", { id: suffixId, className: "slider-thumblabel", children: suffix })), (0, jsx_runtime_1.jsx)(react_slider_1.SliderThumb, { thumbIndex: i, className: "slider-thumb", "aria-label": ariaLabel
65
+ ? suffix
66
+ ? `${ariaLabel} ${suffix}`
67
+ : ariaLabel
68
+ : undefined, "aria-labelledby": !ariaLabel && labelledBy
69
+ ? suffix
70
+ ? `${labelledBy} ${suffixId}`
71
+ : labelledBy
72
+ : undefined })] }, i));
73
+ }) }));
14
74
  }
15
75
  /**
16
76
  * A range input, driven by seed-design's headless engine so the keyboard model,
17
77
  * the pointer maths and multi-thumb constraints are theirs by construction.
18
78
  * Pass one value for a simple slider, two for a range.
19
79
  */
20
- function Slider({ className, ...props }) {
21
- return ((0, jsx_runtime_1.jsx)(react_slider_1.SliderRoot, { ...props, className: (0, slider_recipe_1.sliderRecipe)({ className }), children: (0, jsx_runtime_1.jsxs)("span", { className: "slider-control", children: [(0, jsx_runtime_1.jsx)("span", { className: "slider-track", children: (0, jsx_runtime_1.jsx)(react_slider_1.SliderRange, { className: "slider-range" }) }), (0, jsx_runtime_1.jsx)(Thumbs, {})] }) }));
80
+ function Slider({ className, thumbLabel = defaultThumbLabel, ...props }) {
81
+ const rootRef = React.useRef(null);
82
+ const generatedId = React.useId();
83
+ const idBase = props.id ?? generatedId;
84
+ const ariaLabel = props["aria-label"];
85
+ const labelledByProp = props["aria-labelledby"];
86
+ // A `<label for>` on the root cannot name a div, and the thumbs are what
87
+ // need the name, so the label is resolved from the DOM and threaded down.
88
+ // State rather than a mutation: the thumbs take props, so the name belongs
89
+ // in the render rather than being written over it afterwards.
90
+ const [foundLabelId, setFoundLabelId] = React.useState();
91
+ React.useEffect(() => {
92
+ if (ariaLabel || labelledByProp)
93
+ return;
94
+ const el = rootRef.current;
95
+ if (!el?.id)
96
+ return;
97
+ const label = el.ownerDocument.querySelector(`label[for="${CSS.escape(el.id)}"]`);
98
+ if (!label)
99
+ return;
100
+ if (!label.id)
101
+ label.id = `${el.id}-label`;
102
+ setFoundLabelId(label.id);
103
+ }, [ariaLabel, labelledByProp]);
104
+ return ((0, jsx_runtime_1.jsx)(react_slider_1.SliderRoot, { ref: rootRef, ...props, className: (0, slider_recipe_1.sliderRecipe)({ className }), children: (0, jsx_runtime_1.jsxs)("span", { className: "slider-control", children: [(0, jsx_runtime_1.jsx)("span", { className: "slider-track", children: (0, jsx_runtime_1.jsx)(react_slider_1.SliderRange, { className: "slider-range" }) }), (0, jsx_runtime_1.jsx)(Thumbs, { ariaLabel: ariaLabel, labelledBy: labelledByProp ?? foundLabelId, thumbLabel: thumbLabel, idBase: idBase })] }) }));
22
105
  }
@@ -27,6 +27,12 @@ const config = (0, core_1.defineRecipe)({
27
27
  width: 100%; min-width: 0; gap: ${tokens_1.contract.space["0.5"]};
28
28
  font-family: ${tokens_1.contract.font.sans}; user-select: none; touch-action: none;
29
29
 
30
+ /* the position words ("minimum"/"maximum") that ARIA concatenates onto a
31
+ thumb's name — read, never drawn */
32
+ & .slider-thumblabel {
33
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
34
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
35
+ }
30
36
  & .slider-control { position: relative; height: 26px; display: flex; align-items: center; }
31
37
 
32
38
  & .slider-track {
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.usePrefersReducedMotion = exports.useFormReset = exports.useFocusAfterRemoval = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = void 0;
17
+ exports.usePrefersReducedMotion = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = void 0;
18
18
  var cx_1 = require("./cx");
19
19
  Object.defineProperty(exports, "cx", { enumerable: true, get: function () { return cx_1.cx; } });
20
20
  var define_1 = require("./define");
@@ -34,5 +34,7 @@ var use_focus_after_removal_1 = require("./use-focus-after-removal");
34
34
  Object.defineProperty(exports, "useFocusAfterRemoval", { enumerable: true, get: function () { return use_focus_after_removal_1.useFocusAfterRemoval; } });
35
35
  var use_form_reset_1 = require("./use-form-reset");
36
36
  Object.defineProperty(exports, "useFormReset", { enumerable: true, get: function () { return use_form_reset_1.useFormReset; } });
37
+ var use_label_association_1 = require("./use-label-association");
38
+ Object.defineProperty(exports, "useLabelAssociation", { enumerable: true, get: function () { return use_label_association_1.useLabelAssociation; } });
37
39
  var use_prefers_reduced_motion_1 = require("./use-prefers-reduced-motion");
38
40
  Object.defineProperty(exports, "usePrefersReducedMotion", { enumerable: true, get: function () { return use_prefers_reduced_motion_1.usePrefersReducedMotion; } });
@@ -0,0 +1,77 @@
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
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.useLabelAssociation = useLabelAssociation;
37
+ const React = __importStar(require("react"));
38
+ /**
39
+ * Let a `<label for=…>` name a group.
40
+ *
41
+ * `for` only associates with a LABELABLE element — button, input, select,
42
+ * textarea, meter, output, progress. A `role="radiogroup"` or `role="group"`
43
+ * is a `<div>`, so a FormField label sitting above one names nothing at all:
44
+ * the markup looks right, the label is clickable, and a screen reader
45
+ * announces "group" with no name (#247).
46
+ *
47
+ * ARIA's answer for a group is `aria-labelledby`, which is exactly what this
48
+ * repairs: when the element carries neither `aria-label` nor
49
+ * `aria-labelledby`, and a label points at its id, that label becomes its
50
+ * name. Done from the DOM rather than through a prop because the label is
51
+ * rendered by whoever wrote the markup — crisp's FormField, or a hand-written
52
+ * `<label>` — and neither can reach into the group's props.
53
+ *
54
+ * The label is given an id if it has none, derived from the control's own id
55
+ * so two groups on a page cannot collide.
56
+ */
57
+ function useLabelAssociation(ref) {
58
+ React.useEffect(() => {
59
+ const el = ref.current;
60
+ if (!el?.id)
61
+ return;
62
+ if (el.getAttribute("aria-label") || el.getAttribute("aria-labelledby"))
63
+ return;
64
+ const label = el.ownerDocument.querySelector(`label[for="${CSS.escape(el.id)}"]`);
65
+ if (!label)
66
+ return;
67
+ if (!label.id)
68
+ label.id = `${el.id}-label`;
69
+ el.setAttribute("aria-labelledby", label.id);
70
+ return () => {
71
+ // only take back what this put there: a consumer who set their own
72
+ // aria-labelledby in the meantime keeps it
73
+ if (el.getAttribute("aria-labelledby") === label.id)
74
+ el.removeAttribute("aria-labelledby");
75
+ };
76
+ });
77
+ }
@@ -29,7 +29,11 @@ function Chip({ opt }) {
29
29
  * popover with a search field over rows of "[chip] … [checkbox]" that toggle on
30
30
  * click (the menu stays open). Keyboard: ↑/↓ move, Enter toggles, Esc closes.
31
31
  */
32
- export function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
32
+ export function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name,
33
+ // The id belongs on the trigger, not the wrapper: `<label for>` only names a
34
+ // LABELABLE element, so an id left on the root div meant a FormField label
35
+ // above this control named nothing (#247). Select already did this.
36
+ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
33
37
  // Virtual focus: DOM focus stays on the trigger (or the search field) and the
34
38
  // active option is named by `aria-activedescendant`. `data-highlighted` is a
35
39
  // styling hook only -- a screen reader cannot see it.
@@ -126,7 +130,7 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
126
130
  return () => document.removeEventListener("pointerdown", onDown);
127
131
  }, [open]);
128
132
  return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ className }), ...rest, children: [name &&
129
- value.map((v) => _jsx("input", { type: "hidden", name: name, value: v }, v)), _jsxs("button", { ref: triggerRef, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
133
+ value.map((v) => _jsx("input", { type: "hidden", name: name, value: v }, v)), _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
130
134
  if (open && !searchable)
131
135
  onNavKeys(e);
132
136
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
@@ -2,6 +2,8 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { QuantityPickerDecrementButton, QuantityPickerHiddenInput, QuantityPickerIncrementButton, QuantityPickerRoot, useQuantityPickerContext, } from "@seed-design/react-quantity-picker";
4
4
  import { Minus, Plus, Trash2 } from "lucide-react";
5
+ import * as React from "react";
6
+ import { useLabelAssociation } from "../../core/index.js";
5
7
  import { Spinner } from "../spinner/index.js";
6
8
  import { quantitypickerRecipe } from "./quantitypicker.recipe.js";
7
9
  /** seed's icon box per size, read from their stylesheet. */
@@ -54,6 +56,9 @@ function IncrementIcon({ size }) {
54
56
  * reverses its visible children rather than relying on CSS.
55
57
  */
56
58
  export function QuantityPicker({ size = "medium", fill = false, className, name, decrementLabel = "Decrease quantity", incrementLabel = "Increase quantity", ...props }) {
57
- return (_jsxs(QuantityPickerRoot, { ...props, className: quantitypickerRecipe({ size, fill, className }), children: [_jsx(QuantityPickerDecrementButton, { className: "quantitypicker-button", "aria-label": decrementLabel, children: _jsx(DecrementIcon, { size: size }) }), _jsx("span", { className: "quantitypicker-divider" }), _jsx(Value, {}), _jsx("span", { className: "quantitypicker-divider" }), _jsx(QuantityPickerIncrementButton, { className: "quantitypicker-button", "aria-label": incrementLabel, children: _jsx(IncrementIcon, { size: size }) }), name ? _jsx(QuantityPickerHiddenInput, { name: name }) : null] }));
59
+ // A `<label for>` cannot name a div, and this root is a role="group" (#247).
60
+ const rootRef = React.useRef(null);
61
+ useLabelAssociation(rootRef);
62
+ return (_jsxs(QuantityPickerRoot, { ref: rootRef, ...props, className: quantitypickerRecipe({ size, fill, className }), children: [_jsx(QuantityPickerDecrementButton, { className: "quantitypicker-button", "aria-label": decrementLabel, children: _jsx(DecrementIcon, { size: size }) }), _jsx("span", { className: "quantitypicker-divider" }), _jsx(Value, {}), _jsx("span", { className: "quantitypicker-divider" }), _jsx(QuantityPickerIncrementButton, { className: "quantitypicker-button", "aria-label": incrementLabel, children: _jsx(IncrementIcon, { size: size }) }), name ? _jsx(QuantityPickerHiddenInput, { name: name }) : null] }));
58
63
  }
59
64
  QuantityPicker.displayName = "QuantityPicker";
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
- import { useFormReset } from "../../core/index.js";
3
+ import { useFormReset, useLabelAssociation } from "../../core/index.js";
4
4
  import { radiogroupRecipe } from "./radiogroup.recipe.js";
5
5
  import { useRadioGroupKeyboard } from "./radiogroup-keyboard.js";
6
6
  const RadioContext = React.createContext(null);
@@ -24,6 +24,9 @@ export function RadioGroup({ value, defaultValue, onValueChange, variant = "defa
24
24
  // `form.reset()` restores native controls; a group of role="radio" buttons
25
25
  // holds its answer in React, so it has to listen (#245). A controlled group
26
26
  // is left alone — the value is the consumer's.
27
+ // A `<label for>` cannot name a div, so a FormField above this group named
28
+ // nothing until now (#247).
29
+ useLabelAssociation(groupRef);
27
30
  useFormReset(groupRef, () => {
28
31
  if (value === undefined)
29
32
  setInternal(defaultValue);
@@ -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 { useLabelAssociation } from "../../core/index.js";
3
4
  import { useRadioGroupKeyboard } from "../radiogroup/index.js";
4
5
  import { segmentedRecipe } from "./segmented.recipe.js";
5
6
  /**
@@ -33,6 +34,9 @@ export function Segmented({ options, value, defaultValue, onValueChange, "aria-l
33
34
  // replaces closed over its own index, so it selected a neighbour without
34
35
  // moving focus and every press after the first recomputed the same step.
35
36
  const { onKeyDown } = useRadioGroupKeyboard({ select, groupRef: rootRef });
37
+ // A `<label for>` cannot name a div, and this root is a role="radiogroup"
38
+ // (#247).
39
+ useLabelAssociation(rootRef);
36
40
  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
41
  width: thumb.width,
38
42
  transform: `translateX(${thumb.left}px)`,
@@ -1,19 +1,69 @@
1
1
  "use client";
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { SliderRange, SliderRoot, SliderThumb, useSliderContext, } from "@seed-design/react-slider";
4
+ import * as React from "react";
4
5
  import { sliderRecipe } from "./slider.recipe.js";
5
- /** Reads the thumb count from the engine rather than re-deriving it from props. */
6
- function Thumbs() {
6
+ const defaultThumbLabel = (index, count) => count === 2 ? (index === 0 ? "minimum" : "maximum") : String(index + 1);
7
+ /**
8
+ * The name for each thumb.
9
+ *
10
+ * The thumbs are what carry `role="slider"`, and they are rendered by the
11
+ * engine, so a name put on the crisp root — an `aria-label` prop, or a
12
+ * FormField's `<label for>` — reached nothing at all (#248). The root is a
13
+ * plain `<div>`: it has no role to name.
14
+ *
15
+ * Both sources are threaded down. Text (`aria-label`) is composed directly;
16
+ * an id reference (`aria-labelledby`, or the label found for the root's own
17
+ * id) is concatenated with a hidden span per thumb, because that is how ARIA
18
+ * builds a name out of parts without inventing a second copy of the label.
19
+ */
20
+ function Thumbs({ ariaLabel, labelledBy, thumbLabel, idBase, }) {
7
21
  const { values } = useSliderContext();
8
- return (_jsx(_Fragment, { children: values.map((_, i) => (_jsx(SliderThumb
9
- // biome-ignore lint/suspicious/noArrayIndexKey: thumbs are positional — the index IS the identity the engine addresses them by
10
- , { thumbIndex: i, className: "slider-thumb" }, i))) }));
22
+ const count = values.length;
23
+ return (_jsx(_Fragment, { children: values.map((_, i) => {
24
+ const suffix = count > 1 ? thumbLabel(i, count) : undefined;
25
+ const suffixId = `${idBase}-thumb-${i}`;
26
+ return (_jsxs(React.Fragment
27
+ // biome-ignore lint/suspicious/noArrayIndexKey: thumbs are positional — the index IS the identity the engine addresses them by
28
+ , { children: [suffix && labelledBy && !ariaLabel && (_jsx("span", { id: suffixId, className: "slider-thumblabel", children: suffix })), _jsx(SliderThumb, { thumbIndex: i, className: "slider-thumb", "aria-label": ariaLabel
29
+ ? suffix
30
+ ? `${ariaLabel} ${suffix}`
31
+ : ariaLabel
32
+ : undefined, "aria-labelledby": !ariaLabel && labelledBy
33
+ ? suffix
34
+ ? `${labelledBy} ${suffixId}`
35
+ : labelledBy
36
+ : undefined })] }, i));
37
+ }) }));
11
38
  }
12
39
  /**
13
40
  * A range input, driven by seed-design's headless engine so the keyboard model,
14
41
  * the pointer maths and multi-thumb constraints are theirs by construction.
15
42
  * Pass one value for a simple slider, two for a range.
16
43
  */
17
- export function Slider({ className, ...props }) {
18
- return (_jsx(SliderRoot, { ...props, className: sliderRecipe({ className }), children: _jsxs("span", { className: "slider-control", children: [_jsx("span", { className: "slider-track", children: _jsx(SliderRange, { className: "slider-range" }) }), _jsx(Thumbs, {})] }) }));
44
+ export function Slider({ className, thumbLabel = defaultThumbLabel, ...props }) {
45
+ const rootRef = React.useRef(null);
46
+ const generatedId = React.useId();
47
+ const idBase = props.id ?? generatedId;
48
+ const ariaLabel = props["aria-label"];
49
+ const labelledByProp = props["aria-labelledby"];
50
+ // A `<label for>` on the root cannot name a div, and the thumbs are what
51
+ // need the name, so the label is resolved from the DOM and threaded down.
52
+ // State rather than a mutation: the thumbs take props, so the name belongs
53
+ // in the render rather than being written over it afterwards.
54
+ const [foundLabelId, setFoundLabelId] = React.useState();
55
+ React.useEffect(() => {
56
+ if (ariaLabel || labelledByProp)
57
+ return;
58
+ const el = rootRef.current;
59
+ if (!el?.id)
60
+ return;
61
+ const label = el.ownerDocument.querySelector(`label[for="${CSS.escape(el.id)}"]`);
62
+ if (!label)
63
+ return;
64
+ if (!label.id)
65
+ label.id = `${el.id}-label`;
66
+ setFoundLabelId(label.id);
67
+ }, [ariaLabel, labelledByProp]);
68
+ return (_jsx(SliderRoot, { ref: rootRef, ...props, className: sliderRecipe({ className }), children: _jsxs("span", { className: "slider-control", children: [_jsx("span", { className: "slider-track", children: _jsx(SliderRange, { className: "slider-range" }) }), _jsx(Thumbs, { ariaLabel: ariaLabel, labelledBy: labelledByProp ?? foundLabelId, thumbLabel: thumbLabel, idBase: idBase })] }) }));
19
69
  }
@@ -24,6 +24,12 @@ const config = defineRecipe({
24
24
  width: 100%; min-width: 0; gap: ${c.space["0.5"]};
25
25
  font-family: ${c.font.sans}; user-select: none; touch-action: none;
26
26
 
27
+ /* the position words ("minimum"/"maximum") that ARIA concatenates onto a
28
+ thumb's name — read, never drawn */
29
+ & .slider-thumblabel {
30
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
31
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
32
+ }
27
33
  & .slider-control { position: relative; height: 26px; display: flex; align-items: center; }
28
34
 
29
35
  & .slider-track {
@@ -8,4 +8,5 @@ export { resolveRecipe } from "./resolve.js";
8
8
  export * from "./rtl.js";
9
9
  export { useFocusAfterRemoval } from "./use-focus-after-removal.js";
10
10
  export { useFormReset } from "./use-form-reset.js";
11
+ export { useLabelAssociation } from "./use-label-association.js";
11
12
  export { usePrefersReducedMotion } from "./use-prefers-reduced-motion.js";
@@ -0,0 +1,41 @@
1
+ import * as React from "react";
2
+ /**
3
+ * Let a `<label for=…>` name a group.
4
+ *
5
+ * `for` only associates with a LABELABLE element — button, input, select,
6
+ * textarea, meter, output, progress. A `role="radiogroup"` or `role="group"`
7
+ * is a `<div>`, so a FormField label sitting above one names nothing at all:
8
+ * the markup looks right, the label is clickable, and a screen reader
9
+ * announces "group" with no name (#247).
10
+ *
11
+ * ARIA's answer for a group is `aria-labelledby`, which is exactly what this
12
+ * repairs: when the element carries neither `aria-label` nor
13
+ * `aria-labelledby`, and a label points at its id, that label becomes its
14
+ * name. Done from the DOM rather than through a prop because the label is
15
+ * rendered by whoever wrote the markup — crisp's FormField, or a hand-written
16
+ * `<label>` — and neither can reach into the group's props.
17
+ *
18
+ * The label is given an id if it has none, derived from the control's own id
19
+ * so two groups on a page cannot collide.
20
+ */
21
+ export function useLabelAssociation(ref) {
22
+ React.useEffect(() => {
23
+ const el = ref.current;
24
+ if (!el?.id)
25
+ return;
26
+ if (el.getAttribute("aria-label") || el.getAttribute("aria-labelledby"))
27
+ return;
28
+ const label = el.ownerDocument.querySelector(`label[for="${CSS.escape(el.id)}"]`);
29
+ if (!label)
30
+ return;
31
+ if (!label.id)
32
+ label.id = `${el.id}-label`;
33
+ el.setAttribute("aria-labelledby", label.id);
34
+ return () => {
35
+ // only take back what this put there: a consumer who set their own
36
+ // aria-labelledby in the meantime keeps it
37
+ if (el.getAttribute("aria-labelledby") === label.id)
38
+ el.removeAttribute("aria-labelledby");
39
+ };
40
+ });
41
+ }
package/dist/slider.css CHANGED
@@ -4,6 +4,12 @@
4
4
  width: 100%; min-width: 0; gap: var(--space-0-5);
5
5
  font-family: var(--font-sans); user-select: none; touch-action: none;
6
6
 
7
+ /* the position words ("minimum"/"maximum") that ARIA concatenates onto a
8
+ thumb's name — read, never drawn */
9
+ & .slider-thumblabel {
10
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
11
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
12
+ }
7
13
  & .slider-control { position: relative; height: 26px; display: flex; align-items: center; }
8
14
 
9
15
  & .slider-track {
package/dist/styles.css CHANGED
@@ -1454,6 +1454,12 @@
1454
1454
  width: 100%; min-width: 0; gap: var(--space-0-5);
1455
1455
  font-family: var(--font-sans); user-select: none; touch-action: none;
1456
1456
 
1457
+ /* the position words ("minimum"/"maximum") that ARIA concatenates onto a
1458
+ thumb's name — read, never drawn */
1459
+ & .slider-thumblabel {
1460
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
1461
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
1462
+ }
1457
1463
  & .slider-control { position: relative; height: 26px; display: flex; align-items: center; }
1458
1464
 
1459
1465
  & .slider-track {
@@ -36,7 +36,7 @@ export type MultiSelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "valu
36
36
  * popover with a search field over rows of "[chip] … [checkbox]" that toggle on
37
37
  * click (the menu stays open). Keyboard: ↑/↓ move, Enter toggles, Esc closes.
38
38
  */
39
- export declare function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, placeholder, searchable, searchPlaceholder, emptyLabel, disabled, className, "aria-label": ariaLabel, ...rest }: MultiSelectProps): React.JSX.Element;
39
+ export declare function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, id, placeholder, searchable, searchPlaceholder, emptyLabel, disabled, className, "aria-label": ariaLabel, ...rest }: MultiSelectProps): React.JSX.Element;
40
40
  export declare namespace MultiSelect {
41
41
  var displayName: string;
42
42
  }
@@ -1,4 +1,5 @@
1
1
  import { type QuantityPickerRootProps } from "@seed-design/react-quantity-picker";
2
+ import * as React from "react";
2
3
  /** seed's icon box per size, read from their stylesheet. */
3
4
  declare const ICON: {
4
5
  readonly small: 16;
@@ -36,7 +37,7 @@ export type QuantityPickerProps = QuantityPickerRootProps & {
36
37
  * Slider documents. Here it also decides child ORDER, since the engine
37
38
  * reverses its visible children rather than relying on CSS.
38
39
  */
39
- export declare function QuantityPicker({ size, fill, className, name, decrementLabel, incrementLabel, ...props }: QuantityPickerProps): import("react").JSX.Element;
40
+ export declare function QuantityPicker({ size, fill, className, name, decrementLabel, incrementLabel, ...props }: QuantityPickerProps): React.JSX.Element;
40
41
  export declare namespace QuantityPicker {
41
42
  var displayName: string;
42
43
  }
@@ -1,8 +1,19 @@
1
1
  import { type SliderRootProps } from "@seed-design/react-slider";
2
- export type SliderProps = SliderRootProps;
2
+ import * as React from "react";
3
+ export type SliderProps = SliderRootProps & {
4
+ /**
5
+ * What to call each thumb when there is more than one, appended to the
6
+ * slider's own name: "Price minimum" / "Price maximum".
7
+ *
8
+ * A formatter, not two strings, because the number of thumbs is open-ended
9
+ * and the words differ by language. With a single thumb it is never called —
10
+ * the thumb simply takes the slider's name.
11
+ */
12
+ thumbLabel?: (index: number, count: number) => string;
13
+ };
3
14
  /**
4
15
  * A range input, driven by seed-design's headless engine so the keyboard model,
5
16
  * the pointer maths and multi-thumb constraints are theirs by construction.
6
17
  * Pass one value for a simple slider, two for a range.
7
18
  */
8
- export declare function Slider({ className, ...props }: SliderProps): import("react").JSX.Element;
19
+ export declare function Slider({ className, thumbLabel, ...props }: SliderProps): React.JSX.Element;
@@ -9,4 +9,5 @@ export * from "./rtl";
9
9
  export type * from "./types";
10
10
  export { useFocusAfterRemoval } from "./use-focus-after-removal";
11
11
  export { useFormReset } from "./use-form-reset";
12
+ export { useLabelAssociation } from "./use-label-association";
12
13
  export { usePrefersReducedMotion } from "./use-prefers-reduced-motion";
@@ -0,0 +1,21 @@
1
+ import * as React from "react";
2
+ /**
3
+ * Let a `<label for=…>` name a group.
4
+ *
5
+ * `for` only associates with a LABELABLE element — button, input, select,
6
+ * textarea, meter, output, progress. A `role="radiogroup"` or `role="group"`
7
+ * is a `<div>`, so a FormField label sitting above one names nothing at all:
8
+ * the markup looks right, the label is clickable, and a screen reader
9
+ * announces "group" with no name (#247).
10
+ *
11
+ * ARIA's answer for a group is `aria-labelledby`, which is exactly what this
12
+ * repairs: when the element carries neither `aria-label` nor
13
+ * `aria-labelledby`, and a label points at its id, that label becomes its
14
+ * name. Done from the DOM rather than through a prop because the label is
15
+ * rendered by whoever wrote the markup — crisp's FormField, or a hand-written
16
+ * `<label>` — and neither can reach into the group's props.
17
+ *
18
+ * The label is given an id if it has none, derived from the control's own id
19
+ * so two groups on a page cannot collide.
20
+ */
21
+ export declare function useLabelAssociation(ref: React.RefObject<HTMLElement | null>): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",