@adecore/ui 0.22.0 → 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.
@@ -11,7 +11,8 @@ export interface SegmentedProps<T extends string> {
11
11
  options: readonly SegmentedOption<T>[];
12
12
  label: string;
13
13
  disabled?: boolean;
14
+ orientation?: 'horizontal' | 'vertical';
14
15
  className?: string;
15
16
  ref?: Ref<HTMLDivElement>;
16
17
  }
17
- export declare function Segmented<T extends string>({ value, onValueChange, options, label, disabled, className, ref }: SegmentedProps<T>): import("react").JSX.Element;
18
+ export declare function Segmented<T extends string>({ value, onValueChange, options, label, disabled, orientation, className, ref }: SegmentedProps<T>): import("react").JSX.Element;
package/dist/Segmented.js CHANGED
@@ -1,7 +1,29 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import clsx from 'clsx';
3
3
  import { Icon } from './Icon.js';
4
- /* One of a few options side by side in one sunken track, the picked one lifted out of it. */
5
- export function Segmented({ value, onValueChange, options, label, disabled, className, ref }) {
6
- return (_jsx("div", { ref: ref, className: clsx('flex h-8 w-fit items-center rounded-lg bg-surface-sunken p-0.5 text-xs font-medium', className), role: "radiogroup", "aria-label": label, children: options.map((option) => (_jsxs("button", { type: "button", role: "radio", "aria-checked": value === option.id, disabled: disabled, className: clsx('flex h-7 items-center gap-1.5 rounded-md px-3 transition-colors disabled:opacity-50', value === option.id ? 'bg-surface-raised text-text shadow-raised' : 'text-text-muted hover:text-text'), onClick: () => onValueChange(option.id), children: [option.icon && _jsx(Icon, { icon: option.icon, size: 14 }), option.label] }, option.id))) }));
4
+ const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
5
+ /*
6
+ * One of a few options side by side in one sunken track, the picked one lifted out of it. A radio
7
+ * group: one tab stop, and the arrows pick and focus the next option, around the ends.
8
+ */
9
+ export function Segmented({ value, onValueChange, options, label, disabled, orientation = 'horizontal', className, ref }) {
10
+ const vertical = orientation === 'vertical';
11
+ const picked = options.findIndex((option) => option.id === value);
12
+ const stop = picked === -1 ? 0 : picked;
13
+ function move(event, index) {
14
+ const next = event.key in STEPS
15
+ ? (index + STEPS[event.key] + options.length) % options.length
16
+ : event.key === 'Home'
17
+ ? 0
18
+ : event.key === 'End'
19
+ ? options.length - 1
20
+ : null;
21
+ if (next === null) {
22
+ return;
23
+ }
24
+ event.preventDefault();
25
+ onValueChange(options[next].id);
26
+ event.currentTarget.parentElement?.querySelectorAll('[role="radio"]')[next]?.focus();
27
+ }
28
+ return (_jsx("div", { ref: ref, className: clsx('flex rounded-lg bg-surface-sunken p-0.5 text-xs font-medium', vertical ? 'w-full flex-col items-stretch gap-0.5' : 'min-h-8 w-fit items-center', className), role: "radiogroup", "aria-label": label, "aria-orientation": vertical ? 'vertical' : undefined, children: options.map((option, index) => (_jsxs("button", { type: "button", role: "radio", "aria-checked": value === option.id, tabIndex: index === stop ? 0 : -1, disabled: disabled, className: clsx('flex min-h-7 items-center gap-1.5 rounded-md px-3 py-1 text-left transition-colors disabled:opacity-50', value === option.id ? 'bg-surface-raised text-text shadow-raised' : 'text-text-muted hover:text-text'), onClick: () => onValueChange(option.id), onKeyDown: (event) => move(event, index), children: [option.icon && _jsx(Icon, { icon: option.icon, size: 14, className: "shrink-0" }), option.label] }, option.id))) }));
7
29
  }
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.0",
3
+ "version": "0.23.0",
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/Segmented.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { Ref } from 'react';
1
+ import type { KeyboardEvent, Ref } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import type { LucideIcon } from 'lucide-react';
4
4
  import { Icon } from './Icon.tsx';
@@ -16,33 +16,68 @@ export interface SegmentedProps<T extends string> {
16
16
  /* The accessible name of the group. */
17
17
  label: string;
18
18
  disabled?: boolean;
19
+ /* `vertical` stacks the options in a column as wide as its container, for labels too long to stand side by side. */
20
+ orientation?: 'horizontal' | 'vertical';
19
21
  className?: string;
20
22
  ref?: Ref<HTMLDivElement>;
21
23
  }
22
24
 
23
- /* One of a few options side by side in one sunken track, the picked one lifted out of it. */
24
- export function Segmented<T extends string>({ value, onValueChange, options, label, disabled, className, ref }: SegmentedProps<T>) {
25
+ const STEPS: Readonly<Record<string, number>> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
26
+
27
+ /*
28
+ * One of a few options side by side in one sunken track, the picked one lifted out of it. A radio
29
+ * group: one tab stop, and the arrows pick and focus the next option, around the ends.
30
+ */
31
+ export function Segmented<T extends string>({ value, onValueChange, options, label, disabled, orientation = 'horizontal', className, ref }: SegmentedProps<T>) {
32
+ const vertical = orientation === 'vertical';
33
+ const picked = options.findIndex((option) => option.id === value);
34
+ const stop = picked === -1 ? 0 : picked;
35
+
36
+ function move(event: KeyboardEvent<HTMLButtonElement>, index: number) {
37
+ const next =
38
+ event.key in STEPS
39
+ ? (index + STEPS[event.key]! + options.length) % options.length
40
+ : event.key === 'Home'
41
+ ? 0
42
+ : event.key === 'End'
43
+ ? options.length - 1
44
+ : null;
45
+ if (next === null) {
46
+ return;
47
+ }
48
+ event.preventDefault();
49
+ onValueChange(options[next]!.id);
50
+ event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[next]?.focus();
51
+ }
52
+
25
53
  return (
26
54
  <div
27
55
  ref={ref}
28
- className={clsx('flex h-8 w-fit items-center rounded-lg bg-surface-sunken p-0.5 text-xs font-medium', className)}
56
+ className={clsx(
57
+ 'flex rounded-lg bg-surface-sunken p-0.5 text-xs font-medium',
58
+ vertical ? 'w-full flex-col items-stretch gap-0.5' : 'min-h-8 w-fit items-center',
59
+ className
60
+ )}
29
61
  role="radiogroup"
30
62
  aria-label={label}
63
+ aria-orientation={vertical ? 'vertical' : undefined}
31
64
  >
32
- {options.map((option) => (
65
+ {options.map((option, index) => (
33
66
  <button
34
67
  key={option.id}
35
68
  type="button"
36
69
  role="radio"
37
70
  aria-checked={value === option.id}
71
+ tabIndex={index === stop ? 0 : -1}
38
72
  disabled={disabled}
39
73
  className={clsx(
40
- 'flex h-7 items-center gap-1.5 rounded-md px-3 transition-colors disabled:opacity-50',
74
+ 'flex min-h-7 items-center gap-1.5 rounded-md px-3 py-1 text-left transition-colors disabled:opacity-50',
41
75
  value === option.id ? 'bg-surface-raised text-text shadow-raised' : 'text-text-muted hover:text-text'
42
76
  )}
43
77
  onClick={() => onValueChange(option.id)}
78
+ onKeyDown={(event) => move(event, index)}
44
79
  >
45
- {option.icon && <Icon icon={option.icon} size={14} />}
80
+ {option.icon && <Icon icon={option.icon} size={14} className="shrink-0" />}
46
81
  {option.label}
47
82
  </button>
48
83
  ))}
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');