@crisp-ui-kit/crisp 0.30.1 → 0.31.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, name,
68
+ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, required,
69
69
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
70
70
  // LABELABLE element, so an id left on the root div meant a FormField label
71
71
  // above this control named nothing (#247). Select already did this.
@@ -166,7 +166,7 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
166
166
  return () => document.removeEventListener("pointerdown", onDown);
167
167
  }, [open]);
168
168
  return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ className }), ...rest, children: [name &&
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) => {
169
+ (value.length > 0 ? value : required ? [""] : []).map((v) => ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, 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) => {
170
170
  if (open && !searchable)
171
171
  onNavKeys(e);
172
172
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
@@ -47,7 +47,7 @@ const RadioContext = React.createContext(null);
47
47
  * preview cards whose border turns brand when picked (the Theme picker). Arrow
48
48
  * keys move between options (roving focus). Controlled or uncontrolled.
49
49
  */
50
- function RadioGroup({ value, defaultValue, onValueChange, variant = "default", name, className, "aria-label": ariaLabel, children, ...rest }) {
50
+ function RadioGroup({ value, defaultValue, onValueChange, variant = "default", name, required, className, "aria-label": ariaLabel, children, ...rest }) {
51
51
  const [internal, setInternal] = React.useState(defaultValue);
52
52
  const selected = value ?? internal;
53
53
  const generatedName = React.useId();
@@ -68,7 +68,7 @@ function RadioGroup({ value, defaultValue, onValueChange, variant = "default", n
68
68
  if (value === undefined)
69
69
  setInternal(defaultValue);
70
70
  });
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 }))] }) }));
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 || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }))] }) }));
72
72
  }
73
73
  RadioGroup.displayName = "RadioGroup";
74
74
  function Radio({ value, label, children, disabled }) {
@@ -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, name, 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, required, 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
@@ -156,7 +156,10 @@ function Select({ options, value, defaultValue, onValueChange, name, placeholder
156
156
  document.addEventListener("pointerdown", onDown);
157
157
  return () => document.removeEventListener("pointerdown", onDown);
158
158
  }, [open]);
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) => {
159
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, className }), ...rest, children: [name && (selected || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
160
+ // controlled by the listbox above; `readOnly` would bar it from
161
+ // validation, which is the only reason this input is not hidden
162
+ onChange: () => { } })), (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) => {
160
163
  setQuery(e.target.value);
161
164
  setHighlight(0);
162
165
  }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
@@ -44,7 +44,7 @@ const switch_recipe_1 = require("./switch.recipe");
44
44
  * label association and keyboard (Space/Enter) come for free. Controlled via
45
45
  * `checked`/`onCheckedChange`, or uncontrolled from `defaultChecked`.
46
46
  */
47
- exports.Switch = React.forwardRef(({ checked, defaultChecked = false, onCheckedChange, disabled, className, onClick, name, value = "on", ...props }, ref) => {
47
+ exports.Switch = React.forwardRef(({ checked, defaultChecked = false, onCheckedChange, disabled, className, onClick, name, value = "on", required, ...props }, ref) => {
48
48
  const [internal, setInternal] = React.useState(defaultChecked);
49
49
  const on = checked ?? internal;
50
50
  // `form.reset()` restores native controls; a role="switch" button holds its
@@ -63,6 +63,6 @@ exports.Switch = React.forwardRef(({ checked, defaultChecked = false, onCheckedC
63
63
  if (checked === undefined)
64
64
  setInternal((v) => !v);
65
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 })] }));
66
+ }, ...props, children: (0, jsx_runtime_1.jsx)("span", { className: "switch-thumb" }) }), name && (on || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, type: "checkbox", name: name, value: value, checked: on, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }))] }));
67
67
  });
68
68
  exports.Switch.displayName = "Switch";
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formValueStyle = void 0;
4
+ /**
5
+ * The style for the input that carries a non-native control's value into the
6
+ * form.
7
+ *
8
+ * It cannot be `type="hidden"`: hidden inputs are *barred from constraint
9
+ * validation*, so `required` on one is ignored and the form submits with the
10
+ * field empty (#253). A laid-out but invisible input validates normally — the
11
+ * browser blocks the submit, focuses it and shows its own message beside the
12
+ * control.
13
+ *
14
+ * `opacity: 0` rather than `display: none` or `visibility: hidden`, both of
15
+ * which bar it again. Inline rather than a recipe rule because Switch renders
16
+ * its input as a SIBLING of the button that is its root: a nested `& .class`
17
+ * rule never matches there, and the first version of this shipped a visible
18
+ * 13px checkbox next to every switch — caught in a browser, not by a test.
19
+ */
20
+ exports.formValueStyle = {
21
+ position: "absolute",
22
+ width: 1,
23
+ height: 1,
24
+ opacity: 0,
25
+ margin: 0,
26
+ padding: 0,
27
+ border: 0,
28
+ pointerEvents: "none",
29
+ };
@@ -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.useLabelAssociation = 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.formValueStyle = 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");
@@ -23,6 +23,8 @@ var dialog_description_1 = require("./dialog-description");
23
23
  Object.defineProperty(exports, "withDescribedBy", { enumerable: true, get: function () { return dialog_description_1.withDescribedBy; } });
24
24
  var direction_1 = require("./direction");
25
25
  Object.defineProperty(exports, "isRtlElement", { enumerable: true, get: function () { return direction_1.isRtlElement; } });
26
+ var form_value_1 = require("./form-value");
27
+ Object.defineProperty(exports, "formValueStyle", { enumerable: true, get: function () { return form_value_1.formValueStyle; } });
26
28
  var place_floating_1 = require("./place-floating");
27
29
  Object.defineProperty(exports, "placeFloating", { enumerable: true, get: function () { return place_floating_1.placeFloating; } });
28
30
  var primitive_1 = require("./primitive");
@@ -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, useFormReset } from "../../core/index.js";
4
+ import { formValueStyle, 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, name,
32
+ export function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, required,
33
33
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
34
34
  // LABELABLE element, so an id left on the root div meant a FormField label
35
35
  // above this control named nothing (#247). Select already did this.
@@ -130,7 +130,7 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
130
130
  return () => document.removeEventListener("pointerdown", onDown);
131
131
  }, [open]);
132
132
  return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ className }), ...rest, children: [name &&
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) => {
133
+ (value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, 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) => {
134
134
  if (open && !searchable)
135
135
  onNavKeys(e);
136
136
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
@@ -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, useLabelAssociation } from "../../core/index.js";
3
+ import { formValueStyle, 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);
@@ -10,7 +10,7 @@ const RadioContext = React.createContext(null);
10
10
  * preview cards whose border turns brand when picked (the Theme picker). Arrow
11
11
  * keys move between options (roving focus). Controlled or uncontrolled.
12
12
  */
13
- export function RadioGroup({ value, defaultValue, onValueChange, variant = "default", name, className, "aria-label": ariaLabel, children, ...rest }) {
13
+ export function RadioGroup({ value, defaultValue, onValueChange, variant = "default", name, required, className, "aria-label": ariaLabel, children, ...rest }) {
14
14
  const [internal, setInternal] = React.useState(defaultValue);
15
15
  const selected = value ?? internal;
16
16
  const generatedName = React.useId();
@@ -31,7 +31,7 @@ export function RadioGroup({ value, defaultValue, onValueChange, variant = "defa
31
31
  if (value === undefined)
32
32
  setInternal(defaultValue);
33
33
  });
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 }))] }) }));
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 || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }))] }) }));
35
35
  }
36
36
  RadioGroup.displayName = "RadioGroup";
37
37
  export function Radio({ value, label, children, disabled }) {
@@ -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, useFormReset } from "../../core/index.js";
4
+ import { formValueStyle, 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, name, 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, required, 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
@@ -120,7 +120,10 @@ export function Select({ options, value, defaultValue, onValueChange, name, plac
120
120
  document.addEventListener("pointerdown", onDown);
121
121
  return () => document.removeEventListener("pointerdown", onDown);
122
122
  }, [open]);
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) => {
123
+ return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
124
+ // controlled by the listbox above; `readOnly` would bar it from
125
+ // validation, which is the only reason this input is not hidden
126
+ onChange: () => { } })), _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) => {
124
127
  setQuery(e.target.value);
125
128
  setHighlight(0);
126
129
  }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
@@ -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 { formValueStyle, useFormReset } from "../../core/index.js";
4
4
  import { switchRecipe } from "./switch.recipe.js";
5
5
  /**
6
6
  * An on/off toggle — the settings switch, measured from Attio. Renders a
@@ -8,7 +8,7 @@ import { switchRecipe } from "./switch.recipe.js";
8
8
  * label association and keyboard (Space/Enter) come for free. Controlled via
9
9
  * `checked`/`onCheckedChange`, or uncontrolled from `defaultChecked`.
10
10
  */
11
- export const Switch = React.forwardRef(({ checked, defaultChecked = false, onCheckedChange, disabled, className, onClick, name, value = "on", ...props }, ref) => {
11
+ export const Switch = React.forwardRef(({ checked, defaultChecked = false, onCheckedChange, disabled, className, onClick, name, value = "on", required, ...props }, ref) => {
12
12
  const [internal, setInternal] = React.useState(defaultChecked);
13
13
  const on = checked ?? internal;
14
14
  // `form.reset()` restores native controls; a role="switch" button holds its
@@ -27,6 +27,6 @@ export const Switch = React.forwardRef(({ checked, defaultChecked = false, onChe
27
27
  if (checked === undefined)
28
28
  setInternal((v) => !v);
29
29
  onCheckedChange?.(!on);
30
- }, ...props, children: _jsx("span", { className: "switch-thumb" }) }), name && on && _jsx("input", { type: "hidden", name: name, value: value })] }));
30
+ }, ...props, children: _jsx("span", { className: "switch-thumb" }) }), name && (on || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, type: "checkbox", name: name, value: value, checked: on, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }))] }));
31
31
  });
32
32
  Switch.displayName = "Switch";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The style for the input that carries a non-native control's value into the
3
+ * form.
4
+ *
5
+ * It cannot be `type="hidden"`: hidden inputs are *barred from constraint
6
+ * validation*, so `required` on one is ignored and the form submits with the
7
+ * field empty (#253). A laid-out but invisible input validates normally — the
8
+ * browser blocks the submit, focuses it and shows its own message beside the
9
+ * control.
10
+ *
11
+ * `opacity: 0` rather than `display: none` or `visibility: hidden`, both of
12
+ * which bar it again. Inline rather than a recipe rule because Switch renders
13
+ * its input as a SIBLING of the button that is its root: a nested `& .class`
14
+ * rule never matches there, and the first version of this shipped a visible
15
+ * 13px checkbox next to every switch — caught in a browser, not by a test.
16
+ */
17
+ export const formValueStyle = {
18
+ position: "absolute",
19
+ width: 1,
20
+ height: 1,
21
+ opacity: 0,
22
+ margin: 0,
23
+ padding: 0,
24
+ border: 0,
25
+ pointerEvents: "none",
26
+ };
@@ -2,6 +2,7 @@ export { cx } from "./cx.js";
2
2
  export { defineRecipe } from "./define.js";
3
3
  export { withDescribedBy } from "./dialog-description.js";
4
4
  export { isRtlElement } from "./direction.js";
5
+ export { formValueStyle } from "./form-value.js";
5
6
  export { placeFloating, } from "./place-floating.js";
6
7
  export { Primitive } from "./primitive.js";
7
8
  export { resolveRecipe } from "./resolve.js";
@@ -18,6 +18,16 @@ export type MultiSelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "valu
18
18
  * nothing to FormData (#243).
19
19
  */
20
20
  name?: string;
21
+ /**
22
+ * Blocks the form until the field is answered, using the browser's own
23
+ * validation: the submit is stopped, the control is focused and the
24
+ * browser's message is shown.
25
+ *
26
+ * Needs `name`, because the value rides on a real input — a `type="hidden"`
27
+ * one is barred from constraint validation and ignores `required`
28
+ * entirely (#253).
29
+ */
30
+ required?: boolean;
21
31
  /** Fires with the next value on every toggle, controlled or not. */
22
32
  onValueChange?: (value: string[]) => void;
23
33
  placeholder?: string;
@@ -36,7 +46,7 @@ export type MultiSelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "valu
36
46
  * popover with a search field over rows of "[chip] … [checkbox]" that toggle on
37
47
  * click (the menu stays open). Keyboard: ↑/↓ move, Enter toggles, Esc closes.
38
48
  */
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;
49
+ export declare function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, required, id, placeholder, searchable, searchPlaceholder, emptyLabel, disabled, className, "aria-label": ariaLabel, ...rest }: MultiSelectProps): React.JSX.Element;
40
50
  export declare namespace MultiSelect {
41
51
  var displayName: string;
42
52
  }
@@ -13,6 +13,16 @@ export type RadioGroupProps = Omit<React.ComponentPropsWithoutRef<"div">, "value
13
13
  * (#243). The value now rides on a hidden input.
14
14
  */
15
15
  name?: string;
16
+ /**
17
+ * Blocks the form until the field is answered, using the browser's own
18
+ * validation: the submit is stopped, the control is focused and the
19
+ * browser's message is shown.
20
+ *
21
+ * Needs `name`, because the value rides on a real input — a `type="hidden"`
22
+ * one is barred from constraint validation and ignores `required`
23
+ * entirely (#253).
24
+ */
25
+ required?: boolean;
16
26
  className?: string;
17
27
  "aria-label"?: string;
18
28
  children: React.ReactNode;
@@ -23,7 +33,7 @@ export type RadioGroupProps = Omit<React.ComponentPropsWithoutRef<"div">, "value
23
33
  * preview cards whose border turns brand when picked (the Theme picker). Arrow
24
34
  * keys move between options (roving focus). Controlled or uncontrolled.
25
35
  */
26
- export declare function RadioGroup({ value, defaultValue, onValueChange, variant, name, className, "aria-label": ariaLabel, children, ...rest }: RadioGroupProps): React.JSX.Element;
36
+ export declare function RadioGroup({ value, defaultValue, onValueChange, variant, name, required, className, "aria-label": ariaLabel, children, ...rest }: RadioGroupProps): React.JSX.Element;
27
37
  export declare namespace RadioGroup {
28
38
  var displayName: string;
29
39
  }
@@ -21,6 +21,16 @@ export type SelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "value" |
21
21
  * nothing to FormData (#243).
22
22
  */
23
23
  name?: string;
24
+ /**
25
+ * Blocks the form until the field is answered, using the browser's own
26
+ * validation: the submit is stopped, the control is focused and the
27
+ * browser's message is shown.
28
+ *
29
+ * Needs `name`, because the value rides on a real input — a `type="hidden"`
30
+ * one is barred from constraint validation and ignores `required`
31
+ * entirely (#253).
32
+ */
33
+ required?: boolean;
24
34
  placeholder?: string;
25
35
  /** `ghost` is borderless until hover — for inline selects (e.g. sort rows). */
26
36
  variant?: "default" | "ghost";
@@ -43,7 +53,7 @@ export type SelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "value" |
43
53
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
44
54
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
45
55
  */
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;
56
+ export declare function Select({ options, value, defaultValue, onValueChange, name, required, placeholder, variant, width, searchable, searchPlaceholder, emptyLabel, disabled, className, "aria-label": ariaLabel, id, ...rest }: SelectProps): React.JSX.Element;
47
57
  export declare namespace Select {
48
58
  var displayName: string;
49
59
  }
@@ -13,6 +13,16 @@ export type SwitchProps = Omit<React.ComponentPropsWithoutRef<"button">, "onChan
13
13
  name?: string;
14
14
  /** The value submitted when it is on (default "on", as a checkbox does). */
15
15
  value?: string;
16
+ /**
17
+ * Blocks the form until the field is answered, using the browser's own
18
+ * validation: the submit is stopped, the control is focused and the
19
+ * browser's message is shown.
20
+ *
21
+ * Needs `name`, because the value rides on a real input — a `type="hidden"`
22
+ * one is barred from constraint validation and ignores `required`
23
+ * entirely (#253).
24
+ */
25
+ required?: boolean;
16
26
  };
17
27
  /**
18
28
  * An on/off toggle — the settings switch, measured from Attio. Renders a
@@ -34,4 +44,14 @@ export declare const Switch: React.ForwardRefExoticComponent<Omit<Omit<React.Det
34
44
  name?: string;
35
45
  /** The value submitted when it is on (default "on", as a checkbox does). */
36
46
  value?: string;
47
+ /**
48
+ * Blocks the form until the field is answered, using the browser's own
49
+ * validation: the submit is stopped, the control is focused and the
50
+ * browser's message is shown.
51
+ *
52
+ * Needs `name`, because the value rides on a real input — a `type="hidden"`
53
+ * one is barred from constraint validation and ignores `required`
54
+ * entirely (#253).
55
+ */
56
+ required?: boolean;
37
57
  } & React.RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,18 @@
1
+ import type * as React from "react";
2
+ /**
3
+ * The style for the input that carries a non-native control's value into the
4
+ * form.
5
+ *
6
+ * It cannot be `type="hidden"`: hidden inputs are *barred from constraint
7
+ * validation*, so `required` on one is ignored and the form submits with the
8
+ * field empty (#253). A laid-out but invisible input validates normally — the
9
+ * browser blocks the submit, focuses it and shows its own message beside the
10
+ * control.
11
+ *
12
+ * `opacity: 0` rather than `display: none` or `visibility: hidden`, both of
13
+ * which bar it again. Inline rather than a recipe rule because Switch renders
14
+ * its input as a SIBLING of the button that is its root: a nested `& .class`
15
+ * rule never matches there, and the first version of this shipped a visible
16
+ * 13px checkbox next to every switch — caught in a browser, not by a test.
17
+ */
18
+ export declare const formValueStyle: React.CSSProperties;
@@ -2,6 +2,7 @@ export { cx } from "./cx";
2
2
  export { defineRecipe } from "./define";
3
3
  export { withDescribedBy } from "./dialog-description";
4
4
  export { isRtlElement } from "./direction";
5
+ export { formValueStyle } from "./form-value";
5
6
  export { type FloatingAlign, type PlaceFloatingOptions, placeFloating, } from "./place-floating";
6
7
  export { Primitive } from "./primitive";
7
8
  export { resolveRecipe } from "./resolve";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",