@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 +133 -2
- package/THIRD-PARTY-NOTICES +54 -0
- package/dist/index.js +221 -0
- package/package.json +59 -4
- package/scss/_combobox.scss +73 -0
- package/scss/_variables.scss +23 -0
- package/scss/index.scss +5 -0
package/README.md
CHANGED
|
@@ -1,3 +1,134 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @carissa-io/components-combobox
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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;
|