@adecore/ui 0.22.1 → 0.23.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/dist/Select.d.ts +6 -13
- package/dist/Select.js +14 -16
- package/dist/SelectSearch.d.ts +2 -0
- package/dist/SelectSearch.js +39 -0
- package/dist/locales/en.json +4 -0
- package/dist/locales/nl.json +4 -0
- package/dist/select-row.d.ts +7 -0
- package/dist/select-row.js +6 -0
- package/dist/select-shared.d.ts +23 -0
- package/dist/select-shared.js +14 -0
- package/package.json +1 -1
- package/src/Select.tsx +23 -40
- package/src/SelectSearch.tsx +112 -0
- package/src/locales/en.json +4 -0
- package/src/locales/nl.json +4 -0
- package/src/select-row.tsx +17 -0
- package/src/select-shared.ts +34 -0
package/dist/Select.d.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
label: string;
|
|
5
|
-
description?: string;
|
|
6
|
-
icon?: ReactNode;
|
|
7
|
-
disabled?: boolean;
|
|
8
|
-
}
|
|
9
|
-
export interface SelectGroup<T extends string> {
|
|
10
|
-
label: string;
|
|
11
|
-
items: SelectItem<T>[];
|
|
12
|
-
}
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
import { type SelectGroup, type SelectItem } from './select-shared.ts';
|
|
3
|
+
export type { SelectGroup, SelectItem };
|
|
13
4
|
export interface SelectProps<T extends string> {
|
|
14
5
|
value: T | null;
|
|
15
6
|
onValueChange(value: T): void;
|
|
@@ -21,7 +12,9 @@ export interface SelectProps<T extends string> {
|
|
|
21
12
|
disabled?: boolean;
|
|
22
13
|
align?: 'start' | 'end';
|
|
23
14
|
truncateValue?: boolean;
|
|
15
|
+
searchable?: boolean | number;
|
|
16
|
+
searchPlaceholder?: string;
|
|
24
17
|
className?: string;
|
|
25
18
|
ref?: Ref<HTMLButtonElement>;
|
|
26
19
|
}
|
|
27
|
-
export declare function Select<T extends string>(
|
|
20
|
+
export declare function Select<T extends string>(props: SelectProps<T>): import("react").JSX.Element;
|
package/dist/Select.js
CHANGED
|
@@ -4,27 +4,25 @@ import clsx from 'clsx';
|
|
|
4
4
|
import { ChevronDown } from 'lucide-react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import { Icon } from './Icon.js';
|
|
7
|
-
import { MENU_LABEL
|
|
7
|
+
import { MENU_LABEL } from './menu/parts.js';
|
|
8
8
|
import { renderPositioner } from './popup-layer.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
md: 'h-8 gap-1.5 px-2'
|
|
13
|
-
};
|
|
14
|
-
const TRIGGER_VARIANT = {
|
|
15
|
-
outlined: 'max-w-56 rounded-lg border border-border bg-surface-raised text-text',
|
|
16
|
-
ghost: 'rounded-md text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text data-disabled:hover:bg-transparent data-disabled:hover:text-text-muted'
|
|
17
|
-
};
|
|
18
|
-
/* The dimmed look `Switch` and `Button` wear when disabled, so a select that cannot open reads as such. */
|
|
19
|
-
const TRIGGER_DISABLED = 'data-disabled:cursor-default data-disabled:opacity-50';
|
|
20
|
-
/* A description makes the row two lines high; the box keeps the icon on the label's line. */
|
|
9
|
+
import { RowContent } from './select-row.js';
|
|
10
|
+
import { isGroup, rowClassName, TRIGGER_DISABLED, TRIGGER_SIZE, TRIGGER_VARIANT } from './select-shared.js';
|
|
11
|
+
import { SelectSearch } from './SelectSearch.js';
|
|
21
12
|
function Row({ item, picked }) {
|
|
22
|
-
return (
|
|
13
|
+
return (_jsx(BaseSelect.Item, { className: rowClassName(item), value: item.value, label: item.label, disabled: item.disabled, children: _jsx(RowContent, { item: item, picked: picked, text: _jsx(BaseSelect.ItemText, { children: item.label }) }) }));
|
|
23
14
|
}
|
|
24
15
|
/* The one select. A Base UI `Select` in the popup style the menus use, so a settings field and a
|
|
25
16
|
picker in a toolbar read the same. Base UI brings the keyboard along (arrows,
|
|
26
|
-
Home and End, typeahead, Enter, Escape), which a hand-rolled listbox would have to repeat.
|
|
27
|
-
|
|
17
|
+
Home and End, typeahead, Enter, Escape), which a hand-rolled listbox would have to repeat.
|
|
18
|
+
A long list can ask for a search field, which swaps in a combobox with the same trigger and rows. */
|
|
19
|
+
export function Select(props) {
|
|
20
|
+
const { searchable, items } = props;
|
|
21
|
+
const count = items.length > 0 && isGroup(items[0]) ? items.reduce((sum, group) => sum + group.items.length, 0) : items.length;
|
|
22
|
+
const search = searchable === true || (typeof searchable === 'number' && count >= searchable);
|
|
23
|
+
return search ? _jsx(SelectSearch, { ...props }) : _jsx(PlainSelect, { ...props });
|
|
24
|
+
}
|
|
25
|
+
function PlainSelect({ value, onValueChange, items, label, placeholder, size = 'md', variant = 'outlined', disabled, align = 'start', truncateValue = true, className, ref }) {
|
|
28
26
|
const { t } = useTranslation('ui');
|
|
29
27
|
const groups = items.length > 0 && isGroup(items[0]) ? items : null;
|
|
30
28
|
const flat = groups ? groups.flatMap((group) => group.items) : items;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { SelectProps } from './Select.tsx';
|
|
2
|
+
export declare function SelectSearch<T extends string>({ value, onValueChange, items, label, placeholder, searchPlaceholder, size, variant, disabled, align, truncateValue, className, ref }: SelectProps<T>): import("react").JSX.Element;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Combobox } from '@base-ui-components/react/combobox';
|
|
4
|
+
import clsx from 'clsx';
|
|
5
|
+
import { ChevronDown, Search } from 'lucide-react';
|
|
6
|
+
import { useTranslation } from 'react-i18next';
|
|
7
|
+
import { Icon } from './Icon.js';
|
|
8
|
+
import { MENU_LABEL } from './menu/parts.js';
|
|
9
|
+
import { renderPositioner } from './popup-layer.js';
|
|
10
|
+
import { RowContent } from './select-row.js';
|
|
11
|
+
import { isGroup, rowClassName, TRIGGER_DISABLED, TRIGGER_SIZE, TRIGGER_VARIANT } from './select-shared.js';
|
|
12
|
+
/* The `Select` with a search field on top of its options, for lists too long to scan. A combobox of Base UI brings the filtering and the keyboard. */
|
|
13
|
+
export function SelectSearch({ value, onValueChange, items, label, placeholder, searchPlaceholder, size = 'md', variant = 'outlined', disabled, align = 'start', truncateValue = true, className, ref }) {
|
|
14
|
+
const { t } = useTranslation('ui');
|
|
15
|
+
const [query, setQuery] = useState('');
|
|
16
|
+
const groups = items.length > 0 && isGroup(items[0]) ? items : null;
|
|
17
|
+
const flat = groups ? groups.flatMap((group) => group.items) : items;
|
|
18
|
+
const byValue = new Map(flat.map((item) => [item.value, item]));
|
|
19
|
+
const selected = value === null ? null : (byValue.get(value) ?? null);
|
|
20
|
+
const comboboxItems = groups
|
|
21
|
+
? groups.map((group) => ({ value: group.label, items: group.items.map((item) => item.value) }))
|
|
22
|
+
: flat.map((item) => item.value);
|
|
23
|
+
const searchLabel = searchPlaceholder ?? t('select.search');
|
|
24
|
+
const renderRow = (itemValue) => {
|
|
25
|
+
const item = byValue.get(itemValue);
|
|
26
|
+
return (_jsx(Combobox.Item, { className: rowClassName(item), value: item.value, disabled: item.disabled, children: _jsx(RowContent, { item: item, picked: item.value === value, text: _jsx("span", { className: "truncate", children: item.label }) }) }, item.value));
|
|
27
|
+
};
|
|
28
|
+
return (_jsxs(Combobox.Root, { items: comboboxItems, value: value, disabled: disabled, autoHighlight: true, itemToStringLabel: (itemValue) => byValue.get(itemValue)?.label ?? itemValue, onValueChange: (next) => {
|
|
29
|
+
if (next !== null) {
|
|
30
|
+
onValueChange(next);
|
|
31
|
+
}
|
|
32
|
+
}, onInputValueChange: setQuery, onOpenChange: (open) => {
|
|
33
|
+
if (!open) {
|
|
34
|
+
setQuery('');
|
|
35
|
+
}
|
|
36
|
+
}, children: [_jsxs(Combobox.Trigger, { ref: ref, "aria-label": label, className: clsx('flex shrink-0 items-center whitespace-nowrap text-xs', TRIGGER_SIZE[size], TRIGGER_VARIANT[variant], TRIGGER_DISABLED, className), children: [selected?.icon, _jsx("span", { className: clsx(truncateValue && 'truncate'), children: selected?.label ?? placeholder ?? t('action.select') }), _jsx("span", { className: "ml-auto flex shrink-0 pl-1", children: _jsx(Icon, { icon: ChevronDown, size: 14 }) })] }), _jsx(Combobox.Portal, { children: _jsx(Combobox.Positioner, { className: "popup-positioner", render: renderPositioner, side: "bottom", align: align, sideOffset: 6, children: _jsxs(Combobox.Popup, { className: "menu-popup flex max-h-96 flex-col", children: [_jsxs("label", { className: "flex shrink-0 items-center gap-2 border-b border-border px-3", children: [_jsx(Icon, { icon: Search, size: 14, className: "shrink-0 text-text-faint" }), _jsx(Combobox.Input, { "aria-label": searchLabel, placeholder: searchLabel, className: "h-9 min-w-0 flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-faint" })] }), _jsx(Combobox.Empty, { className: "px-3 py-4 text-center text-xs text-text-faint empty:hidden", children: t('select.noMatch', { query }) }), _jsx(Combobox.List, { className: "min-h-0 overflow-auto", children: groups
|
|
37
|
+
? (group) => (_jsxs(Combobox.Group, { items: group.items, children: [_jsx(Combobox.GroupLabel, { className: MENU_LABEL, children: group.value }), _jsx(Combobox.Collection, { children: renderRow })] }, group.value))
|
|
38
|
+
: renderRow })] }) }) })] }));
|
|
39
|
+
}
|
package/dist/locales/en.json
CHANGED
package/dist/locales/nl.json
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { SelectItem } from './select-shared.ts';
|
|
3
|
+
export declare function RowContent<T extends string>({ item, picked, text }: {
|
|
4
|
+
item: SelectItem<T>;
|
|
5
|
+
picked: boolean;
|
|
6
|
+
text: ReactNode;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { MenuCheck } from './menu/parts.js';
|
|
3
|
+
/* The check, icon and text of a row; `text` is the element that carries the label, which Base UI wants to know. */
|
|
4
|
+
export function RowContent({ item, picked, text }) {
|
|
5
|
+
return (_jsxs(_Fragment, { children: [_jsx(MenuCheck, { kind: "radio", checked: picked }), item.icon && _jsx("span", { className: "flex h-lh shrink-0 items-center", children: item.icon }), _jsxs("span", { className: "flex min-w-0 flex-col", children: [text, item.description && _jsx("span", { className: "text-xs text-text-faint", children: item.description })] })] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
export interface SelectItem<T extends string> {
|
|
3
|
+
value: T;
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
icon?: ReactNode;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface SelectGroup<T extends string> {
|
|
10
|
+
label: string;
|
|
11
|
+
items: SelectItem<T>[];
|
|
12
|
+
}
|
|
13
|
+
export declare const isGroup: <T extends string>(entry: SelectItem<T> | SelectGroup<T>) => entry is SelectGroup<T>;
|
|
14
|
+
export declare const TRIGGER_SIZE: {
|
|
15
|
+
readonly sm: 'h-7 gap-1 px-2';
|
|
16
|
+
readonly md: 'h-8 gap-1.5 px-2';
|
|
17
|
+
};
|
|
18
|
+
export declare const TRIGGER_VARIANT: {
|
|
19
|
+
readonly outlined: 'max-w-56 rounded-lg border border-border bg-surface-raised text-text';
|
|
20
|
+
readonly ghost: 'rounded-md text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text data-disabled:hover:bg-transparent data-disabled:hover:text-text-muted';
|
|
21
|
+
};
|
|
22
|
+
export declare const TRIGGER_DISABLED = "data-disabled:cursor-default data-disabled:opacity-50";
|
|
23
|
+
export declare const rowClassName: (item: SelectItem<string>) => string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
export const isGroup = (entry) => Array.isArray(entry.items);
|
|
3
|
+
export const TRIGGER_SIZE = {
|
|
4
|
+
sm: 'h-7 gap-1 px-2',
|
|
5
|
+
md: 'h-8 gap-1.5 px-2'
|
|
6
|
+
};
|
|
7
|
+
export const TRIGGER_VARIANT = {
|
|
8
|
+
outlined: 'max-w-56 rounded-lg border border-border bg-surface-raised text-text',
|
|
9
|
+
ghost: 'rounded-md text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text data-disabled:hover:bg-transparent data-disabled:hover:text-text-muted'
|
|
10
|
+
};
|
|
11
|
+
/* The dimmed look `Switch` and `Button` wear when disabled, so a select that cannot open reads as such. */
|
|
12
|
+
export const TRIGGER_DISABLED = 'data-disabled:cursor-default data-disabled:opacity-50';
|
|
13
|
+
/* A description makes the row two lines high; the box keeps the icon on the label's line. */
|
|
14
|
+
export const rowClassName = (item) => clsx('menu-item', item.description && 'items-start');
|
package/package.json
CHANGED
package/src/Select.tsx
CHANGED
|
@@ -1,25 +1,16 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
2
|
import { Select as BaseSelect } from '@base-ui-components/react/select';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
4
|
import { ChevronDown } from 'lucide-react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import { Icon } from './Icon.tsx';
|
|
7
|
-
import { MENU_LABEL
|
|
7
|
+
import { MENU_LABEL } from './menu/parts.tsx';
|
|
8
8
|
import { renderPositioner } from './popup-layer.ts';
|
|
9
|
+
import { RowContent } from './select-row.tsx';
|
|
10
|
+
import { isGroup, rowClassName, TRIGGER_DISABLED, TRIGGER_SIZE, TRIGGER_VARIANT, type SelectGroup, type SelectItem } from './select-shared.ts';
|
|
11
|
+
import { SelectSearch } from './SelectSearch.tsx';
|
|
9
12
|
|
|
10
|
-
export
|
|
11
|
-
value: T;
|
|
12
|
-
label: string;
|
|
13
|
-
/* A second line under the label, for what the choice does. */
|
|
14
|
-
description?: string;
|
|
15
|
-
icon?: ReactNode;
|
|
16
|
-
disabled?: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface SelectGroup<T extends string> {
|
|
20
|
-
label: string;
|
|
21
|
-
items: SelectItem<T>[];
|
|
22
|
-
}
|
|
13
|
+
export type { SelectGroup, SelectItem };
|
|
23
14
|
|
|
24
15
|
export interface SelectProps<T extends string> {
|
|
25
16
|
value: T | null;
|
|
@@ -34,43 +25,35 @@ export interface SelectProps<T extends string> {
|
|
|
34
25
|
disabled?: boolean;
|
|
35
26
|
align?: 'start' | 'end';
|
|
36
27
|
truncateValue?: boolean;
|
|
28
|
+
/* A search field above the options: `true` always, a number once the list has at least that many options. */
|
|
29
|
+
searchable?: boolean | number;
|
|
30
|
+
/* What the search field says while it is empty, and its accessible name. */
|
|
31
|
+
searchPlaceholder?: string;
|
|
37
32
|
className?: string;
|
|
38
33
|
ref?: Ref<HTMLButtonElement>;
|
|
39
34
|
}
|
|
40
35
|
|
|
41
|
-
const isGroup = <T extends string>(entry: SelectItem<T> | SelectGroup<T>): entry is SelectGroup<T> => Array.isArray((entry as SelectGroup<T>).items);
|
|
42
|
-
|
|
43
|
-
const TRIGGER_SIZE = {
|
|
44
|
-
sm: 'h-7 gap-1 px-2',
|
|
45
|
-
md: 'h-8 gap-1.5 px-2'
|
|
46
|
-
} as const;
|
|
47
|
-
|
|
48
|
-
const TRIGGER_VARIANT = {
|
|
49
|
-
outlined: 'max-w-56 rounded-lg border border-border bg-surface-raised text-text',
|
|
50
|
-
ghost: 'rounded-md text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text data-disabled:hover:bg-transparent data-disabled:hover:text-text-muted'
|
|
51
|
-
} as const;
|
|
52
|
-
|
|
53
|
-
/* The dimmed look `Switch` and `Button` wear when disabled, so a select that cannot open reads as such. */
|
|
54
|
-
const TRIGGER_DISABLED = 'data-disabled:cursor-default data-disabled:opacity-50';
|
|
55
|
-
|
|
56
|
-
/* A description makes the row two lines high; the box keeps the icon on the label's line. */
|
|
57
36
|
function Row<T extends string>({ item, picked }: { item: SelectItem<T>; picked: boolean }) {
|
|
58
37
|
return (
|
|
59
|
-
<BaseSelect.Item className={
|
|
60
|
-
<
|
|
61
|
-
{item.icon && <span className="flex h-lh shrink-0 items-center">{item.icon}</span>}
|
|
62
|
-
<span className="flex min-w-0 flex-col">
|
|
63
|
-
<BaseSelect.ItemText>{item.label}</BaseSelect.ItemText>
|
|
64
|
-
{item.description && <span className="text-xs text-text-faint">{item.description}</span>}
|
|
65
|
-
</span>
|
|
38
|
+
<BaseSelect.Item className={rowClassName(item)} value={item.value} label={item.label} disabled={item.disabled}>
|
|
39
|
+
<RowContent item={item} picked={picked} text={<BaseSelect.ItemText>{item.label}</BaseSelect.ItemText>} />
|
|
66
40
|
</BaseSelect.Item>
|
|
67
41
|
);
|
|
68
42
|
}
|
|
69
43
|
|
|
70
44
|
/* The one select. A Base UI `Select` in the popup style the menus use, so a settings field and a
|
|
71
45
|
picker in a toolbar read the same. Base UI brings the keyboard along (arrows,
|
|
72
|
-
Home and End, typeahead, Enter, Escape), which a hand-rolled listbox would have to repeat.
|
|
73
|
-
|
|
46
|
+
Home and End, typeahead, Enter, Escape), which a hand-rolled listbox would have to repeat.
|
|
47
|
+
A long list can ask for a search field, which swaps in a combobox with the same trigger and rows. */
|
|
48
|
+
export function Select<T extends string>(props: SelectProps<T>) {
|
|
49
|
+
const { searchable, items } = props;
|
|
50
|
+
const count = items.length > 0 && isGroup(items[0]!) ? (items as SelectGroup<T>[]).reduce((sum, group) => sum + group.items.length, 0) : items.length;
|
|
51
|
+
const search = searchable === true || (typeof searchable === 'number' && count >= searchable);
|
|
52
|
+
|
|
53
|
+
return search ? <SelectSearch {...props} /> : <PlainSelect {...props} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function PlainSelect<T extends string>({
|
|
74
57
|
value,
|
|
75
58
|
onValueChange,
|
|
76
59
|
items,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Combobox } from '@base-ui-components/react/combobox';
|
|
3
|
+
import clsx from 'clsx';
|
|
4
|
+
import { ChevronDown, Search } from 'lucide-react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { Icon } from './Icon.tsx';
|
|
7
|
+
import { MENU_LABEL } from './menu/parts.tsx';
|
|
8
|
+
import { renderPositioner } from './popup-layer.ts';
|
|
9
|
+
import type { SelectProps } from './Select.tsx';
|
|
10
|
+
import { RowContent } from './select-row.tsx';
|
|
11
|
+
import { isGroup, rowClassName, TRIGGER_DISABLED, TRIGGER_SIZE, TRIGGER_VARIANT, type SelectGroup, type SelectItem } from './select-shared.ts';
|
|
12
|
+
|
|
13
|
+
/* The `Select` with a search field on top of its options, for lists too long to scan. A combobox of Base UI brings the filtering and the keyboard. */
|
|
14
|
+
export function SelectSearch<T extends string>({
|
|
15
|
+
value,
|
|
16
|
+
onValueChange,
|
|
17
|
+
items,
|
|
18
|
+
label,
|
|
19
|
+
placeholder,
|
|
20
|
+
searchPlaceholder,
|
|
21
|
+
size = 'md',
|
|
22
|
+
variant = 'outlined',
|
|
23
|
+
disabled,
|
|
24
|
+
align = 'start',
|
|
25
|
+
truncateValue = true,
|
|
26
|
+
className,
|
|
27
|
+
ref
|
|
28
|
+
}: SelectProps<T>) {
|
|
29
|
+
const { t } = useTranslation('ui');
|
|
30
|
+
const [query, setQuery] = useState('');
|
|
31
|
+
const groups = items.length > 0 && isGroup(items[0]!) ? (items as SelectGroup<T>[]) : null;
|
|
32
|
+
const flat = groups ? groups.flatMap((group) => group.items) : (items as SelectItem<T>[]);
|
|
33
|
+
const byValue = new Map(flat.map((item) => [item.value, item]));
|
|
34
|
+
const selected = value === null ? null : (byValue.get(value) ?? null);
|
|
35
|
+
const comboboxItems = groups
|
|
36
|
+
? groups.map((group) => ({ value: group.label, items: group.items.map((item) => item.value) }))
|
|
37
|
+
: flat.map((item) => item.value);
|
|
38
|
+
const searchLabel = searchPlaceholder ?? t('select.search');
|
|
39
|
+
|
|
40
|
+
const renderRow = (itemValue: T) => {
|
|
41
|
+
const item = byValue.get(itemValue)!;
|
|
42
|
+
return (
|
|
43
|
+
<Combobox.Item key={item.value} className={rowClassName(item)} value={item.value} disabled={item.disabled}>
|
|
44
|
+
<RowContent item={item} picked={item.value === value} text={<span className="truncate">{item.label}</span>} />
|
|
45
|
+
</Combobox.Item>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Combobox.Root<T>
|
|
51
|
+
items={comboboxItems}
|
|
52
|
+
value={value as T}
|
|
53
|
+
disabled={disabled}
|
|
54
|
+
autoHighlight
|
|
55
|
+
itemToStringLabel={(itemValue) => byValue.get(itemValue)?.label ?? itemValue}
|
|
56
|
+
onValueChange={(next) => {
|
|
57
|
+
if (next !== null) {
|
|
58
|
+
onValueChange(next);
|
|
59
|
+
}
|
|
60
|
+
}}
|
|
61
|
+
onInputValueChange={setQuery}
|
|
62
|
+
onOpenChange={(open) => {
|
|
63
|
+
if (!open) {
|
|
64
|
+
setQuery('');
|
|
65
|
+
}
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<Combobox.Trigger
|
|
69
|
+
ref={ref}
|
|
70
|
+
aria-label={label}
|
|
71
|
+
className={clsx(
|
|
72
|
+
'flex shrink-0 items-center whitespace-nowrap text-xs',
|
|
73
|
+
TRIGGER_SIZE[size],
|
|
74
|
+
TRIGGER_VARIANT[variant],
|
|
75
|
+
TRIGGER_DISABLED,
|
|
76
|
+
className
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{selected?.icon}
|
|
80
|
+
<span className={clsx(truncateValue && 'truncate')}>{selected?.label ?? placeholder ?? t('action.select')}</span>
|
|
81
|
+
<span className="ml-auto flex shrink-0 pl-1">
|
|
82
|
+
<Icon icon={ChevronDown} size={14} />
|
|
83
|
+
</span>
|
|
84
|
+
</Combobox.Trigger>
|
|
85
|
+
<Combobox.Portal>
|
|
86
|
+
<Combobox.Positioner className="popup-positioner" render={renderPositioner} side="bottom" align={align} sideOffset={6}>
|
|
87
|
+
<Combobox.Popup className="menu-popup flex max-h-96 flex-col">
|
|
88
|
+
<label className="flex shrink-0 items-center gap-2 border-b border-border px-3">
|
|
89
|
+
<Icon icon={Search} size={14} className="shrink-0 text-text-faint" />
|
|
90
|
+
<Combobox.Input
|
|
91
|
+
aria-label={searchLabel}
|
|
92
|
+
placeholder={searchLabel}
|
|
93
|
+
className="h-9 min-w-0 flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-faint"
|
|
94
|
+
/>
|
|
95
|
+
</label>
|
|
96
|
+
<Combobox.Empty className="px-3 py-4 text-center text-xs text-text-faint empty:hidden">{t('select.noMatch', { query })}</Combobox.Empty>
|
|
97
|
+
<Combobox.List className="min-h-0 overflow-auto">
|
|
98
|
+
{groups
|
|
99
|
+
? (group: { value: string; items: T[] }) => (
|
|
100
|
+
<Combobox.Group key={group.value} items={group.items}>
|
|
101
|
+
<Combobox.GroupLabel className={MENU_LABEL}>{group.value}</Combobox.GroupLabel>
|
|
102
|
+
<Combobox.Collection>{renderRow}</Combobox.Collection>
|
|
103
|
+
</Combobox.Group>
|
|
104
|
+
)
|
|
105
|
+
: renderRow}
|
|
106
|
+
</Combobox.List>
|
|
107
|
+
</Combobox.Popup>
|
|
108
|
+
</Combobox.Positioner>
|
|
109
|
+
</Combobox.Portal>
|
|
110
|
+
</Combobox.Root>
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/locales/en.json
CHANGED
package/src/locales/nl.json
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { MenuCheck } from './menu/parts.tsx';
|
|
3
|
+
import type { SelectItem } from './select-shared.ts';
|
|
4
|
+
|
|
5
|
+
/* The check, icon and text of a row; `text` is the element that carries the label, which Base UI wants to know. */
|
|
6
|
+
export function RowContent<T extends string>({ item, picked, text }: { item: SelectItem<T>; picked: boolean; text: ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
<MenuCheck kind="radio" checked={picked} />
|
|
10
|
+
{item.icon && <span className="flex h-lh shrink-0 items-center">{item.icon}</span>}
|
|
11
|
+
<span className="flex min-w-0 flex-col">
|
|
12
|
+
{text}
|
|
13
|
+
{item.description && <span className="text-xs text-text-faint">{item.description}</span>}
|
|
14
|
+
</span>
|
|
15
|
+
</>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
|
|
4
|
+
export interface SelectItem<T extends string> {
|
|
5
|
+
value: T;
|
|
6
|
+
label: string;
|
|
7
|
+
/* A second line under the label, for what the choice does. */
|
|
8
|
+
description?: string;
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SelectGroup<T extends string> {
|
|
14
|
+
label: string;
|
|
15
|
+
items: SelectItem<T>[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const isGroup = <T extends string>(entry: SelectItem<T> | SelectGroup<T>): entry is SelectGroup<T> => Array.isArray((entry as SelectGroup<T>).items);
|
|
19
|
+
|
|
20
|
+
export const TRIGGER_SIZE = {
|
|
21
|
+
sm: 'h-7 gap-1 px-2',
|
|
22
|
+
md: 'h-8 gap-1.5 px-2'
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export const TRIGGER_VARIANT = {
|
|
26
|
+
outlined: 'max-w-56 rounded-lg border border-border bg-surface-raised text-text',
|
|
27
|
+
ghost: 'rounded-md text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text data-disabled:hover:bg-transparent data-disabled:hover:text-text-muted'
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/* The dimmed look `Switch` and `Button` wear when disabled, so a select that cannot open reads as such. */
|
|
31
|
+
export const TRIGGER_DISABLED = 'data-disabled:cursor-default data-disabled:opacity-50';
|
|
32
|
+
|
|
33
|
+
/* A description makes the row two lines high; the box keeps the icon on the label's line. */
|
|
34
|
+
export const rowClassName = (item: SelectItem<string>): string => clsx('menu-item', item.description && 'items-start');
|