@crisp-ui-kit/crisp 0.26.1 → 0.28.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,7 @@ 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, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
68
+ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
69
69
  // Virtual focus: DOM focus stays on the trigger (or the search field) and the
70
70
  // active option is named by `aria-activedescendant`. `data-highlighted` is a
71
71
  // styling hook only -- a screen reader cannot see it.
@@ -78,6 +78,13 @@ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, p
78
78
  const [query, setQuery] = React.useState("");
79
79
  const [highlight, setHighlight] = React.useState(0);
80
80
  const rootRef = React.useRef(null);
81
+ // `form.reset()` restores native controls; a button + listbox holds its value
82
+ // in React, so it has to listen (#245). Controlled multiselects are left
83
+ // alone.
84
+ (0, core_1.useFormReset)(rootRef, () => {
85
+ if (valueProp === undefined)
86
+ setInternalValue(defaultValue ?? []);
87
+ });
81
88
  const triggerRef = React.useRef(null);
82
89
  const searchRef = React.useRef(null);
83
90
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? []);
@@ -154,7 +161,8 @@ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, p
154
161
  document.addEventListener("pointerdown", onDown);
155
162
  return () => document.removeEventListener("pointerdown", onDown);
156
163
  }, [open]);
157
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ className }), ...rest, children: [(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) => {
164
+ 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) => {
158
166
  if (open && !searchable)
159
167
  onNavKeys(e);
160
168
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
@@ -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,7 +58,14 @@ 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 });
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
+ // `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
+ (0, core_1.useFormReset)(groupRef, () => {
65
+ if (value === undefined)
66
+ setInternal(defaultValue);
67
+ });
68
+ 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
69
  }
62
70
  RadioGroup.displayName = "RadioGroup";
63
71
  function Radio({ value, label, children, disabled }) {
@@ -65,7 +73,7 @@ function Radio({ value, label, children, disabled }) {
65
73
  if (!ctx)
66
74
  throw new Error("Radio must be used within a RadioGroup");
67
75
  const checked = ctx.value === value;
68
- return ((0, jsx_runtime_1.jsx)("button", { type: "button", role: "radio", "aria-checked": checked, name: ctx.name, disabled: disabled, "data-value": value, "data-checked": checked ? "" : undefined,
76
+ return ((0, jsx_runtime_1.jsx)("button", { type: "button", role: "radio", "aria-checked": checked, disabled: disabled, "data-value": value, "data-checked": checked ? "" : undefined,
69
77
  // roving tabindex: the selected (or first, when none) radio is tabbable
70
78
  tabIndex: checked ? 0 : -1, className: "radio", onClick: () => ctx.select(value), children: ctx.variant === "card" ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "radio-preview", children: children }), (0, jsx_runtime_1.jsx)("span", { className: "radio-cardfoot", children: (0, jsx_runtime_1.jsx)("span", { className: "radio-label", children: label }) })] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "radio-dot" }), (0, jsx_runtime_1.jsx)("span", { className: "radio-label", children: label ?? children })] })) }));
71
79
  }
@@ -52,7 +52,7 @@ function optionText(o) {
52
52
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
53
53
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
54
54
  */
55
- function Select({ options, value, defaultValue, onValueChange, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, id, ...rest }) {
55
+ function Select({ options, value, defaultValue, onValueChange, name, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, id, ...rest }) {
56
56
  // Virtual focus: DOM focus stays on the trigger (or the search field), and the
57
57
  // active option is named by `aria-activedescendant`. That is the only thing a
58
58
  // screen reader has to go on -- `data-highlighted` is invisible to it -- so the
@@ -69,6 +69,12 @@ function Select({ options, value, defaultValue, onValueChange, placeholder = "Se
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);
@@ -150,7 +156,7 @@ function Select({ options, value, defaultValue, onValueChange, placeholder = "Se
150
156
  document.addEventListener("pointerdown", onDown);
151
157
  return () => document.removeEventListener("pointerdown", onDown);
152
158
  }, [open]);
153
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, className }), ...rest, children: [(0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", id: id, 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: "select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [(0, jsx_runtime_1.jsx)("span", { className: "select-value", children: selectedOption ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedOption.color && ((0, jsx_runtime_1.jsx)("span", { className: "select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : ((0, jsx_runtime_1.jsx)("span", { className: "select-placeholder", children: placeholder })) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "select-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsxs)("div", { id: listboxId, className: "select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "select-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
159
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, className }), ...rest, children: [name && selected && (0, jsx_runtime_1.jsx)("input", { type: "hidden", name: name, value: selected }), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", id: id, 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: "select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [(0, jsx_runtime_1.jsx)("span", { className: "select-value", children: selectedOption ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedOption.color && ((0, jsx_runtime_1.jsx)("span", { className: "select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : ((0, jsx_runtime_1.jsx)("span", { className: "select-placeholder", children: placeholder })) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "select-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsxs)("div", { id: listboxId, className: "select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "select-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
154
160
  setQuery(e.target.value);
155
161
  setHighlight(0);
156
162
  }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
@@ -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
@@ -43,16 +44,25 @@ const switch_recipe_1 = require("./switch.recipe");
43
44
  * label association and keyboard (Space/Enter) come for free. Controlled via
44
45
  * `checked`/`onCheckedChange`, or uncontrolled from `defaultChecked`.
45
46
  */
46
- exports.Switch = React.forwardRef(({ checked, defaultChecked = false, onCheckedChange, disabled, className, onClick, ...props }, ref) => {
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.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
- onClick?.(e);
51
- if (e.defaultPrevented)
52
- return;
53
- if (checked === undefined)
54
- setInternal((v) => !v);
55
- onCheckedChange?.(!on);
56
- }, ...props, children: (0, jsx_runtime_1.jsx)("span", { className: "switch-thumb" }) }));
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) => {
60
+ onClick?.(e);
61
+ if (e.defaultPrevented)
62
+ return;
63
+ if (checked === undefined)
64
+ setInternal((v) => !v);
65
+ onCheckedChange?.(!on);
66
+ }, ...props, children: (0, jsx_runtime_1.jsx)("span", { className: "switch-thumb" }) }), name && on && (0, jsx_runtime_1.jsx)("input", { type: "hidden", name: name, value: value })] }));
57
67
  });
58
68
  exports.Switch.displayName = "Switch";
@@ -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.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,7 @@ 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; } });
35
37
  var use_prefers_reduced_motion_1 = require("./use-prefers-reduced-motion");
36
38
  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
+ }
@@ -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,7 @@ 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, 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, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
33
33
  // Virtual focus: DOM focus stays on the trigger (or the search field) and the
34
34
  // active option is named by `aria-activedescendant`. `data-highlighted` is a
35
35
  // styling hook only -- a screen reader cannot see it.
@@ -42,6 +42,13 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
42
42
  const [query, setQuery] = React.useState("");
43
43
  const [highlight, setHighlight] = React.useState(0);
44
44
  const rootRef = React.useRef(null);
45
+ // `form.reset()` restores native controls; a button + listbox holds its value
46
+ // in React, so it has to listen (#245). Controlled multiselects are left
47
+ // alone.
48
+ useFormReset(rootRef, () => {
49
+ if (valueProp === undefined)
50
+ setInternalValue(defaultValue ?? []);
51
+ });
45
52
  const triggerRef = React.useRef(null);
46
53
  const searchRef = React.useRef(null);
47
54
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? []);
@@ -118,7 +125,8 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
118
125
  document.addEventListener("pointerdown", onDown);
119
126
  return () => document.removeEventListener("pointerdown", onDown);
120
127
  }, [open]);
121
- return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ className }), ...rest, children: [_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) => {
128
+ 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) => {
122
130
  if (open && !searchable)
123
131
  onNavKeys(e);
124
132
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
@@ -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 { radiogroupRecipe } from "./radiogroup.recipe.js";
4
5
  import { useRadioGroupKeyboard } from "./radiogroup-keyboard.js";
5
6
  const RadioContext = React.createContext(null);
@@ -20,7 +21,14 @@ export function RadioGroup({ value, defaultValue, onValueChange, variant = "defa
20
21
  onValueChange?.(v);
21
22
  }, [value, onValueChange]);
22
23
  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
+ // `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
+ useFormReset(groupRef, () => {
28
+ if (value === undefined)
29
+ setInternal(defaultValue);
30
+ });
31
+ 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
32
  }
25
33
  RadioGroup.displayName = "RadioGroup";
26
34
  export function Radio({ value, label, children, disabled }) {
@@ -28,7 +36,7 @@ export function Radio({ value, label, children, disabled }) {
28
36
  if (!ctx)
29
37
  throw new Error("Radio must be used within a RadioGroup");
30
38
  const checked = ctx.value === value;
31
- return (_jsx("button", { type: "button", role: "radio", "aria-checked": checked, name: ctx.name, disabled: disabled, "data-value": value, "data-checked": checked ? "" : undefined,
39
+ return (_jsx("button", { type: "button", role: "radio", "aria-checked": checked, disabled: disabled, "data-value": value, "data-checked": checked ? "" : undefined,
32
40
  // roving tabindex: the selected (or first, when none) radio is tabbable
33
41
  tabIndex: checked ? 0 : -1, className: "radio", onClick: () => ctx.select(value), children: ctx.variant === "card" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "radio-preview", children: children }), _jsx("span", { className: "radio-cardfoot", children: _jsx("span", { className: "radio-label", children: label }) })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "radio-dot" }), _jsx("span", { className: "radio-label", children: label ?? children })] })) }));
34
42
  }
@@ -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) {
@@ -16,7 +16,7 @@ function optionText(o) {
16
16
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
17
17
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
18
18
  */
19
- export function Select({ options, value, defaultValue, onValueChange, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, id, ...rest }) {
19
+ export function Select({ options, value, defaultValue, onValueChange, name, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, id, ...rest }) {
20
20
  // Virtual focus: DOM focus stays on the trigger (or the search field), and the
21
21
  // active option is named by `aria-activedescendant`. That is the only thing a
22
22
  // screen reader has to go on -- `data-highlighted` is invisible to it -- so the
@@ -33,6 +33,12 @@ export function Select({ options, value, defaultValue, onValueChange, placeholde
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);
@@ -114,7 +120,7 @@ export function Select({ options, value, defaultValue, onValueChange, placeholde
114
120
  document.addEventListener("pointerdown", onDown);
115
121
  return () => document.removeEventListener("pointerdown", onDown);
116
122
  }, [open]);
117
- return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, className }), ...rest, children: [_jsxs("button", { ref: triggerRef, type: "button", id: id, 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: "select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsxs("div", { id: listboxId, className: "select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
123
+ return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, className }), ...rest, children: [name && selected && _jsx("input", { type: "hidden", name: name, value: selected }), _jsxs("button", { ref: triggerRef, type: "button", id: id, 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: "select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsxs("div", { id: listboxId, className: "select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
118
124
  setQuery(e.target.value);
119
125
  setHighlight(0);
120
126
  }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
@@ -1,5 +1,6 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
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
@@ -7,16 +8,25 @@ import { switchRecipe } from "./switch.recipe.js";
7
8
  * label association and keyboard (Space/Enter) come for free. Controlled via
8
9
  * `checked`/`onCheckedChange`, or uncontrolled from `defaultChecked`.
9
10
  */
10
- export const Switch = React.forwardRef(({ checked, defaultChecked = false, onCheckedChange, disabled, className, onClick, ...props }, ref) => {
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 (_jsx("button", { ref: ref, type: "button", role: "switch", "aria-checked": on, disabled: disabled, "data-checked": on ? "" : undefined, className: switchRecipe({ className }), onClick: (e) => {
14
- onClick?.(e);
15
- if (e.defaultPrevented)
16
- return;
17
- if (checked === undefined)
18
- setInternal((v) => !v);
19
- onCheckedChange?.(!on);
20
- }, ...props, children: _jsx("span", { className: "switch-thumb" }) }));
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) => {
24
+ onClick?.(e);
25
+ if (e.defaultPrevented)
26
+ return;
27
+ if (checked === undefined)
28
+ setInternal((v) => !v);
29
+ onCheckedChange?.(!on);
30
+ }, ...props, children: _jsx("span", { className: "switch-thumb" }) }), name && on && _jsx("input", { type: "hidden", name: name, value: value })] }));
21
31
  });
22
32
  Switch.displayName = "Switch";
@@ -7,4 +7,5 @@ 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";
10
11
  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
+ }
@@ -11,6 +11,13 @@ export type MultiSelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "valu
11
11
  value?: string[];
12
12
  /** Initially selected values, when uncontrolled. */
13
13
  defaultValue?: string[];
14
+ /**
15
+ * Submits with the form under this name. crisp's MultiSelect is a
16
+ * a button and a listbox, not a native control, so the value reaches a `<form>` only through a
17
+ * hidden input — without one, `name` type-checks, renders, and contributes
18
+ * nothing to FormData (#243).
19
+ */
20
+ name?: string;
14
21
  /** Fires with the next value on every toggle, controlled or not. */
15
22
  onValueChange?: (value: string[]) => void;
16
23
  placeholder?: string;
@@ -29,7 +36,7 @@ export type MultiSelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "valu
29
36
  * popover with a search field over rows of "[chip] … [checkbox]" that toggle on
30
37
  * click (the menu stays open). Keyboard: ↑/↓ move, Enter toggles, Esc closes.
31
38
  */
32
- export declare function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, 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, placeholder, searchable, searchPlaceholder, emptyLabel, disabled, className, "aria-label": ariaLabel, ...rest }: MultiSelectProps): React.JSX.Element;
33
40
  export declare namespace MultiSelect {
34
41
  var displayName: string;
35
42
  }
@@ -6,6 +6,12 @@ export type RadioGroupProps = Omit<React.ComponentPropsWithoutRef<"div">, "value
6
6
  onValueChange?: (value: string) => void;
7
7
  /** `default` = rows (dot + label); `card` = bordered preview cards. */
8
8
  variant?: "default" | "card";
9
+ /**
10
+ * Submits with the form under this name. It used to land on each radio
11
+ * `<button>`, where a name means nothing unless the button submits the form:
12
+ * the prop type-checked, rendered, and contributed nothing to FormData
13
+ * (#243). The value now rides on a hidden input.
14
+ */
9
15
  name?: string;
10
16
  className?: string;
11
17
  "aria-label"?: string;
@@ -14,6 +14,13 @@ export type SelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "value" |
14
14
  value?: string;
15
15
  defaultValue?: string;
16
16
  onValueChange?: (value: string) => void;
17
+ /**
18
+ * Submits with the form under this name. crisp's Select is a
19
+ * a button and a listbox, not a native control, so the value reaches a `<form>` only through a
20
+ * hidden input — without one, `name` type-checks, renders, and contributes
21
+ * nothing to FormData (#243).
22
+ */
23
+ name?: string;
17
24
  placeholder?: string;
18
25
  /** `ghost` is borderless until hover — for inline selects (e.g. sort rows). */
19
26
  variant?: "default" | "ghost";
@@ -36,7 +43,7 @@ export type SelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "value" |
36
43
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
37
44
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
38
45
  */
39
- export declare function Select({ options, value, defaultValue, onValueChange, placeholder, variant, width, searchable, searchPlaceholder, emptyLabel, disabled, className, "aria-label": ariaLabel, id, ...rest }: SelectProps): React.JSX.Element;
46
+ export declare function Select({ options, value, defaultValue, onValueChange, name, placeholder, variant, width, searchable, searchPlaceholder, emptyLabel, disabled, className, "aria-label": ariaLabel, id, ...rest }: SelectProps): React.JSX.Element;
40
47
  export declare namespace Select {
41
48
  var displayName: string;
42
49
  }
@@ -4,6 +4,15 @@ export type SwitchProps = Omit<React.ComponentPropsWithoutRef<"button">, "onChan
4
4
  checked?: boolean;
5
5
  defaultChecked?: boolean;
6
6
  onCheckedChange?: (checked: boolean) => void;
7
+ /**
8
+ * Submits with the form under this name. crisp's Switch is a
9
+ * `role="switch"` button`, not a native control, so the value reaches a `<form>` only
10
+ * through a hidden input — without one, `name` type-checks, renders, and
11
+ * contributes nothing to FormData (#243).
12
+ */
13
+ name?: string;
14
+ /** The value submitted when it is on (default "on", as a checkbox does). */
15
+ value?: string;
7
16
  };
8
17
  /**
9
18
  * An on/off toggle — the settings switch, measured from Attio. Renders a
@@ -16,4 +25,13 @@ export declare const Switch: React.ForwardRefExoticComponent<Omit<Omit<React.Det
16
25
  checked?: boolean;
17
26
  defaultChecked?: boolean;
18
27
  onCheckedChange?: (checked: boolean) => void;
28
+ /**
29
+ * Submits with the form under this name. crisp's Switch is a
30
+ * `role="switch"` button`, not a native control, so the value reaches a `<form>` only
31
+ * through a hidden input — without one, `name` type-checks, renders, and
32
+ * contributes nothing to FormData (#243).
33
+ */
34
+ name?: string;
35
+ /** The value submitted when it is on (default "on", as a checkbox does). */
36
+ value?: string;
19
37
  } & React.RefAttributes<HTMLButtonElement>>;
@@ -8,4 +8,5 @@ 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";
11
12
  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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.26.1",
3
+ "version": "0.28.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",