@crisp-ui-kit/crisp 0.30.1 → 0.31.1

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.
Files changed (29) hide show
  1. package/dist/cjs/components/multiselect/multiselect.js +2 -2
  2. package/dist/cjs/components/radiogroup/radiogroup-keyboard.js +6 -1
  3. package/dist/cjs/components/radiogroup/radiogroup.js +2 -2
  4. package/dist/cjs/components/select/select.js +5 -2
  5. package/dist/cjs/components/switch/switch.js +2 -2
  6. package/dist/cjs/components/tabs/tabs-keyboard.js +9 -2
  7. package/dist/cjs/core/form-value.js +29 -0
  8. package/dist/cjs/core/index.js +3 -1
  9. package/dist/cjs/product/board/board.js +7 -5
  10. package/dist/cjs/product/recordpage/recordpage.js +9 -4
  11. package/dist/cjs/product/recordstable/recordstable.js +9 -4
  12. package/dist/esm/components/multiselect/multiselect.js +3 -3
  13. package/dist/esm/components/radiogroup/radiogroup-keyboard.js +6 -1
  14. package/dist/esm/components/radiogroup/radiogroup.js +3 -3
  15. package/dist/esm/components/select/select.js +6 -3
  16. package/dist/esm/components/switch/switch.js +3 -3
  17. package/dist/esm/components/tabs/tabs-keyboard.js +9 -2
  18. package/dist/esm/core/form-value.js +26 -0
  19. package/dist/esm/core/index.js +1 -0
  20. package/dist/esm/product/board/board.js +7 -5
  21. package/dist/esm/product/recordpage/recordpage.js +9 -4
  22. package/dist/esm/product/recordstable/recordstable.js +9 -4
  23. package/dist/types/components/multiselect/multiselect.d.ts +11 -1
  24. package/dist/types/components/radiogroup/radiogroup.d.ts +11 -1
  25. package/dist/types/components/select/select.d.ts +11 -1
  26. package/dist/types/components/switch/switch.d.ts +20 -0
  27. package/dist/types/core/form-value.d.ts +18 -0
  28. package/dist/types/core/index.d.ts +1 -0
  29. package/package.json +1 -1
@@ -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")) {
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.useRadioGroupKeyboard = useRadioGroupKeyboard;
37
37
  const React = __importStar(require("react"));
38
+ const core_1 = require("../../core");
38
39
  /**
39
40
  * The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
40
41
  * previous enabled radio, wrapping, and check it on arrival. Home/End jump to
@@ -113,7 +114,11 @@ function useRadioGroupKeyboard({ select, groupRef: externalRef, }) {
113
114
  else if (e.key === "End")
114
115
  next = radios[radios.length - 1];
115
116
  else {
116
- const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
117
+ // Down always means forward; the horizontal pair follows the direction
118
+ // the radios are drawn in, which for Segmented's pill row is the whole
119
+ // point (#255).
120
+ const rtl = (0, core_1.isRtlElement)(e.currentTarget);
121
+ const fwd = e.key === "ArrowDown" || e.key === (rtl ? "ArrowLeft" : "ArrowRight");
117
122
  // focus outside the group: enter at the near end rather than stepping
118
123
  // off an index of -1, which would land two from the back going backwards
119
124
  if (idx < 0)
@@ -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";
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.useTablistKeyboard = useTablistKeyboard;
4
+ const core_1 = require("../../core");
4
5
  /**
5
6
  * The WAI-ARIA tablist keyboard model: ←/→ to move (wrapping), Home/End to
6
7
  * jump, disabled tabs skipped, focus following selection.
@@ -20,9 +21,15 @@ function useTablistKeyboard({ tabs, active, select, listRef, }) {
20
21
  return;
21
22
  const idx = enabled.findIndex((t) => t.value === active);
22
23
  let next = -1;
23
- if (e.key === "ArrowRight")
24
+ // The arrows follow the strip as it is drawn: under RTL the next tab is to
25
+ // the LEFT, so the keys swap. Everything else about the model is unchanged
26
+ // (#255).
27
+ const rtl = (0, core_1.isRtlElement)(e.currentTarget);
28
+ const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
29
+ const backKey = rtl ? "ArrowRight" : "ArrowLeft";
30
+ if (e.key === forwardKey)
24
31
  next = (idx + 1) % enabled.length;
25
- else if (e.key === "ArrowLeft")
32
+ else if (e.key === backKey)
26
33
  next = (idx - 1 + enabled.length) % enabled.length;
27
34
  else if (e.key === "Home")
28
35
  next = 0;
@@ -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");
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Board = Board;
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 board_recipe_1 = require("./board.recipe");
40
41
  /**
41
42
  * A kanban board with native drag-and-drop between columns. Cards are
@@ -108,11 +109,12 @@ function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLa
108
109
  }, onKeyDown: (e) => {
109
110
  if (!(e.ctrlKey || e.metaKey))
110
111
  return;
111
- const delta = e.key === "ArrowLeft"
112
- ? -1
113
- : e.key === "ArrowRight"
114
- ? 1
115
- : 0;
112
+ // the columns are a flex row, so under RTL the next one
113
+ // is to the LEFT and the keys swap (#255)
114
+ const rtl = (0, core_1.isRtlElement)(e.currentTarget);
115
+ const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
116
+ const backKey = rtl ? "ArrowRight" : "ArrowLeft";
117
+ const delta = e.key === backKey ? -1 : e.key === forwardKey ? 1 : 0;
116
118
  // only swallow the key when the move actually happens, so
117
119
  // Ctrl+Right at the last column still reaches the browser
118
120
  if (delta && moveByKey(card, delta))
@@ -414,10 +414,15 @@ function RecordPage({ record, columns, collection = "Records", labels, onBack, o
414
414
  if (!next)
415
415
  setFieldQuery("");
416
416
  }, children: showAllValues ? t.viewLess : t.viewAllValues }))] })) : ((0, jsx_runtime_1.jsxs)("div", { className: "recordpage-noresults", children: ["No fields match \u201C", fieldQuery, "\u201D."] })))] }), (0, jsx_runtime_1.jsxs)("div", { className: "recordpage-section", children: [(0, jsx_runtime_1.jsx)(SectionHead, { title: t.tabs.lists, action: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "recordpage-addbtn", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), (0, jsx_runtime_1.jsx)("div", { className: "recordpage-emptystate", children: t.noLists })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": 300, "aria-valuemax": 560, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
417
- if (e.key === "ArrowLeft")
418
- setDetailsW((w) => Math.max(300, w - 16));
419
- else if (e.key === "ArrowRight")
420
- setDetailsW((w) => Math.min(560, w + 16));
417
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
418
+ return;
419
+ // The pointer drag learned its direction in #212; this did not.
420
+ // The details column is the flex row's first child, so it sits on
421
+ // the RIGHT under RTL and ArrowLeft has to grow it — the same
422
+ // half-conversion the drag had, one handler away (#255).
423
+ const rtl = (0, core_1.isRtlElement)(e.currentTarget);
424
+ const grow = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
425
+ setDetailsW((w) => grow ? Math.min(560, w + 16) : Math.max(300, w - 16));
421
426
  } }), (0, jsx_runtime_1.jsx)("main", { className: "recordpage-main", children: (0, jsx_runtime_1.jsx)(tabs_1.Tabs, { tabs: tabs, defaultValue: "overview", "aria-label": `${name} record`, children: panel }) })] })] }));
422
427
  }
423
428
  RecordPage.displayName = "RecordPage";
@@ -640,10 +640,15 @@ function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, r
640
640
  nr = Math.max(0, r - 1);
641
641
  else if (e.key === "ArrowDown")
642
642
  nr = Math.min(visibleRows.length - 1, r + 1);
643
- else if (e.key === "ArrowLeft")
644
- nc = Math.max(0, c - 1);
645
- else if (e.key === "ArrowRight")
646
- nc = Math.min(cols.length - 1, c + 1);
643
+ // The arrows follow what the user SEES. Under RTL the first column renders
644
+ // rightmost, so ArrowLeft moves to the next column and ArrowRight to the
645
+ // previous — the grid's own half-conversion, of the same kind the resize
646
+ // grips had (#255).
647
+ else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
648
+ const rtl = (0, core_1.isRtlElement)(e.currentTarget);
649
+ const forward = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
650
+ nc = forward ? Math.min(cols.length - 1, c + 1) : Math.max(0, c - 1);
651
+ }
647
652
  else if (e.key === "Escape") {
648
653
  setSelCell(null);
649
654
  return;
@@ -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,4 +1,5 @@
1
1
  import * as React from "react";
2
+ import { isRtlElement } from "../../core/index.js";
2
3
  /**
3
4
  * The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
4
5
  * previous enabled radio, wrapping, and check it on arrival. Home/End jump to
@@ -77,7 +78,11 @@ export function useRadioGroupKeyboard({ select, groupRef: externalRef, }) {
77
78
  else if (e.key === "End")
78
79
  next = radios[radios.length - 1];
79
80
  else {
80
- const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
81
+ // Down always means forward; the horizontal pair follows the direction
82
+ // the radios are drawn in, which for Segmented's pill row is the whole
83
+ // point (#255).
84
+ const rtl = isRtlElement(e.currentTarget);
85
+ const fwd = e.key === "ArrowDown" || e.key === (rtl ? "ArrowLeft" : "ArrowRight");
81
86
  // focus outside the group: enter at the near end rather than stepping
82
87
  // off an index of -1, which would land two from the back going backwards
83
88
  if (idx < 0)
@@ -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";
@@ -1,3 +1,4 @@
1
+ import { isRtlElement } from "../../core/index.js";
1
2
  /**
2
3
  * The WAI-ARIA tablist keyboard model: ←/→ to move (wrapping), Home/End to
3
4
  * jump, disabled tabs skipped, focus following selection.
@@ -17,9 +18,15 @@ export function useTablistKeyboard({ tabs, active, select, listRef, }) {
17
18
  return;
18
19
  const idx = enabled.findIndex((t) => t.value === active);
19
20
  let next = -1;
20
- if (e.key === "ArrowRight")
21
+ // The arrows follow the strip as it is drawn: under RTL the next tab is to
22
+ // the LEFT, so the keys swap. Everything else about the model is unchanged
23
+ // (#255).
24
+ const rtl = isRtlElement(e.currentTarget);
25
+ const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
26
+ const backKey = rtl ? "ArrowRight" : "ArrowLeft";
27
+ if (e.key === forwardKey)
21
28
  next = (idx + 1) % enabled.length;
22
- else if (e.key === "ArrowLeft")
29
+ else if (e.key === backKey)
23
30
  next = (idx - 1 + enabled.length) % enabled.length;
24
31
  else if (e.key === "Home")
25
32
  next = 0;
@@ -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";
@@ -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 { isRtlElement } from "../../core/index.js";
3
4
  import { boardRecipe } from "./board.recipe.js";
4
5
  /**
5
6
  * A kanban board with native drag-and-drop between columns. Cards are
@@ -72,11 +73,12 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
72
73
  }, onKeyDown: (e) => {
73
74
  if (!(e.ctrlKey || e.metaKey))
74
75
  return;
75
- const delta = e.key === "ArrowLeft"
76
- ? -1
77
- : e.key === "ArrowRight"
78
- ? 1
79
- : 0;
76
+ // the columns are a flex row, so under RTL the next one
77
+ // is to the LEFT and the keys swap (#255)
78
+ const rtl = isRtlElement(e.currentTarget);
79
+ const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
80
+ const backKey = rtl ? "ArrowRight" : "ArrowLeft";
81
+ const delta = e.key === backKey ? -1 : e.key === forwardKey ? 1 : 0;
80
82
  // only swallow the key when the move actually happens, so
81
83
  // Ctrl+Right at the last column still reaches the browser
82
84
  if (delta && moveByKey(card, delta))
@@ -377,10 +377,15 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
377
377
  if (!next)
378
378
  setFieldQuery("");
379
379
  }, children: showAllValues ? t.viewLess : t.viewAllValues }))] })) : (_jsxs("div", { className: "recordpage-noresults", children: ["No fields match \u201C", fieldQuery, "\u201D."] })))] }), _jsxs("div", { className: "recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "recordpage-emptystate", children: t.noLists })] })] }), _jsx("div", { className: "recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": 300, "aria-valuemax": 560, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
380
- if (e.key === "ArrowLeft")
381
- setDetailsW((w) => Math.max(300, w - 16));
382
- else if (e.key === "ArrowRight")
383
- setDetailsW((w) => Math.min(560, w + 16));
380
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
381
+ return;
382
+ // The pointer drag learned its direction in #212; this did not.
383
+ // The details column is the flex row's first child, so it sits on
384
+ // the RIGHT under RTL and ArrowLeft has to grow it — the same
385
+ // half-conversion the drag had, one handler away (#255).
386
+ const rtl = isRtlElement(e.currentTarget);
387
+ const grow = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
388
+ setDetailsW((w) => grow ? Math.min(560, w + 16) : Math.max(300, w - 16));
384
389
  } }), _jsx("main", { className: "recordpage-main", children: _jsx(Tabs, { tabs: tabs, defaultValue: "overview", "aria-label": `${name} record`, children: panel }) })] })] }));
385
390
  }
386
391
  RecordPage.displayName = "RecordPage";
@@ -600,10 +600,15 @@ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, ro
600
600
  nr = Math.max(0, r - 1);
601
601
  else if (e.key === "ArrowDown")
602
602
  nr = Math.min(visibleRows.length - 1, r + 1);
603
- else if (e.key === "ArrowLeft")
604
- nc = Math.max(0, c - 1);
605
- else if (e.key === "ArrowRight")
606
- nc = Math.min(cols.length - 1, c + 1);
603
+ // The arrows follow what the user SEES. Under RTL the first column renders
604
+ // rightmost, so ArrowLeft moves to the next column and ArrowRight to the
605
+ // previous — the grid's own half-conversion, of the same kind the resize
606
+ // grips had (#255).
607
+ else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
608
+ const rtl = isRtlElement(e.currentTarget);
609
+ const forward = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
610
+ nc = forward ? Math.min(cols.length - 1, c + 1) : Math.max(0, c - 1);
611
+ }
607
612
  else if (e.key === "Escape") {
608
613
  setSelCell(null);
609
614
  return;
@@ -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.1",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",