@crisp-ui-kit/crisp 0.27.0 → 0.29.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.
@@ -78,6 +82,13 @@ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, n
78
82
  const [query, setQuery] = React.useState("");
79
83
  const [highlight, setHighlight] = React.useState(0);
80
84
  const rootRef = React.useRef(null);
85
+ // `form.reset()` restores native controls; a button + listbox holds its value
86
+ // in React, so it has to listen (#245). Controlled multiselects are left
87
+ // alone.
88
+ (0, core_1.useFormReset)(rootRef, () => {
89
+ if (valueProp === undefined)
90
+ setInternalValue(defaultValue ?? []);
91
+ });
81
92
  const triggerRef = React.useRef(null);
82
93
  const searchRef = React.useRef(null);
83
94
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? []);
@@ -155,7 +166,7 @@ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, n
155
166
  return () => document.removeEventListener("pointerdown", onDown);
156
167
  }, [open]);
157
168
  return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ className }), ...rest, children: [name &&
158
- 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) => {
159
170
  if (open && !searchable)
160
171
  onNavKeys(e);
161
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";
@@ -37,6 +37,7 @@ exports.RadioGroup = RadioGroup;
37
37
  exports.Radio = Radio;
38
38
  const jsx_runtime_1 = require("react/jsx-runtime");
39
39
  const React = __importStar(require("react"));
40
+ const core_1 = require("../../core");
40
41
  const radiogroup_recipe_1 = require("./radiogroup.recipe");
41
42
  const radiogroup_keyboard_1 = require("./radiogroup-keyboard");
42
43
  const RadioContext = React.createContext(null);
@@ -57,6 +58,16 @@ function RadioGroup({ value, defaultValue, onValueChange, variant = "default", n
57
58
  onValueChange?.(v);
58
59
  }, [value, onValueChange]);
59
60
  const { onKeyDown, groupRef } = (0, radiogroup_keyboard_1.useRadioGroupKeyboard)({ select });
61
+ // `form.reset()` restores native controls; a group of role="radio" buttons
62
+ // holds its answer in React, so it has to listen (#245). A controlled group
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);
67
+ (0, core_1.useFormReset)(groupRef, () => {
68
+ if (value === undefined)
69
+ setInternal(defaultValue);
70
+ });
60
71
  return ((0, jsx_runtime_1.jsx)(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: (0, jsx_runtime_1.jsxs)("div", { ref: groupRef, role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: (0, radiogroup_recipe_1.radiogroupRecipe)({ className }), ...rest, onKeyDown: onKeyDown, children: [children, name && selected && ((0, jsx_runtime_1.jsx)("input", { type: "hidden", name: name, value: selected }))] }) }));
61
72
  }
62
73
  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 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)`,
@@ -69,6 +69,12 @@ function Select({ options, value, defaultValue, onValueChange, name, placeholder
69
69
  const [highlight, setHighlight] = React.useState(0);
70
70
  const [query, setQuery] = React.useState("");
71
71
  const rootRef = React.useRef(null);
72
+ // `form.reset()` restores native controls; a button + listbox holds its value
73
+ // in React, so it has to listen (#245). Controlled selects are left alone.
74
+ (0, core_1.useFormReset)(rootRef, () => {
75
+ if (value === undefined)
76
+ setInternal(defaultValue);
77
+ });
72
78
  const triggerRef = React.useRef(null);
73
79
  const searchRef = React.useRef(null);
74
80
  const selectedOption = options.find((o) => o.value === selected);
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Switch = void 0;
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 switch_recipe_1 = require("./switch.recipe");
40
41
  /**
41
42
  * An on/off toggle — the settings switch, measured from Attio. Renders a
@@ -46,7 +47,16 @@ const switch_recipe_1 = require("./switch.recipe");
46
47
  exports.Switch = React.forwardRef(({ checked, defaultChecked = false, onCheckedChange, disabled, className, onClick, name, value = "on", ...props }, ref) => {
47
48
  const [internal, setInternal] = React.useState(defaultChecked);
48
49
  const on = checked ?? internal;
49
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { ref: ref, type: "button", role: "switch", "aria-checked": on, disabled: disabled, "data-checked": on ? "" : undefined, className: (0, switch_recipe_1.switchRecipe)({ className }), onClick: (e) => {
50
+ // `form.reset()` restores native controls; a role="switch" button holds its
51
+ // state in React, so it has to listen (#245). A controlled switch is left
52
+ // alone — the value is the consumer's.
53
+ const rootRef = React.useRef(null);
54
+ React.useImperativeHandle(ref, () => rootRef.current);
55
+ (0, core_1.useFormReset)(rootRef, () => {
56
+ if (checked === undefined)
57
+ setInternal(defaultChecked);
58
+ });
59
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { ref: rootRef, type: "button", role: "switch", "aria-checked": on, disabled: disabled, "data-checked": on ? "" : undefined, className: (0, switch_recipe_1.switchRecipe)({ className }), onClick: (e) => {
50
60
  onClick?.(e);
51
61
  if (e.defaultPrevented)
52
62
  return;
@@ -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.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");
@@ -32,5 +32,9 @@ Object.defineProperty(exports, "resolveRecipe", { enumerable: true, get: functio
32
32
  __exportStar(require("./rtl"), exports);
33
33
  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
+ var use_form_reset_1 = require("./use-form-reset");
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; } });
35
39
  var use_prefers_reduced_motion_1 = require("./use-prefers-reduced-motion");
36
40
  Object.defineProperty(exports, "usePrefersReducedMotion", { enumerable: true, get: function () { return use_prefers_reduced_motion_1.usePrefersReducedMotion; } });
@@ -0,0 +1,78 @@
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.useFormReset = useFormReset;
37
+ const React = __importStar(require("react"));
38
+ /**
39
+ * Restore a control's default when the form around it is reset.
40
+ *
41
+ * `form.reset()` restores native controls — crisp's Input and Checkbox come
42
+ * back on their own. The controls that are buttons and listboxes hold their
43
+ * value in React state, which the platform cannot touch, so a Reset button
44
+ * used to snap half a form back and leave the other half as the user set it
45
+ * (#245). The hidden inputs that carry the value to `FormData` do not help:
46
+ * React rewrites them from the same state a moment later.
47
+ *
48
+ * Only the UNCONTROLLED case is restored here. A controlled component's value
49
+ * belongs to the consumer, and React already behaves this way for a controlled
50
+ * `<input>`: the platform resets the DOM, the next render puts the prop
51
+ * straight back. A consumer who wants a controlled control to follow the reset
52
+ * listens for the same event.
53
+ *
54
+ * The listener lives on the form the element is in, found from the element
55
+ * rather than passed in, because the consumer owns the markup between them.
56
+ *
57
+ * The `value === undefined` guard in each caller is deliberate and is NOT
58
+ * covered by a test, because it cannot be: with a controlled component the
59
+ * internal state is never read — `value ?? internal` — so writing to it
60
+ * changes nothing observable. It is kept because a component that later reads
61
+ * that state (a consumer switching from controlled to uncontrolled) would
62
+ * otherwise find a value the reset put there behind its back.
63
+ */
64
+ function useFormReset(ref, onReset) {
65
+ // the handler is read through a ref so a fresh closure each render does not
66
+ // resubscribe: the listener is bound to the form, not to this render.
67
+ const latest = React.useRef(onReset);
68
+ latest.current = onReset;
69
+ React.useEffect(() => {
70
+ const el = ref.current;
71
+ const form = el?.closest("form");
72
+ if (!form)
73
+ return;
74
+ const handler = () => latest.current();
75
+ form.addEventListener("reset", handler);
76
+ return () => form.removeEventListener("reset", handler);
77
+ }, [ref]);
78
+ }
@@ -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
+ }
@@ -1,7 +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 { placeFloating } from "../../core/index.js";
4
+ import { placeFloating, useFormReset } from "../../core/index.js";
5
5
  import { useListboxKeyboard } from "../select/index.js";
6
6
  import { multiselectRecipe } from "./multiselect.recipe.js";
7
7
  const TONES = [
@@ -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.
@@ -42,6 +46,13 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
42
46
  const [query, setQuery] = React.useState("");
43
47
  const [highlight, setHighlight] = React.useState(0);
44
48
  const rootRef = React.useRef(null);
49
+ // `form.reset()` restores native controls; a button + listbox holds its value
50
+ // in React, so it has to listen (#245). Controlled multiselects are left
51
+ // alone.
52
+ useFormReset(rootRef, () => {
53
+ if (valueProp === undefined)
54
+ setInternalValue(defaultValue ?? []);
55
+ });
45
56
  const triggerRef = React.useRef(null);
46
57
  const searchRef = React.useRef(null);
47
58
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? []);
@@ -119,7 +130,7 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
119
130
  return () => document.removeEventListener("pointerdown", onDown);
120
131
  }, [open]);
121
132
  return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ className }), ...rest, children: [name &&
122
- 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) => {
123
134
  if (open && !searchable)
124
135
  onNavKeys(e);
125
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,5 +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, useLabelAssociation } from "../../core/index.js";
3
4
  import { radiogroupRecipe } from "./radiogroup.recipe.js";
4
5
  import { useRadioGroupKeyboard } from "./radiogroup-keyboard.js";
5
6
  const RadioContext = React.createContext(null);
@@ -20,6 +21,16 @@ export function RadioGroup({ value, defaultValue, onValueChange, variant = "defa
20
21
  onValueChange?.(v);
21
22
  }, [value, onValueChange]);
22
23
  const { onKeyDown, groupRef } = useRadioGroupKeyboard({ select });
24
+ // `form.reset()` restores native controls; a group of role="radio" buttons
25
+ // holds its answer in React, so it has to listen (#245). A controlled group
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);
30
+ useFormReset(groupRef, () => {
31
+ if (value === undefined)
32
+ setInternal(defaultValue);
33
+ });
23
34
  return (_jsx(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: _jsxs("div", { ref: groupRef, role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: radiogroupRecipe({ className }), ...rest, onKeyDown: onKeyDown, children: [children, name && selected && (_jsx("input", { type: "hidden", name: name, value: selected }))] }) }));
24
35
  }
25
36
  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 { 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,7 +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 { placeFloating } from "../../core/index.js";
4
+ import { placeFloating, useFormReset } from "../../core/index.js";
5
5
  import { useListboxKeyboard } from "./listbox-keyboard.js";
6
6
  import { selectRecipe } from "./select.recipe.js";
7
7
  function optionText(o) {
@@ -33,6 +33,12 @@ export function Select({ options, value, defaultValue, onValueChange, name, plac
33
33
  const [highlight, setHighlight] = React.useState(0);
34
34
  const [query, setQuery] = React.useState("");
35
35
  const rootRef = React.useRef(null);
36
+ // `form.reset()` restores native controls; a button + listbox holds its value
37
+ // in React, so it has to listen (#245). Controlled selects are left alone.
38
+ useFormReset(rootRef, () => {
39
+ if (value === undefined)
40
+ setInternal(defaultValue);
41
+ });
36
42
  const triggerRef = React.useRef(null);
37
43
  const searchRef = React.useRef(null);
38
44
  const selectedOption = options.find((o) => o.value === selected);
@@ -1,5 +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
4
  import { switchRecipe } from "./switch.recipe.js";
4
5
  /**
5
6
  * An on/off toggle — the settings switch, measured from Attio. Renders a
@@ -10,7 +11,16 @@ import { switchRecipe } from "./switch.recipe.js";
10
11
  export const Switch = React.forwardRef(({ checked, defaultChecked = false, onCheckedChange, disabled, className, onClick, name, value = "on", ...props }, ref) => {
11
12
  const [internal, setInternal] = React.useState(defaultChecked);
12
13
  const on = checked ?? internal;
13
- return (_jsxs(_Fragment, { children: [_jsx("button", { ref: ref, type: "button", role: "switch", "aria-checked": on, disabled: disabled, "data-checked": on ? "" : undefined, className: switchRecipe({ className }), onClick: (e) => {
14
+ // `form.reset()` restores native controls; a role="switch" button holds its
15
+ // state in React, so it has to listen (#245). A controlled switch is left
16
+ // alone — the value is the consumer's.
17
+ const rootRef = React.useRef(null);
18
+ React.useImperativeHandle(ref, () => rootRef.current);
19
+ useFormReset(rootRef, () => {
20
+ if (checked === undefined)
21
+ setInternal(defaultChecked);
22
+ });
23
+ return (_jsxs(_Fragment, { children: [_jsx("button", { ref: rootRef, type: "button", role: "switch", "aria-checked": on, disabled: disabled, "data-checked": on ? "" : undefined, className: switchRecipe({ className }), onClick: (e) => {
14
24
  onClick?.(e);
15
25
  if (e.defaultPrevented)
16
26
  return;
@@ -7,4 +7,6 @@ export { Primitive } from "./primitive.js";
7
7
  export { resolveRecipe } from "./resolve.js";
8
8
  export * from "./rtl.js";
9
9
  export { useFocusAfterRemoval } from "./use-focus-after-removal.js";
10
+ export { useFormReset } from "./use-form-reset.js";
11
+ export { useLabelAssociation } from "./use-label-association.js";
10
12
  export { usePrefersReducedMotion } from "./use-prefers-reduced-motion.js";
@@ -0,0 +1,42 @@
1
+ import * as React from "react";
2
+ /**
3
+ * Restore a control's default when the form around it is reset.
4
+ *
5
+ * `form.reset()` restores native controls — crisp's Input and Checkbox come
6
+ * back on their own. The controls that are buttons and listboxes hold their
7
+ * value in React state, which the platform cannot touch, so a Reset button
8
+ * used to snap half a form back and leave the other half as the user set it
9
+ * (#245). The hidden inputs that carry the value to `FormData` do not help:
10
+ * React rewrites them from the same state a moment later.
11
+ *
12
+ * Only the UNCONTROLLED case is restored here. A controlled component's value
13
+ * belongs to the consumer, and React already behaves this way for a controlled
14
+ * `<input>`: the platform resets the DOM, the next render puts the prop
15
+ * straight back. A consumer who wants a controlled control to follow the reset
16
+ * listens for the same event.
17
+ *
18
+ * The listener lives on the form the element is in, found from the element
19
+ * rather than passed in, because the consumer owns the markup between them.
20
+ *
21
+ * The `value === undefined` guard in each caller is deliberate and is NOT
22
+ * covered by a test, because it cannot be: with a controlled component the
23
+ * internal state is never read — `value ?? internal` — so writing to it
24
+ * changes nothing observable. It is kept because a component that later reads
25
+ * that state (a consumer switching from controlled to uncontrolled) would
26
+ * otherwise find a value the reset put there behind its back.
27
+ */
28
+ export function useFormReset(ref, onReset) {
29
+ // the handler is read through a ref so a fresh closure each render does not
30
+ // resubscribe: the listener is bound to the form, not to this render.
31
+ const latest = React.useRef(onReset);
32
+ latest.current = onReset;
33
+ React.useEffect(() => {
34
+ const el = ref.current;
35
+ const form = el?.closest("form");
36
+ if (!form)
37
+ return;
38
+ const handler = () => latest.current();
39
+ form.addEventListener("reset", handler);
40
+ return () => form.removeEventListener("reset", handler);
41
+ }, [ref]);
42
+ }
@@ -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
+ }
@@ -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
  }
@@ -8,4 +8,6 @@ export { resolveRecipe } from "./resolve";
8
8
  export * from "./rtl";
9
9
  export type * from "./types";
10
10
  export { useFocusAfterRemoval } from "./use-focus-after-removal";
11
+ export { useFormReset } from "./use-form-reset";
12
+ export { useLabelAssociation } from "./use-label-association";
11
13
  export { usePrefersReducedMotion } from "./use-prefers-reduced-motion";
@@ -0,0 +1,28 @@
1
+ import * as React from "react";
2
+ /**
3
+ * Restore a control's default when the form around it is reset.
4
+ *
5
+ * `form.reset()` restores native controls — crisp's Input and Checkbox come
6
+ * back on their own. The controls that are buttons and listboxes hold their
7
+ * value in React state, which the platform cannot touch, so a Reset button
8
+ * used to snap half a form back and leave the other half as the user set it
9
+ * (#245). The hidden inputs that carry the value to `FormData` do not help:
10
+ * React rewrites them from the same state a moment later.
11
+ *
12
+ * Only the UNCONTROLLED case is restored here. A controlled component's value
13
+ * belongs to the consumer, and React already behaves this way for a controlled
14
+ * `<input>`: the platform resets the DOM, the next render puts the prop
15
+ * straight back. A consumer who wants a controlled control to follow the reset
16
+ * listens for the same event.
17
+ *
18
+ * The listener lives on the form the element is in, found from the element
19
+ * rather than passed in, because the consumer owns the markup between them.
20
+ *
21
+ * The `value === undefined` guard in each caller is deliberate and is NOT
22
+ * covered by a test, because it cannot be: with a controlled component the
23
+ * internal state is never read — `value ?? internal` — so writing to it
24
+ * changes nothing observable. It is kept because a component that later reads
25
+ * that state (a consumer switching from controlled to uncontrolled) would
26
+ * otherwise find a value the reset put there behind its back.
27
+ */
28
+ export declare function useFormReset(ref: React.RefObject<HTMLElement | null>, onReset: () => void): void;
@@ -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.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",