@attrus-ds/core 0.2.0 → 0.3.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.
- package/LICENSE +27 -27
- package/dist/assets/flags/Brazil-rounded.svg +15 -15
- package/dist/assets/flags/Brazil-square.svg +16 -16
- package/dist/assets/flags/Chile-rounded.svg +10 -10
- package/dist/assets/flags/Chile-square.svg +10 -10
- package/dist/assets/flags/Colombia-rounded.svg +9 -9
- package/dist/assets/flags/Colombia-square.svg +9 -9
- package/dist/assets/flags/EUA-rounded.svg +10 -10
- package/dist/assets/flags/EUA-square.svg +10 -10
- package/dist/assets/flags/EUR-rounded.svg +8 -8
- package/dist/assets/flags/EUR-square.svg +8 -8
- package/dist/assets/flags/Mexico-rounded.svg +18 -18
- package/dist/assets/flags/Mexico-square.svg +18 -18
- package/dist/assets/icon-attrus-dark.svg +2 -2
- package/dist/assets/icon-attrus-light.svg +2 -2
- package/dist/assets/logo-attrus-dark.svg +7 -7
- package/dist/assets/logo-attrus-light.svg +7 -7
- package/dist/css/components/buttons.css +301 -301
- package/dist/css/components/callout.css +195 -195
- package/dist/css/components/cards.css +272 -272
- package/dist/css/components/combobox.css +319 -319
- package/dist/css/components/empty-states.css +61 -61
- package/dist/css/components/inputs.css +975 -975
- package/dist/css/components/number-input.css +138 -138
- package/dist/css/components/overlays.css +374 -374
- package/dist/css/components/primitives.css +314 -314
- package/dist/css/components/sidebar.css +179 -179
- package/dist/css/components/smart-search.css +91 -91
- package/dist/css/components/status-pills.css +207 -207
- package/dist/css/components/stepper.css +309 -309
- package/dist/css/components/table.css +602 -602
- package/dist/css/components/tabs.css +319 -319
- package/dist/css/components/toast.css +243 -243
- package/dist/css/components/tooltip.css +231 -231
- package/dist/css/components/topbar.css +148 -148
- package/dist/css/tokens.css +666 -666
- package/dist/react/AppSidebar/AppSidebar.d.ts +50 -50
- package/dist/react/AppSidebar/AppSidebar.tsx +197 -197
- package/dist/react/AppTopbar/AppTopbar.d.ts +58 -58
- package/dist/react/AppTopbar/AppTopbar.tsx +203 -203
- package/dist/react/Atoms/Atoms.d.ts +149 -149
- package/dist/react/Atoms/Atoms.tsx +407 -407
- package/dist/react/Button/Button.d.ts +25 -25
- package/dist/react/Button/Button.tsx +83 -83
- package/dist/react/Callout/Callout.d.ts +29 -29
- package/dist/react/Callout/Callout.tsx +98 -98
- package/dist/react/Card/Card.d.ts +38 -38
- package/dist/react/Card/Card.tsx +93 -93
- package/dist/react/Combobox/Combobox.d.ts +52 -52
- package/dist/react/Combobox/Combobox.tsx +254 -254
- package/dist/react/DataTable/DataTable.d.ts +81 -81
- package/dist/react/DataTable/DataTable.tsx +308 -308
- package/dist/react/Drawer/Drawer.d.ts +35 -35
- package/dist/react/Drawer/Drawer.tsx +226 -226
- package/dist/react/Input/Input.d.ts +155 -155
- package/dist/react/Input/Input.tsx +478 -478
- package/dist/react/Modal/Modal.d.ts +27 -27
- package/dist/react/Modal/Modal.tsx +173 -173
- package/dist/react/NumberInput/NumberInput.d.ts +25 -25
- package/dist/react/NumberInput/NumberInput.tsx +117 -117
- package/dist/react/Pill/Pill.d.ts +31 -31
- package/dist/react/Pill/Pill.tsx +104 -104
- package/dist/react/SearchPill/SearchPill.d.ts +14 -14
- package/dist/react/SearchPill/SearchPill.tsx +40 -40
- package/dist/react/SmartSearch/SmartSearch.d.ts +65 -65
- package/dist/react/SmartSearch/SmartSearch.tsx +271 -271
- package/dist/react/Stepper/Stepper.d.ts +25 -25
- package/dist/react/Stepper/Stepper.tsx +94 -94
- package/dist/react/Tabs/Tabs.d.ts +32 -32
- package/dist/react/Tabs/Tabs.tsx +147 -147
- package/dist/react/Toast/Toast.d.ts +30 -30
- package/dist/react/Toast/Toast.tsx +94 -94
- package/dist/react/Tooltip/Tooltip.d.ts +23 -23
- package/dist/react/Tooltip/Tooltip.tsx +80 -80
- package/dist/react/ZeroState/ZeroState.d.ts +17 -17
- package/dist/react/ZeroState/ZeroState.tsx +45 -45
- package/dist/react/index.ts +35 -35
- package/dist/tokens/attrus-tokens.flat.json +1234 -1234
- package/dist/tokens/attrus-tokens.json +1089 -1089
- package/package.json +64 -64
|
@@ -1,254 +1,254 @@
|
|
|
1
|
-
import * as React from 'react';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* ATTRUS Combobox — typed wrapper over the canonical `.combobox`
|
|
5
|
-
* (preview/components/combobox.css): trigger + popover listbox with
|
|
6
|
-
* filterable options, groups, metas, selected check and footer action.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export interface ComboOption {
|
|
10
|
-
value: string;
|
|
11
|
-
label: React.ReactNode;
|
|
12
|
-
/** Secondary line under the label. */
|
|
13
|
-
meta?: React.ReactNode;
|
|
14
|
-
/** Right-aligned mono hint (e.g. a code). */
|
|
15
|
-
trailing?: React.ReactNode;
|
|
16
|
-
/** Optional group header this option sits under. */
|
|
17
|
-
group?: string;
|
|
18
|
-
/** Non-selectable (`.is-disabled`). */
|
|
19
|
-
disabled?: boolean;
|
|
20
|
-
/** Text used by the search filter when label isn't a plain string. */
|
|
21
|
-
searchText?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export type ComboboxStatus = 'default' | 'error';
|
|
25
|
-
|
|
26
|
-
export interface ComboboxProps {
|
|
27
|
-
options: ComboOption[];
|
|
28
|
-
value?: string | null;
|
|
29
|
-
onChange?: (value: string) => void;
|
|
30
|
-
placeholder?: string;
|
|
31
|
-
/** Shows the search header inside the popover. */
|
|
32
|
-
searchable?: boolean;
|
|
33
|
-
searchPlaceholder?: string;
|
|
34
|
-
/** Trigger leading icon (`.combobox-trigger-icon`). */
|
|
35
|
-
icon?: React.ReactNode;
|
|
36
|
-
/** Validation state — error ring on the trigger (`.is-invalid`). */
|
|
37
|
-
status?: ComboboxStatus;
|
|
38
|
-
/** Filter-chip row inside the popover (`.combobox-filters`) — use `.combobox-filter-chip` buttons. */
|
|
39
|
-
filters?: React.ReactNode;
|
|
40
|
-
/** Results meta line above the list (`.combobox-meta`), e.g. "Results (21)". */
|
|
41
|
-
meta?: React.ReactNode;
|
|
42
|
-
/** Muted meta line below the list (`.combobox-foot-meta`). */
|
|
43
|
-
footMeta?: React.ReactNode;
|
|
44
|
-
/** Popover starts open (galleries/docs). */
|
|
45
|
-
defaultOpen?: boolean;
|
|
46
|
-
/** Footer action (e.g. "Create new counterparty"). */
|
|
47
|
-
footer?: React.ReactNode;
|
|
48
|
-
onFooterClick?: () => void;
|
|
49
|
-
emptyTitle?: React.ReactNode;
|
|
50
|
-
emptySub?: React.ReactNode;
|
|
51
|
-
disabled?: boolean;
|
|
52
|
-
className?: string;
|
|
53
|
-
style?: React.CSSProperties;
|
|
54
|
-
'aria-label'?: string;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const Chevron: React.FC = () => (
|
|
58
|
-
<svg className="combobox-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
59
|
-
<path d="m6 9 6 6 6-6" />
|
|
60
|
-
</svg>
|
|
61
|
-
);
|
|
62
|
-
const Check: React.FC = () => (
|
|
63
|
-
<svg className="combobox-option-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
64
|
-
<path d="M20 6 9 17l-5-5" />
|
|
65
|
-
</svg>
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
function optionText(o: ComboOption): string {
|
|
69
|
-
if (o.searchText) return o.searchText;
|
|
70
|
-
return typeof o.label === 'string' ? o.label : String(o.value);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export const Combobox: React.FC<ComboboxProps> = ({
|
|
74
|
-
options,
|
|
75
|
-
value = null,
|
|
76
|
-
onChange,
|
|
77
|
-
placeholder = 'Select…',
|
|
78
|
-
searchable = false,
|
|
79
|
-
searchPlaceholder = 'Search…',
|
|
80
|
-
icon,
|
|
81
|
-
status = 'default',
|
|
82
|
-
filters,
|
|
83
|
-
meta,
|
|
84
|
-
footMeta,
|
|
85
|
-
defaultOpen = false,
|
|
86
|
-
footer,
|
|
87
|
-
onFooterClick,
|
|
88
|
-
emptyTitle = 'No matches',
|
|
89
|
-
emptySub,
|
|
90
|
-
disabled = false,
|
|
91
|
-
className,
|
|
92
|
-
style,
|
|
93
|
-
...rest
|
|
94
|
-
}) => {
|
|
95
|
-
const [open, setOpen] = React.useState(defaultOpen);
|
|
96
|
-
const [q, setQ] = React.useState('');
|
|
97
|
-
const [active, setActive] = React.useState<string | null>(null);
|
|
98
|
-
const wrapRef = React.useRef<HTMLDivElement>(null);
|
|
99
|
-
|
|
100
|
-
React.useEffect(() => {
|
|
101
|
-
if (!open) return;
|
|
102
|
-
const onDoc = (e: MouseEvent) => {
|
|
103
|
-
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
|
|
104
|
-
};
|
|
105
|
-
const onKey = (e: KeyboardEvent) => {
|
|
106
|
-
if (e.key === 'Escape') setOpen(false);
|
|
107
|
-
};
|
|
108
|
-
document.addEventListener('mousedown', onDoc);
|
|
109
|
-
document.addEventListener('keydown', onKey);
|
|
110
|
-
return () => {
|
|
111
|
-
document.removeEventListener('mousedown', onDoc);
|
|
112
|
-
document.removeEventListener('keydown', onKey);
|
|
113
|
-
};
|
|
114
|
-
}, [open]);
|
|
115
|
-
|
|
116
|
-
const selected = options.find((o) => o.value === value) || null;
|
|
117
|
-
const filtered = q
|
|
118
|
-
? options.filter((o) => optionText(o).toLowerCase().indexOf(q.toLowerCase()) !== -1)
|
|
119
|
-
: options;
|
|
120
|
-
const selectable = filtered.filter((o) => !o.disabled);
|
|
121
|
-
|
|
122
|
-
// Keyboard nav — wrapper-level so it works from the trigger AND the search
|
|
123
|
-
// input. Arrow keys move `.is-active`; Enter commits the active option.
|
|
124
|
-
const onWrapKeyDown = (e: React.KeyboardEvent) => {
|
|
125
|
-
if (!open || !selectable.length) return;
|
|
126
|
-
const idx = selectable.findIndex((o) => o.value === active);
|
|
127
|
-
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
128
|
-
e.preventDefault();
|
|
129
|
-
const next = e.key === 'ArrowDown'
|
|
130
|
-
? selectable[Math.min(idx + 1, selectable.length - 1)]
|
|
131
|
-
: selectable[Math.max(idx - 1, 0)];
|
|
132
|
-
setActive(next.value);
|
|
133
|
-
const list = wrapRef.current?.querySelector('.combobox-list');
|
|
134
|
-
const el = list?.querySelector(`[data-value="${next.value}"]`) as HTMLElement | null;
|
|
135
|
-
if (list && el) {
|
|
136
|
-
const lt = (list as HTMLElement).scrollTop;
|
|
137
|
-
const lb = lt + (list as HTMLElement).clientHeight;
|
|
138
|
-
if (el.offsetTop < lt) (list as HTMLElement).scrollTop = el.offsetTop;
|
|
139
|
-
else if (el.offsetTop + el.offsetHeight > lb) (list as HTMLElement).scrollTop = el.offsetTop + el.offsetHeight - (list as HTMLElement).clientHeight;
|
|
140
|
-
}
|
|
141
|
-
} else if (e.key === 'Enter') {
|
|
142
|
-
const hit = selectable.find((o) => o.value === active);
|
|
143
|
-
if (hit) {
|
|
144
|
-
e.preventDefault();
|
|
145
|
-
if (onChange) onChange(hit.value);
|
|
146
|
-
setOpen(false);
|
|
147
|
-
setQ('');
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
// group-preserving render order
|
|
153
|
-
const rendered: React.ReactNode[] = [];
|
|
154
|
-
let lastGroup: string | undefined;
|
|
155
|
-
filtered.forEach((o) => {
|
|
156
|
-
if (o.group && o.group !== lastGroup) {
|
|
157
|
-
rendered.push(
|
|
158
|
-
<li key={`g-${o.group}`} className="combobox-group-label">
|
|
159
|
-
{o.group}
|
|
160
|
-
</li>
|
|
161
|
-
);
|
|
162
|
-
lastGroup = o.group;
|
|
163
|
-
}
|
|
164
|
-
const isSel = o.value === value;
|
|
165
|
-
rendered.push(
|
|
166
|
-
<li
|
|
167
|
-
key={o.value}
|
|
168
|
-
data-value={o.value}
|
|
169
|
-
className={['combobox-option', isSel ? 'is-selected' : '', o.value === active ? 'is-active' : '', o.disabled ? 'is-disabled' : ''].filter(Boolean).join(' ')}
|
|
170
|
-
role="option"
|
|
171
|
-
aria-selected={isSel}
|
|
172
|
-
aria-disabled={o.disabled || undefined}
|
|
173
|
-
onMouseEnter={() => { if (!o.disabled) setActive(o.value); }}
|
|
174
|
-
onClick={() => {
|
|
175
|
-
if (o.disabled) return;
|
|
176
|
-
if (onChange) onChange(o.value);
|
|
177
|
-
setOpen(false);
|
|
178
|
-
setQ('');
|
|
179
|
-
}}
|
|
180
|
-
>
|
|
181
|
-
<span className="combobox-option-content">
|
|
182
|
-
<span className="combobox-option-label">{o.label}</span>
|
|
183
|
-
{o.meta != null ? <span className="combobox-option-meta">{o.meta}</span> : null}
|
|
184
|
-
</span>
|
|
185
|
-
{o.trailing != null ? <span className="combobox-option-trailing">{o.trailing}</span> : null}
|
|
186
|
-
<Check />
|
|
187
|
-
</li>
|
|
188
|
-
);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
return (
|
|
192
|
-
<div
|
|
193
|
-
ref={wrapRef}
|
|
194
|
-
className={['combobox', open ? 'is-open' : '', status === 'error' ? 'is-invalid' : '', className || ''].filter(Boolean).join(' ')}
|
|
195
|
-
style={style}
|
|
196
|
-
onKeyDown={onWrapKeyDown}
|
|
197
|
-
>
|
|
198
|
-
<button
|
|
199
|
-
type="button"
|
|
200
|
-
className="combobox-trigger"
|
|
201
|
-
aria-haspopup="listbox"
|
|
202
|
-
aria-expanded={open}
|
|
203
|
-
disabled={disabled}
|
|
204
|
-
onClick={() => { setOpen((o) => !o); setActive(value); }}
|
|
205
|
-
{...rest}
|
|
206
|
-
>
|
|
207
|
-
{icon != null ? <span className="combobox-trigger-icon">{icon}</span> : null}
|
|
208
|
-
<span className="combobox-value">
|
|
209
|
-
{selected ? selected.label : <span className="combobox-placeholder">{placeholder}</span>}
|
|
210
|
-
</span>
|
|
211
|
-
<Chevron />
|
|
212
|
-
</button>
|
|
213
|
-
{open ? (
|
|
214
|
-
<div className="combobox-popover">
|
|
215
|
-
{searchable ? (
|
|
216
|
-
<div className="combobox-search">
|
|
217
|
-
<svg className="combobox-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
218
|
-
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
|
219
|
-
</svg>
|
|
220
|
-
<input
|
|
221
|
-
autoFocus
|
|
222
|
-
placeholder={searchPlaceholder}
|
|
223
|
-
value={q}
|
|
224
|
-
onChange={(e) => setQ(e.target.value)}
|
|
225
|
-
/>
|
|
226
|
-
</div>
|
|
227
|
-
) : null}
|
|
228
|
-
{filters != null ? <div className="combobox-filters">{filters}</div> : null}
|
|
229
|
-
{meta != null ? <div className="combobox-meta">{meta}</div> : null}
|
|
230
|
-
{filtered.length === 0 ? (
|
|
231
|
-
<div className="combobox-empty">
|
|
232
|
-
<strong>{emptyTitle}</strong>
|
|
233
|
-
{emptySub}
|
|
234
|
-
</div>
|
|
235
|
-
) : (
|
|
236
|
-
<ul className="combobox-list" role="listbox">
|
|
237
|
-
{rendered}
|
|
238
|
-
</ul>
|
|
239
|
-
)}
|
|
240
|
-
{footMeta != null ? <div className="combobox-foot-meta">{footMeta}</div> : null}
|
|
241
|
-
{footer ? (
|
|
242
|
-
<div className="combobox-footer">
|
|
243
|
-
<button type="button" className="combobox-footer-action" onClick={onFooterClick}>
|
|
244
|
-
{footer}
|
|
245
|
-
</button>
|
|
246
|
-
</div>
|
|
247
|
-
) : null}
|
|
248
|
-
</div>
|
|
249
|
-
) : null}
|
|
250
|
-
</div>
|
|
251
|
-
);
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
export default Combobox;
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ATTRUS Combobox — typed wrapper over the canonical `.combobox`
|
|
5
|
+
* (preview/components/combobox.css): trigger + popover listbox with
|
|
6
|
+
* filterable options, groups, metas, selected check and footer action.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ComboOption {
|
|
10
|
+
value: string;
|
|
11
|
+
label: React.ReactNode;
|
|
12
|
+
/** Secondary line under the label. */
|
|
13
|
+
meta?: React.ReactNode;
|
|
14
|
+
/** Right-aligned mono hint (e.g. a code). */
|
|
15
|
+
trailing?: React.ReactNode;
|
|
16
|
+
/** Optional group header this option sits under. */
|
|
17
|
+
group?: string;
|
|
18
|
+
/** Non-selectable (`.is-disabled`). */
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Text used by the search filter when label isn't a plain string. */
|
|
21
|
+
searchText?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ComboboxStatus = 'default' | 'error';
|
|
25
|
+
|
|
26
|
+
export interface ComboboxProps {
|
|
27
|
+
options: ComboOption[];
|
|
28
|
+
value?: string | null;
|
|
29
|
+
onChange?: (value: string) => void;
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
/** Shows the search header inside the popover. */
|
|
32
|
+
searchable?: boolean;
|
|
33
|
+
searchPlaceholder?: string;
|
|
34
|
+
/** Trigger leading icon (`.combobox-trigger-icon`). */
|
|
35
|
+
icon?: React.ReactNode;
|
|
36
|
+
/** Validation state — error ring on the trigger (`.is-invalid`). */
|
|
37
|
+
status?: ComboboxStatus;
|
|
38
|
+
/** Filter-chip row inside the popover (`.combobox-filters`) — use `.combobox-filter-chip` buttons. */
|
|
39
|
+
filters?: React.ReactNode;
|
|
40
|
+
/** Results meta line above the list (`.combobox-meta`), e.g. "Results (21)". */
|
|
41
|
+
meta?: React.ReactNode;
|
|
42
|
+
/** Muted meta line below the list (`.combobox-foot-meta`). */
|
|
43
|
+
footMeta?: React.ReactNode;
|
|
44
|
+
/** Popover starts open (galleries/docs). */
|
|
45
|
+
defaultOpen?: boolean;
|
|
46
|
+
/** Footer action (e.g. "Create new counterparty"). */
|
|
47
|
+
footer?: React.ReactNode;
|
|
48
|
+
onFooterClick?: () => void;
|
|
49
|
+
emptyTitle?: React.ReactNode;
|
|
50
|
+
emptySub?: React.ReactNode;
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
className?: string;
|
|
53
|
+
style?: React.CSSProperties;
|
|
54
|
+
'aria-label'?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const Chevron: React.FC = () => (
|
|
58
|
+
<svg className="combobox-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
59
|
+
<path d="m6 9 6 6 6-6" />
|
|
60
|
+
</svg>
|
|
61
|
+
);
|
|
62
|
+
const Check: React.FC = () => (
|
|
63
|
+
<svg className="combobox-option-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
64
|
+
<path d="M20 6 9 17l-5-5" />
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
function optionText(o: ComboOption): string {
|
|
69
|
+
if (o.searchText) return o.searchText;
|
|
70
|
+
return typeof o.label === 'string' ? o.label : String(o.value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const Combobox: React.FC<ComboboxProps> = ({
|
|
74
|
+
options,
|
|
75
|
+
value = null,
|
|
76
|
+
onChange,
|
|
77
|
+
placeholder = 'Select…',
|
|
78
|
+
searchable = false,
|
|
79
|
+
searchPlaceholder = 'Search…',
|
|
80
|
+
icon,
|
|
81
|
+
status = 'default',
|
|
82
|
+
filters,
|
|
83
|
+
meta,
|
|
84
|
+
footMeta,
|
|
85
|
+
defaultOpen = false,
|
|
86
|
+
footer,
|
|
87
|
+
onFooterClick,
|
|
88
|
+
emptyTitle = 'No matches',
|
|
89
|
+
emptySub,
|
|
90
|
+
disabled = false,
|
|
91
|
+
className,
|
|
92
|
+
style,
|
|
93
|
+
...rest
|
|
94
|
+
}) => {
|
|
95
|
+
const [open, setOpen] = React.useState(defaultOpen);
|
|
96
|
+
const [q, setQ] = React.useState('');
|
|
97
|
+
const [active, setActive] = React.useState<string | null>(null);
|
|
98
|
+
const wrapRef = React.useRef<HTMLDivElement>(null);
|
|
99
|
+
|
|
100
|
+
React.useEffect(() => {
|
|
101
|
+
if (!open) return;
|
|
102
|
+
const onDoc = (e: MouseEvent) => {
|
|
103
|
+
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
|
|
104
|
+
};
|
|
105
|
+
const onKey = (e: KeyboardEvent) => {
|
|
106
|
+
if (e.key === 'Escape') setOpen(false);
|
|
107
|
+
};
|
|
108
|
+
document.addEventListener('mousedown', onDoc);
|
|
109
|
+
document.addEventListener('keydown', onKey);
|
|
110
|
+
return () => {
|
|
111
|
+
document.removeEventListener('mousedown', onDoc);
|
|
112
|
+
document.removeEventListener('keydown', onKey);
|
|
113
|
+
};
|
|
114
|
+
}, [open]);
|
|
115
|
+
|
|
116
|
+
const selected = options.find((o) => o.value === value) || null;
|
|
117
|
+
const filtered = q
|
|
118
|
+
? options.filter((o) => optionText(o).toLowerCase().indexOf(q.toLowerCase()) !== -1)
|
|
119
|
+
: options;
|
|
120
|
+
const selectable = filtered.filter((o) => !o.disabled);
|
|
121
|
+
|
|
122
|
+
// Keyboard nav — wrapper-level so it works from the trigger AND the search
|
|
123
|
+
// input. Arrow keys move `.is-active`; Enter commits the active option.
|
|
124
|
+
const onWrapKeyDown = (e: React.KeyboardEvent) => {
|
|
125
|
+
if (!open || !selectable.length) return;
|
|
126
|
+
const idx = selectable.findIndex((o) => o.value === active);
|
|
127
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
const next = e.key === 'ArrowDown'
|
|
130
|
+
? selectable[Math.min(idx + 1, selectable.length - 1)]
|
|
131
|
+
: selectable[Math.max(idx - 1, 0)];
|
|
132
|
+
setActive(next.value);
|
|
133
|
+
const list = wrapRef.current?.querySelector('.combobox-list');
|
|
134
|
+
const el = list?.querySelector(`[data-value="${next.value}"]`) as HTMLElement | null;
|
|
135
|
+
if (list && el) {
|
|
136
|
+
const lt = (list as HTMLElement).scrollTop;
|
|
137
|
+
const lb = lt + (list as HTMLElement).clientHeight;
|
|
138
|
+
if (el.offsetTop < lt) (list as HTMLElement).scrollTop = el.offsetTop;
|
|
139
|
+
else if (el.offsetTop + el.offsetHeight > lb) (list as HTMLElement).scrollTop = el.offsetTop + el.offsetHeight - (list as HTMLElement).clientHeight;
|
|
140
|
+
}
|
|
141
|
+
} else if (e.key === 'Enter') {
|
|
142
|
+
const hit = selectable.find((o) => o.value === active);
|
|
143
|
+
if (hit) {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
if (onChange) onChange(hit.value);
|
|
146
|
+
setOpen(false);
|
|
147
|
+
setQ('');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// group-preserving render order
|
|
153
|
+
const rendered: React.ReactNode[] = [];
|
|
154
|
+
let lastGroup: string | undefined;
|
|
155
|
+
filtered.forEach((o) => {
|
|
156
|
+
if (o.group && o.group !== lastGroup) {
|
|
157
|
+
rendered.push(
|
|
158
|
+
<li key={`g-${o.group}`} className="combobox-group-label">
|
|
159
|
+
{o.group}
|
|
160
|
+
</li>
|
|
161
|
+
);
|
|
162
|
+
lastGroup = o.group;
|
|
163
|
+
}
|
|
164
|
+
const isSel = o.value === value;
|
|
165
|
+
rendered.push(
|
|
166
|
+
<li
|
|
167
|
+
key={o.value}
|
|
168
|
+
data-value={o.value}
|
|
169
|
+
className={['combobox-option', isSel ? 'is-selected' : '', o.value === active ? 'is-active' : '', o.disabled ? 'is-disabled' : ''].filter(Boolean).join(' ')}
|
|
170
|
+
role="option"
|
|
171
|
+
aria-selected={isSel}
|
|
172
|
+
aria-disabled={o.disabled || undefined}
|
|
173
|
+
onMouseEnter={() => { if (!o.disabled) setActive(o.value); }}
|
|
174
|
+
onClick={() => {
|
|
175
|
+
if (o.disabled) return;
|
|
176
|
+
if (onChange) onChange(o.value);
|
|
177
|
+
setOpen(false);
|
|
178
|
+
setQ('');
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
<span className="combobox-option-content">
|
|
182
|
+
<span className="combobox-option-label">{o.label}</span>
|
|
183
|
+
{o.meta != null ? <span className="combobox-option-meta">{o.meta}</span> : null}
|
|
184
|
+
</span>
|
|
185
|
+
{o.trailing != null ? <span className="combobox-option-trailing">{o.trailing}</span> : null}
|
|
186
|
+
<Check />
|
|
187
|
+
</li>
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div
|
|
193
|
+
ref={wrapRef}
|
|
194
|
+
className={['combobox', open ? 'is-open' : '', status === 'error' ? 'is-invalid' : '', className || ''].filter(Boolean).join(' ')}
|
|
195
|
+
style={style}
|
|
196
|
+
onKeyDown={onWrapKeyDown}
|
|
197
|
+
>
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
className="combobox-trigger"
|
|
201
|
+
aria-haspopup="listbox"
|
|
202
|
+
aria-expanded={open}
|
|
203
|
+
disabled={disabled}
|
|
204
|
+
onClick={() => { setOpen((o) => !o); setActive(value); }}
|
|
205
|
+
{...rest}
|
|
206
|
+
>
|
|
207
|
+
{icon != null ? <span className="combobox-trigger-icon">{icon}</span> : null}
|
|
208
|
+
<span className="combobox-value">
|
|
209
|
+
{selected ? selected.label : <span className="combobox-placeholder">{placeholder}</span>}
|
|
210
|
+
</span>
|
|
211
|
+
<Chevron />
|
|
212
|
+
</button>
|
|
213
|
+
{open ? (
|
|
214
|
+
<div className="combobox-popover">
|
|
215
|
+
{searchable ? (
|
|
216
|
+
<div className="combobox-search">
|
|
217
|
+
<svg className="combobox-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
218
|
+
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
|
219
|
+
</svg>
|
|
220
|
+
<input
|
|
221
|
+
autoFocus
|
|
222
|
+
placeholder={searchPlaceholder}
|
|
223
|
+
value={q}
|
|
224
|
+
onChange={(e) => setQ(e.target.value)}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
) : null}
|
|
228
|
+
{filters != null ? <div className="combobox-filters">{filters}</div> : null}
|
|
229
|
+
{meta != null ? <div className="combobox-meta">{meta}</div> : null}
|
|
230
|
+
{filtered.length === 0 ? (
|
|
231
|
+
<div className="combobox-empty">
|
|
232
|
+
<strong>{emptyTitle}</strong>
|
|
233
|
+
{emptySub}
|
|
234
|
+
</div>
|
|
235
|
+
) : (
|
|
236
|
+
<ul className="combobox-list" role="listbox">
|
|
237
|
+
{rendered}
|
|
238
|
+
</ul>
|
|
239
|
+
)}
|
|
240
|
+
{footMeta != null ? <div className="combobox-foot-meta">{footMeta}</div> : null}
|
|
241
|
+
{footer ? (
|
|
242
|
+
<div className="combobox-footer">
|
|
243
|
+
<button type="button" className="combobox-footer-action" onClick={onFooterClick}>
|
|
244
|
+
{footer}
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
) : null}
|
|
248
|
+
</div>
|
|
249
|
+
) : null}
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export default Combobox;
|