@carissa-io/components-combobox 0.0.0-stage → 0.1.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.
package/README.md CHANGED
@@ -1,3 +1,134 @@
1
- # Temporary Holding Version
1
+ # @carissa-io/components-combobox
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A combobox with list autocomplete: a text field that looks like
4
+ [components-input](https://github.com/carissa-io/components-input), whose text filters a list of options shown in a
5
+ popover. The user picks one option; free text is never a value. React component, SCSS-themed. It builds on
6
+ `components-input` (field look and styles), `components-popover` (the panel) and `components-listbox` (the options and
7
+ the keyboard).
8
+
9
+ ```bash
10
+ npm i @carissa-io/components-combobox
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```jsx
16
+ import { Combobox } from '@carissa-io/components-combobox'
17
+
18
+ <Combobox
19
+ label={t('country')}
20
+ name="country"
21
+ options={[
22
+ { value: 'ar', label: t('argentina') },
23
+ { value: 'br', label: t('brazil') },
24
+ { value: 'cl', label: t('chile'), disabled: true },
25
+ ]}
26
+ placeholder={t('search')}
27
+ empty={t('no_results')}
28
+ required
29
+ messages={{ required: t('required') }}
30
+ onChange={(value, option) => setCountry(value)}
31
+ onValidate={(isValid, errorCode) => setFormValid(isValid)}
32
+ />
33
+ ```
34
+
35
+ | Prop | |
36
+ |---|---|
37
+ | `label`, `name`, `id`, `disabled`, `className` | usual; `id` is the input's id (generated when omitted), `className` goes to the root |
38
+ | `options` | `[{ value, label, disabled }]`; `label` can be any node. Values are compared as given (strings or numbers) |
39
+ | `value` + `onChange(value, option)` | controlled; or `defaultValue` for uncontrolled. `''`, `null` and `undefined` mean "nothing selected" |
40
+ | `input_value` + `onInputChange(text)` | the text of the input, controlled; or `default_input_value` for uncontrolled |
41
+ | `filter(option, text)` | which options match the text. Default: case-insensitive "contains" on the label (on the value when the label is not a string). An empty text shows every option |
42
+ | `placeholder` | string passed to the input |
43
+ | `required` | something must be selected; the error code is `required` |
44
+ | `onValidate(isValid, errorCode)` | fires on mount and whenever the outcome changes |
45
+ | `messages` | `{ [errorCode]: text }`; without an entry the bare code is shown |
46
+ | `error` | an error from the parent (e.g. the API); wins over the built-in one and shows immediately |
47
+ | `arrow_icon` | the arrow node. Omitted = Lucide's `chevron-down`; `null` = no arrow |
48
+ | `empty` | node the list shows when the filter leaves no options. Without it the list renders nothing |
49
+ | `default_open` | `false`. Initial open state (uncontrolled), handy for stories and tests |
50
+ | anything else | goes to the `<input>` (`aria-label`, `data-*`, `onFocus`, ...). Its `onKeyDown` runs first and can `preventDefault()` to skip the list keys |
51
+
52
+ ## Value and text
53
+
54
+ - The input shows the selected option's label until the user types. Typing opens the list, calls `onInputChange(text)`,
55
+ filters the options and activates the first enabled match, so Enter picks the top match.
56
+ - Picking an option (click or Enter) sets the value, puts its label in the input, closes the list and keeps the focus in
57
+ the input. `onChange` fires only when the value changes.
58
+ - Clearing the text (empty input) clears the value: `onChange('', null)`, only if there was a value. In controlled
59
+ mode the parent passes the new `value` back.
60
+ - Free text is not a value: when the input loses focus the text goes back to the selected option's label, or to `''`
61
+ without a selection. `onInputChange` receives the reverted text, so a controlled `input_value` follows. The hidden
62
+ input and `onChange` only ever carry the value of an option.
63
+ - With a `name`, a hidden `<input type="hidden" name value>` carries the value (an empty string when nothing is
64
+ selected) so the combobox is submitted with its form; it is `disabled` along with the combobox.
65
+ - The typed text only filters while it is the user's: after a pick or a blur, opening the list with ArrowDown shows
66
+ every option again.
67
+
68
+ The built-in `required` error shows after the first interaction: the list was opened and closed, or the input lost
69
+ focus. `onValidate` reports from the start. The error line is a `role="alert"` paragraph linked to the input with
70
+ `aria-describedby`.
71
+
72
+ ## Keyboard
73
+
74
+ Focus stays in the input the whole time; the active option is announced through `aria-activedescendant`. The keys come
75
+ from `useListboxKeyDown` of components-listbox, with the text-editing keys left alone (`allow_space_select: false`,
76
+ `open_on_enter: false`, `home_end: false`, `typeahead: false`).
77
+
78
+ | Key | Closed | Open |
79
+ |---|---|---|
80
+ | Typing | opens and filters | filters; the first enabled match becomes active |
81
+ | ArrowDown, ArrowUp | opens on the selected option (else the first / last enabled) | moves to the next / previous enabled option |
82
+ | Enter | nothing special (a form can submit) | selects the active option and closes; with no active option nothing special |
83
+ | Space, Home, End | text editing | text editing |
84
+ | Escape | nothing | closes, keeping the focus and the text |
85
+ | Tab | moves on | closes and moves on |
86
+
87
+ Clicking the input or focusing it does not open the list. A press outside closes it; clicking an option selects it and
88
+ keeps the focus in the input. Disabled options are skipped and cannot be selected.
89
+
90
+ ## Arrow
91
+
92
+ The arrow is decorative: `aria-hidden`, it ignores the pointer and it is not a button. A press anywhere in the box
93
+ (label, padding, arrow) focuses the input, but does not open the list; the keyboard and typing do.
94
+
95
+ ## Accessibility
96
+
97
+ - Input: `role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`, `aria-controls` (the listbox id),
98
+ `aria-activedescendant` only while the list is open with an active option, `aria-required`, `aria-invalid`,
99
+ `aria-describedby`; `autocomplete="off"`. The `<label>` points at the input with `for`.
100
+ - Ids derive from `id`: `<id>-label`, `<id>-listbox` (the `<ul role="listbox">`), `<id>-panel` (the popover panel,
101
+ without a role, so the listbox id is not duplicated), `<id>-error`.
102
+
103
+ ## Limitation
104
+
105
+ The panel lives inside the combobox (no portal, see components-popover), so an ancestor with `overflow: hidden` or
106
+ `auto` clips it, and it only flips above the field by looking at the viewport.
107
+
108
+ ## Style
109
+
110
+ ```scss
111
+ @use '@carissa-io/components-input/scss' with ($c-input-border-color: #000);
112
+ @use '@carissa-io/components-popover/scss' with ($c-popover-shadow: none);
113
+ @use '@carissa-io/components-listbox/scss' with ($c-listbox-max-height: 12rem);
114
+ @use '@carissa-io/components-combobox/scss' with (
115
+ $c-combobox-arrow-color: #000,
116
+ $c-combobox-arrow-rotate-open: 180deg
117
+ );
118
+ ```
119
+
120
+ `scss/index.scss` forwards the styles and variables of the three base packages, so configuring the combobox alone also
121
+ configures them; but Sass only accepts a module's configuration before the module is first loaded: configure input,
122
+ popover and listbox first (in that order) when you want to tune them, and the combobox last. It can sit in the same
123
+ file as components-select, which forwards the same bases. The combobox's own variables are in
124
+ [scss/_variables.scss](scss/_variables.scss): text color, minimum height, cursor, placeholder color (`null` keeps
125
+ Input's), and the arrow (size, offset from the box edge, gap to the text, color, hover/open/disabled colors, transition
126
+ and the rotation when open).
127
+
128
+ Classes: the root is `c-input c-combobox` (modifiers `c-input--invalid`, `--required`, `--disabled`,
129
+ `c-combobox--open`), inside Input's `__box`, `__label`, `__field`, `__error`, plus `c-combobox__popover`,
130
+ `c-combobox__input` (`c-input__control` too) and `c-combobox__arrow`. The input has no focus style of its own: the box
131
+ shows Input's focus ring while the input is focused (`:focus-within`). The panel and the arrow are positioned against
132
+ the whole box.
133
+
134
+ The default arrow is Lucide's `chevron-down` (see [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES)).
@@ -0,0 +1,54 @@
1
+ THIRD-PARTY NOTICES
2
+
3
+ @carissa-io/components-combobox includes material from the following third-party projects.
4
+
5
+ --------------------------------------------------------------------------------
6
+ Lucide (https://lucide.dev), icon "chevron-down", copied from lucide-static v1.53.0
7
+ License: ISC, and MIT for the part derived from the Feather project
8
+ --------------------------------------------------------------------------------
9
+
10
+ The icon "chevron-down" is one of the Lucide icons derived from the Feather project, so both licenses below apply to it.
11
+
12
+ ISC License
13
+
14
+ Copyright (c) 2026 Lucide Icons and Contributors
15
+
16
+ Permission to use, copy, modify, and/or distribute this software for any
17
+ purpose with or without fee is hereby granted, provided that the above
18
+ copyright notice and this permission notice appear in all copies.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
21
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
22
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
23
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
24
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
25
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
26
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
27
+
28
+ ---
29
+
30
+ The following Lucide icons are derived from the Feather project:
31
+
32
+ airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
33
+
34
+ The MIT License (MIT) (for the icons listed above)
35
+
36
+ Copyright (c) 2013-present Cole Bemis
37
+
38
+ Permission is hereby granted, free of charge, to any person obtaining a copy
39
+ of this software and associated documentation files (the "Software"), to deal
40
+ in the Software without restriction, including without limitation the rights
41
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
42
+ copies of the Software, and to permit persons to whom the Software is
43
+ furnished to do so, subject to the following conditions:
44
+
45
+ The above copyright notice and this permission notice shall be included in all
46
+ copies or substantial portions of the Software.
47
+
48
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
49
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
50
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
51
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
52
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
53
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
54
+ SOFTWARE.
package/dist/index.js ADDED
@@ -0,0 +1,221 @@
1
+ // src/Combobox.jsx
2
+ import { useEffect, useId, useRef, useState } from "react";
3
+ import { Popover } from "@carissa-io/components-popover";
4
+ import { Listbox, getOptionId, optionText, useListboxKeyDown } from "@carissa-io/components-listbox";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ var ChevronDown = () => /* @__PURE__ */ jsx(
7
+ "svg",
8
+ {
9
+ viewBox: "0 0 24 24",
10
+ width: "100%",
11
+ height: "100%",
12
+ fill: "none",
13
+ stroke: "currentColor",
14
+ strokeWidth: "2",
15
+ strokeLinecap: "round",
16
+ strokeLinejoin: "round",
17
+ "aria-hidden": "true",
18
+ focusable: "false",
19
+ children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" })
20
+ }
21
+ );
22
+ var isNothing = (value) => value === void 0 || value === null || value === "";
23
+ var firstEnabled = (options) => options.find((option) => !option.disabled);
24
+ var containsText = (option, text) => optionText(option).toLowerCase().includes(text.toLowerCase());
25
+ function Combobox({
26
+ id,
27
+ name,
28
+ label,
29
+ options = [],
30
+ value,
31
+ defaultValue,
32
+ onChange,
33
+ input_value,
34
+ default_input_value,
35
+ onInputChange,
36
+ filter = containsText,
37
+ placeholder,
38
+ onBlur,
39
+ onKeyDown,
40
+ onValidate,
41
+ error,
42
+ required = false,
43
+ messages,
44
+ arrow_icon,
45
+ empty,
46
+ default_open = false,
47
+ className = "",
48
+ disabled = false,
49
+ ...rest
50
+ }) {
51
+ const autoId = useId();
52
+ const inputId = id ?? autoId;
53
+ const labelId = `${inputId}-label`;
54
+ const listId = `${inputId}-listbox`;
55
+ const panelId = `${inputId}-panel`;
56
+ const errorId = `${inputId}-error`;
57
+ const inputRef = useRef(null);
58
+ const [inner, setInner] = useState(defaultValue);
59
+ const [touched, setTouched] = useState(false);
60
+ const current = value !== void 0 ? value : inner;
61
+ const selected = isNothing(current) ? void 0 : options.find((option) => option.value === current);
62
+ const selectedText = selected ? optionText(selected) : "";
63
+ const initialText = input_value !== void 0 ? input_value : default_input_value;
64
+ const [typed, setTyped] = useState(!isNothing(initialText) && initialText !== selectedText);
65
+ const [innerText, setInnerText] = useState(default_input_value ?? "");
66
+ const text = input_value !== void 0 ? input_value : typed ? innerText : selectedText;
67
+ const matches = typed && text !== "" ? options.filter((option) => filter(option, text)) : options;
68
+ const [open, setOpen] = useState(default_open && !disabled);
69
+ const [active, setActive] = useState(() => (selected && !selected.disabled ? selected : firstEnabled(matches))?.value);
70
+ const isOpen = open && !disabled;
71
+ const activeOption = matches.find((option) => option.value === active && !option.disabled);
72
+ const activeIndex = isOpen && activeOption ? matches.indexOf(activeOption) : -1;
73
+ const code = required && isNothing(current) ? "required" : null;
74
+ const valid = code === null;
75
+ const onValidateRef = useRef(onValidate);
76
+ useEffect(() => {
77
+ onValidateRef.current = onValidate;
78
+ });
79
+ useEffect(() => {
80
+ onValidateRef.current?.(valid, code);
81
+ }, [valid, code]);
82
+ const shown = error || (touched && code ? messages?.[code] ?? code : null);
83
+ const changeText = (next) => {
84
+ if (next === text) return;
85
+ setInnerText(next);
86
+ onInputChange?.(next);
87
+ };
88
+ const openList = () => {
89
+ if (disabled) return;
90
+ setActive((selected && !selected.disabled ? selected : firstEnabled(matches))?.value);
91
+ setOpen(true);
92
+ };
93
+ const closeList = () => {
94
+ setOpen(false);
95
+ setTouched(true);
96
+ };
97
+ const pick = (next, option) => {
98
+ if (next !== current) {
99
+ if (value === void 0) setInner(next);
100
+ onChange?.(next, option);
101
+ }
102
+ setTyped(false);
103
+ changeText(optionText(option));
104
+ closeList();
105
+ inputRef.current?.focus();
106
+ };
107
+ const handleKeyDown = useListboxKeyDown({
108
+ options: matches,
109
+ value: current,
110
+ active: activeOption?.value,
111
+ onActiveChange: setActive,
112
+ onSelect: pick,
113
+ isOpen,
114
+ open: openList,
115
+ close: closeList,
116
+ // The input keeps its own editing keys: Space types a space, Home/End move the caret, Enter does not open.
117
+ allow_space_select: false,
118
+ open_on_enter: false,
119
+ home_end: false,
120
+ typeahead: false
121
+ });
122
+ const onInputKeyDown = (event) => {
123
+ onKeyDown?.(event);
124
+ if (!event.defaultPrevented) handleKeyDown(event);
125
+ };
126
+ const onType = (event) => {
127
+ const next = event.target.value;
128
+ const nextMatches = next === "" ? options : options.filter((option) => filter(option, next));
129
+ setTyped(true);
130
+ setInnerText(next);
131
+ onInputChange?.(next);
132
+ setActive(firstEnabled(nextMatches)?.value);
133
+ setOpen(true);
134
+ if (next === "" && !isNothing(current)) {
135
+ if (value === void 0) setInner("");
136
+ onChange?.("", null);
137
+ }
138
+ };
139
+ const onInputBlur = (event) => {
140
+ setTyped(false);
141
+ changeText(selectedText);
142
+ setOpen(false);
143
+ setTouched(true);
144
+ onBlur?.(event);
145
+ };
146
+ const onBoxMouseDown = (event) => {
147
+ if (disabled || event.target === inputRef.current) return;
148
+ event.preventDefault();
149
+ inputRef.current?.focus();
150
+ };
151
+ const arrow = arrow_icon === void 0 ? /* @__PURE__ */ jsx(ChevronDown, {}) : arrow_icon;
152
+ const hiddenValue = isNothing(current) ? "" : String(current);
153
+ const classes = ["c-input", "c-combobox"];
154
+ if (isOpen) classes.push("c-combobox--open");
155
+ if (shown) classes.push("c-input--invalid");
156
+ if (required) classes.push("c-input--required");
157
+ if (disabled) classes.push("c-input--disabled");
158
+ if (className) classes.push(className);
159
+ return /* @__PURE__ */ jsxs("div", { className: classes.join(" "), children: [
160
+ /* @__PURE__ */ jsxs("div", { className: "c-input__box", onMouseDown: onBoxMouseDown, children: [
161
+ label ? /* @__PURE__ */ jsx("label", { className: "c-input__label", id: labelId, htmlFor: inputId, children: label }) : null,
162
+ /* @__PURE__ */ jsxs("div", { className: "c-input__field", children: [
163
+ /* @__PURE__ */ jsxs(
164
+ Popover,
165
+ {
166
+ className: "c-combobox__popover",
167
+ open: isOpen,
168
+ onClose: closeList,
169
+ match_width: true,
170
+ panel_id: panelId,
171
+ panel: /* @__PURE__ */ jsx(
172
+ Listbox,
173
+ {
174
+ id: listId,
175
+ options: matches,
176
+ value: current,
177
+ active: activeOption?.value,
178
+ onActiveChange: setActive,
179
+ onSelect: pick,
180
+ empty
181
+ }
182
+ ),
183
+ children: [
184
+ /* @__PURE__ */ jsx(
185
+ "input",
186
+ {
187
+ ...rest,
188
+ ref: inputRef,
189
+ type: "text",
190
+ role: "combobox",
191
+ className: "c-input__control c-combobox__input",
192
+ id: inputId,
193
+ value: text,
194
+ placeholder,
195
+ disabled,
196
+ autoComplete: "off",
197
+ "aria-autocomplete": "list",
198
+ "aria-expanded": isOpen,
199
+ "aria-controls": listId,
200
+ "aria-activedescendant": activeIndex >= 0 ? getOptionId(listId, activeIndex) : void 0,
201
+ "aria-required": required ? "true" : void 0,
202
+ "aria-invalid": shown ? "true" : void 0,
203
+ "aria-describedby": shown ? errorId : void 0,
204
+ onChange: onType,
205
+ onKeyDown: onInputKeyDown,
206
+ onBlur: onInputBlur
207
+ }
208
+ ),
209
+ arrow !== null && arrow !== false ? /* @__PURE__ */ jsx("span", { className: "c-combobox__arrow", "aria-hidden": "true", children: arrow }) : null
210
+ ]
211
+ }
212
+ ),
213
+ name ? /* @__PURE__ */ jsx("input", { type: "hidden", name, value: hiddenValue, disabled }) : null
214
+ ] })
215
+ ] }),
216
+ shown ? /* @__PURE__ */ jsx("p", { className: "c-input__error", id: errorId, role: "alert", children: shown }) : null
217
+ ] });
218
+ }
219
+ export {
220
+ Combobox
221
+ };
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@carissa-io/components-combobox",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Carissa UI: combobox (text input with a filtered list in a popover, keyboard, validation) as a React component with SCSS variables",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "files": [
8
+ "dist",
9
+ "scss",
10
+ "THIRD-PARTY-NOTICES"
11
+ ],
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./scss": "./scss/index.scss",
15
+ "./scss/*": "./scss/*"
16
+ },
17
+ "sideEffects": false,
18
+ "scripts": {
19
+ "build": "esbuild src/index.js --bundle --packages=external --format=esm --jsx=automatic --outfile=dist/index.js",
20
+ "test": "vitest run",
21
+ "lint": "eslint .",
22
+ "prepublishOnly": "node -e \"if (!process.env.GITHUB_ACTIONS) { console.error('Refusing to publish outside GitHub Actions CI: commit and push to main instead.'); process.exit(1); }\" && npm run build"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/carissa-io/components-combobox.git"
27
+ },
28
+ "license": "UNLICENSED",
29
+ "engines": {
30
+ "node": ">=24"
31
+ },
32
+ "dependencies": {
33
+ "@carissa-io/components-input": "^0.1.4",
34
+ "@carissa-io/components-popover": "^0.1.0",
35
+ "@carissa-io/components-listbox": "^0.1.0"
36
+ },
37
+ "peerDependencies": {
38
+ "react": ">=18"
39
+ },
40
+ "devDependencies": {
41
+ "@eslint/js": "^10.0.1",
42
+ "@testing-library/jest-dom": "^7.0.1",
43
+ "@testing-library/react": "^16.3.3",
44
+ "@testing-library/user-event": "^14.6.7",
45
+ "@vitejs/plugin-react": "^6.1.1",
46
+ "esbuild": "^0.28.0",
47
+ "eslint": "^10.12.0",
48
+ "eslint-plugin-react-hooks": "^7.1.1",
49
+ "globals": "^17.13.0",
50
+ "jsdom": "^29.1.1",
51
+ "react": "^19.3.0",
52
+ "react-dom": "^19.3.0",
53
+ "sass-embedded": "^1.105.1",
54
+ "vite": "^8.3.2",
55
+ "vitest": "^4.1.11"
56
+ },
57
+ "publishConfig": {
58
+ "registry": "https://registry.npmjs.org",
59
+ "access": "public"
60
+ }
61
+ }
@@ -0,0 +1,73 @@
1
+ @use 'variables' as *;
2
+
3
+ // The look of the field is Input's (`c-input`, `c-input__box`, ...); this adds the arrow and sizes the text input.
4
+ .c-combobox {
5
+ // The panel and the arrow are positioned against the whole box, not against the narrower field inside its padding.
6
+ .c-input__box {
7
+ position: relative;
8
+ cursor: $c-combobox-cursor;
9
+ }
10
+
11
+ .c-combobox__popover {
12
+ position: static;
13
+ display: flex;
14
+ flex: 1 1 auto;
15
+ align-items: center;
16
+ min-width: 0;
17
+ }
18
+
19
+ &__input {
20
+ box-sizing: border-box;
21
+ width: 100%;
22
+ min-height: $c-combobox-min-height;
23
+ padding: 0 calc(#{$c-combobox-arrow-size} + #{$c-combobox-arrow-gap}) 0 0;
24
+ color: $c-combobox-color;
25
+ text-overflow: ellipsis;
26
+
27
+ @if $c-combobox-placeholder-color {
28
+ &::placeholder {
29
+ color: $c-combobox-placeholder-color;
30
+ }
31
+ }
32
+
33
+ &:disabled {
34
+ color: inherit; // the disabled color is set on the box by Input
35
+ cursor: $c-combobox-disabled-cursor;
36
+ }
37
+ }
38
+
39
+ &__arrow {
40
+ position: absolute;
41
+ top: 50%;
42
+ right: $c-combobox-arrow-offset;
43
+ display: inline-flex;
44
+ width: $c-combobox-arrow-size;
45
+ height: $c-combobox-arrow-size;
46
+ color: $c-combobox-arrow-color;
47
+ pointer-events: none;
48
+ translate: 0 -50%;
49
+ transition: $c-combobox-arrow-transition;
50
+
51
+ > svg {
52
+ width: 100%;
53
+ height: 100%;
54
+ }
55
+ }
56
+
57
+ .c-input__box:hover &__arrow {
58
+ color: $c-combobox-arrow-color-hover;
59
+ }
60
+
61
+ &--open &__arrow {
62
+ color: $c-combobox-arrow-color-open;
63
+ rotate: $c-combobox-arrow-rotate-open;
64
+ }
65
+
66
+ &.c-input--disabled .c-input__box {
67
+ cursor: $c-combobox-disabled-cursor;
68
+ }
69
+
70
+ &.c-input--disabled &__arrow {
71
+ color: $c-combobox-arrow-color-disabled;
72
+ }
73
+ }
@@ -0,0 +1,23 @@
1
+ // Every value is `!default`: set it before the first @use / through `with (...)` and it wins.
2
+ // The field look (box, notched label, focus ring, errors) comes from components-input, the panel from
3
+ // components-popover and the options from components-listbox: configure those once, before this module.
4
+ //
5
+ // @use '@carissa-io/components-combobox/scss' with ($c-combobox-arrow-size: 1.25rem, $c-combobox-arrow-color: #000);
6
+
7
+ // Input (the text field; the font, padding and focus ring are Input's)
8
+ $c-combobox-color: inherit !default;
9
+ $c-combobox-min-height: 1.5em !default;
10
+ $c-combobox-cursor: text !default; // over the whole box, since a press anywhere in it focuses the input
11
+ $c-combobox-disabled-cursor: not-allowed !default;
12
+ $c-combobox-placeholder-color: null !default; // null = keep the placeholder color of Input
13
+
14
+ // Arrow: decorative, sits inside the field on the inline-end side and never takes the pointer
15
+ $c-combobox-arrow-size: 1rem !default;
16
+ $c-combobox-arrow-offset: 0.75rem !default; // distance from the box edge (match the input's horizontal padding)
17
+ $c-combobox-arrow-gap: 0.5rem !default; // space kept between the text and the arrow
18
+ $c-combobox-arrow-color: #495057 !default;
19
+ $c-combobox-arrow-color-hover: #212529 !default;
20
+ $c-combobox-arrow-color-open: $c-combobox-arrow-color-hover !default;
21
+ $c-combobox-arrow-color-disabled: #868e96 !default;
22
+ $c-combobox-arrow-transition: rotate 0.15s ease-in-out, color 0.15s ease-in-out !default;
23
+ $c-combobox-arrow-rotate-open: 180deg !default;
@@ -0,0 +1,5 @@
1
+ @forward '@carissa-io/components-input/scss';
2
+ @forward '@carissa-io/components-popover/scss';
3
+ @forward '@carissa-io/components-listbox/scss';
4
+ @forward 'variables';
5
+ @use 'combobox';