@adecore/ui 0.22.1 → 0.23.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/dist/Select.d.ts CHANGED
@@ -1,15 +1,6 @@
1
- import type { ReactNode, Ref } 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
- }
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>({ value, onValueChange, items, label, placeholder, size, variant, disabled, align, truncateValue, className, ref }: SelectProps<T>): import("react").JSX.Element;
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, MenuCheck } from './menu/parts.js';
7
+ import { MENU_LABEL } from './menu/parts.js';
8
8
  import { renderPositioner } from './popup-layer.js';
9
- const isGroup = (entry) => Array.isArray(entry.items);
10
- const TRIGGER_SIZE = {
11
- sm: 'h-7 gap-1 px-2',
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 (_jsxs(BaseSelect.Item, { className: clsx('menu-item', item.description && 'items-start'), value: item.value, label: item.label, disabled: item.disabled, 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: [_jsx(BaseSelect.ItemText, { children: item.label }), item.description && _jsx("span", { className: "text-xs text-text-faint", children: item.description })] })] }));
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
- export function Select({ value, onValueChange, items, label, placeholder, size = 'md', variant = 'outlined', disabled, align = 'start', truncateValue = true, className, ref }) {
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
+ }
@@ -34,6 +34,10 @@
34
34
  "largerFor": "{{label}}: larger"
35
35
  }
36
36
  },
37
+ "select": {
38
+ "search": "Search",
39
+ "noMatch": "No option matches “{{query}}”"
40
+ },
37
41
  "iconPicker": {
38
42
  "label": "Icon",
39
43
  "none": "No icon",
@@ -34,6 +34,10 @@
34
34
  "largerFor": "{{label}}: groter"
35
35
  }
36
36
  },
37
+ "select": {
38
+ "search": "Zoeken",
39
+ "noMatch": "Geen optie past bij “{{query}}”"
40
+ },
37
41
  "iconPicker": {
38
42
  "label": "Icoon",
39
43
  "none": "Geen icoon",
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/ui",
3
- "version": "0.22.1",
3
+ "version": "0.23.1",
4
4
  "description": "React components, a theme, formatters and a settings dialog for desktop-grade apps on React 19, Base UI and Tailwind 4.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
package/src/Select.tsx CHANGED
@@ -1,25 +1,16 @@
1
- import type { ReactNode, Ref } from 'react';
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, MenuCheck } from './menu/parts.tsx';
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 interface SelectItem<T extends string> {
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={clsx('menu-item', item.description && 'items-start')} value={item.value} label={item.label} disabled={item.disabled}>
60
- <MenuCheck kind="radio" checked={picked} />
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
- export function Select<T extends string>({
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
+ }
@@ -34,6 +34,10 @@
34
34
  "largerFor": "{{label}}: larger"
35
35
  }
36
36
  },
37
+ "select": {
38
+ "search": "Search",
39
+ "noMatch": "No option matches “{{query}}”"
40
+ },
37
41
  "iconPicker": {
38
42
  "label": "Icon",
39
43
  "none": "No icon",
@@ -34,6 +34,10 @@
34
34
  "largerFor": "{{label}}: groter"
35
35
  }
36
36
  },
37
+ "select": {
38
+ "search": "Zoeken",
39
+ "noMatch": "Geen optie past bij “{{query}}”"
40
+ },
37
41
  "iconPicker": {
38
42
  "label": "Icoon",
39
43
  "none": "Geen icoon",
@@ -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');