diff --git a/paperless/manifest.json b/paperless/manifest.json
index f1679d5..977980c 100644
--- a/paperless/manifest.json
+++ b/paperless/manifest.json
@@ -1,7 +1,7 @@
{
"id": "paperless",
"name": "Paperless-ngx",
- "version": "1.1.1",
+ "version": "1.2.0",
"author": "Bulwark Mail Community",
"description": "Send attachments and messages to Paperless-ngx, find your documents there and attach them to new mail.",
"type": "ui-extension",
@@ -20,7 +20,7 @@
"ui:download-file"
],
"entrypoint": "index.js",
- "minAppVersion": "1.15.0",
+ "minAppVersion": "1.16.0",
"icon": "media/icon.svg",
"banner": "media/banner.svg",
"configSchema": {
diff --git a/paperless/package-lock.json b/paperless/package-lock.json
index f35fab4..9ee1969 100644
--- a/paperless/package-lock.json
+++ b/paperless/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bulwark-plugin-paperless",
- "version": "1.1.1",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-plugin-paperless",
- "version": "1.1.1",
+ "version": "1.2.0",
"devDependencies": {
"esbuild": "^0.28.1"
}
diff --git a/paperless/package.json b/paperless/package.json
index 2ef37f3..5067c2a 100644
--- a/paperless/package.json
+++ b/paperless/package.json
@@ -1,6 +1,6 @@
{
"name": "bulwark-plugin-paperless",
- "version": "1.1.1",
+ "version": "1.2.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/paperless/paperless.zip b/paperless/paperless.zip
index 7a9dfa3..31107eb 100644
Binary files a/paperless/paperless.zip and b/paperless/paperless.zip differ
diff --git a/paperless/src/ui/common.jsx b/paperless/src/ui/common.jsx
index a24ac47..9c8eff8 100644
--- a/paperless/src/ui/common.jsx
+++ b/paperless/src/ui/common.jsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { Alert, Badge, Button, Checkbox, EmptyState, Field, LinkButton, Row, Select, Stack, Text } from '@plugin-host/ui';
+import { Alert, Badge, Button, Combobox, EmptyState, Field, LinkButton, Stack } from '@plugin-host/ui';
import { getApi, t, hostOf } from './host.js';
import { clientFor, connectPersonal, resolveConnection } from '../lib/connection.js';
import { errorText } from '../lib/errors.js';
@@ -73,41 +73,54 @@ export function ConnectEmptyState({ connection, onConnected }) {
);
}
-/** A select over Paperless objects, with an empty "none" entry. */
-export function PickOne({ label, items, value, onChange, noneLabel, suggested, block = true }) {
+/** A searchable combobox over Paperless objects, with an empty "none" entry. */
+export function PickOne({ label, items, value, onChange, noneLabel, suggested, block = true, placeholder }) {
+ const options = useMemo(() => [
+ { value: '', label: noneLabel || t('common.none') },
+ ...(items || []).map((it) => ({ value: String(it.id), label: it.name })),
+ ], [items, noneLabel]);
+
+ const handleChange = useCallback((v) => {
+ onChange?.(v === '' ? null : Number(v));
+ }, [onChange]);
+
+ const strValue = value == null ? '' : String(value);
+ const fieldLabel = suggested ? <>{label} {t('send.suggested')}> : label;
+
return (
- {label} {t('send.suggested')}> : label}>
-
);
}
-/** Tags as a row of check boxes. */
-export function TagPicker({ tags, value, onChange, label }) {
- const on = new Set(value || []);
- if (!tags?.length) return null;
- const toggle = (id) => {
- const next = new Set(on);
- if (next.has(id)) next.delete(id); else next.add(id);
- onChange([...next]);
- };
+
+/** Tags selector using multiselect Combobox. */
+export function TagPicker({ tags, value = [], onChange, label }) {
+ const options = useMemo(() => (tags || []).map((t) => ({ value: String(t.id), label: t.name })), [tags]);
+ const stringValues = useMemo(() => (value || []).map(String), [value]);
+ const handleChange = useCallback((newVals) => {
+ onChange?.(newVals.map(Number));
+ }, [onChange]);
const name = label || t('field.tags');
+ if (!tags?.length) return null;
return (
-
-
- {name}
-
- {tags.map((tag) => (
- toggle(tag.id)} />
- ))}
-
-
-
+
+
+
);
}
diff --git a/paperless/test/combobox.test.mjs b/paperless/test/combobox.test.mjs
new file mode 100644
index 0000000..770ac92
--- /dev/null
+++ b/paperless/test/combobox.test.mjs
@@ -0,0 +1,378 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { act, createElement } from 'react';
+import { createRoot } from 'react-dom/client';
+import { PickOne, TagPicker } from '../src/ui/common.jsx';
+import { fakeApi } from '../../test-utils/fake-api.mjs';
+import { setApi } from '../src/ui/host.js';
+import manifest from '../manifest.json' with { type: 'json' };
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+async function settle() {
+ for (let i = 0; i < 5; i++) await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
+}
+
+async function mount(Component, props = {}) {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ await act(async () => { root.render(createElement(Component, props)); });
+ await settle();
+ return {
+ container,
+ text: () => container.textContent,
+ $: (sel) => container.querySelector(sel),
+ $$: (sel) => [...container.querySelectorAll(sel)],
+ rerender: async (next) => { await act(async () => { root.render(createElement(Component, next)); }); await settle(); },
+ unmount: () => act(() => root.unmount()),
+ };
+}
+
+async function type(input, value) {
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
+ await act(async () => {
+ setter.call(input, value);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await settle();
+}
+
+async function click(el) {
+ await act(async () => {
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+ await settle();
+}
+
+const items = [
+ { id: 1, name: 'ACME Hosting' },
+ { id: 2, name: 'Stadtwerke Hildesheim' },
+ { id: 4, name: 'Bob Example' },
+];
+
+describe('PickOne in searchable mode', () => {
+ afterEach(() => {
+ setApi(null);
+ document.body.innerHTML = '';
+ });
+
+ const setup = () => {
+ const api = fakeApi({ translations: manifest.locales, locale: 'en' });
+ setApi(api);
+ return api;
+ };
+
+ it('renders input with combobox role and placeholder when value is null', async () => {
+ setup();
+ const ui = await mount(PickOne, { items, value: null, noneLabel: 'None' });
+ const input = ui.$('input[role="combobox"]');
+ expect(input).toBeTruthy();
+ expect(input.value).toBe('');
+ expect(input.placeholder).toBe('None');
+ expect(input.getAttribute('aria-expanded')).toBe('false');
+ await ui.unmount();
+ });
+
+ it('displays the selected item name when value is provided', async () => {
+ setup();
+ const ui = await mount(PickOne, { items, value: 1 });
+ const input = ui.$('input[role="combobox"]');
+ expect(input.value).toBe('ACME Hosting');
+ await ui.unmount();
+ });
+
+ it('opens dropdown list on focus or click', async () => {
+ setup();
+ const ui = await mount(PickOne, { items, value: null });
+ const input = ui.$('input[role="combobox"]');
+ expect(ui.$('[role="listbox"]')).toBeNull();
+
+ await click(input);
+
+ const listbox = ui.$('[role="listbox"]');
+ expect(listbox).toBeTruthy();
+ expect(input.getAttribute('aria-expanded')).toBe('true');
+
+ // Shows None plus all items
+ const options = ui.$$('[role="option"]');
+ expect(options).toHaveLength(4);
+ expect(options.map((o) => o.textContent.trim())).toEqual(['None', 'ACME Hosting', 'Stadtwerke Hildesheim', 'Bob Example']);
+ await ui.unmount();
+ });
+
+ it('filters options as the user types', async () => {
+ setup();
+ const ui = await mount(PickOne, { items, value: null });
+ const input = ui.$('input[role="combobox"]');
+
+ await type(input, 'stadt');
+ const options = ui.$$('[role="option"]');
+ expect(options).toHaveLength(1);
+ expect(options[0].textContent).toContain('Stadtwerke Hildesheim');
+ await ui.unmount();
+ });
+
+ it('selects an option on click and calls onChange with item ID', async () => {
+ setup();
+ const onChange = vi.fn();
+ const ui = await mount(PickOne, { items, value: null, onChange });
+ const input = ui.$('input[role="combobox"]');
+
+ await click(input);
+ const options = ui.$$('[role="option"]');
+ const bobOption = options.find((o) => o.textContent.includes('Bob Example'));
+ await click(bobOption);
+
+ expect(onChange).toHaveBeenCalledWith(4);
+ expect(input.value).toBe('Bob Example');
+ expect(ui.$('[role="listbox"]')).toBeNull();
+ await ui.unmount();
+ });
+
+ it('selects None and calls onChange with null', async () => {
+ setup();
+ const onChange = vi.fn();
+ const ui = await mount(PickOne, { items, value: 4, onChange });
+ const input = ui.$('input[role="combobox"]');
+
+ await click(input);
+ const options = ui.$$('[role="option"]');
+ const noneOption = options[0];
+ await click(noneOption);
+
+ expect(onChange).toHaveBeenCalledWith(null);
+ expect(input.value).toBe('');
+ await ui.unmount();
+ });
+
+ it('navigates options with ArrowDown and selects with Enter', async () => {
+ setup();
+ const onChange = vi.fn();
+ const ui = await mount(PickOne, { items, value: null, onChange });
+ const input = ui.$('input[role="combobox"]');
+
+ // ArrowDown to open
+ await act(async () => {
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
+ });
+ await settle();
+ expect(ui.$('[role="listbox"]')).toBeTruthy();
+
+ // ArrowDown to move to first real item (ACME Hosting)
+ await act(async () => {
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
+ });
+ await settle();
+
+ // Enter to select
+ await act(async () => {
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ });
+ await settle();
+
+ expect(onChange).toHaveBeenCalledWith(1);
+ expect(input.value).toBe('ACME Hosting');
+ expect(ui.$('[role="listbox"]')).toBeNull();
+ await ui.unmount();
+ });
+
+ it('clears selection when the clear button is clicked', async () => {
+ setup();
+ const onChange = vi.fn();
+ const ui = await mount(PickOne, { items, value: 1, onChange });
+ const input = ui.$('input[role="combobox"]');
+ expect(input.value).toBe('ACME Hosting');
+
+ const clearBtn = ui.$('button[aria-label="Clear"]');
+ expect(clearBtn).toBeTruthy();
+ await click(clearBtn);
+
+ expect(onChange).toHaveBeenCalledWith(null);
+ expect(input.value).toBe('');
+ expect(ui.$('[role="listbox"]')).toBeNull();
+ await ui.unmount();
+ });
+
+ it('closes dropdown and reverts input on Escape', async () => {
+ setup();
+ const ui = await mount(PickOne, { items, value: 1 });
+ const input = ui.$('input[role="combobox"]');
+
+ await click(input);
+ await type(input, 'something unknown');
+ expect(ui.$('[role="listbox"]')).toBeTruthy();
+
+ await act(async () => {
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ });
+ await settle();
+
+ expect(ui.$('[role="listbox"]')).toBeNull();
+ expect(input.value).toBe('ACME Hosting');
+ await ui.unmount();
+ });
+});
+
+describe('PickOne suggested badge', () => {
+ afterEach(() => {
+ setApi(null);
+ document.body.innerHTML = '';
+ });
+
+ it('renders Combobox and includes label & suggested badge', async () => {
+ fakeApi({ translations: manifest.locales, locale: 'en' });
+ setApi(fakeApi({ translations: manifest.locales, locale: 'en' }));
+ const ui = await mount(PickOne, {
+ label: 'Correspondent',
+ items,
+ value: 1,
+ onChange: () => {},
+ suggested: true,
+ });
+ const input = ui.$('input[role="combobox"]');
+ expect(input).toBeTruthy();
+ expect(input.value).toBe('ACME Hosting');
+ expect(ui.text().toLowerCase()).toContain('suggested');
+ await ui.unmount();
+ });
+});
+
+describe('SendDialog with Searchable Correspondent', () => {
+ afterEach(() => {
+ setApi(null);
+ document.body.innerHTML = '';
+ });
+
+ it('renders Correspondent and Document type with searchable comboboxes', async () => {
+ const api = fakeApi({
+ translations: manifest.locales,
+ locale: 'en',
+ handlers: {
+ 'proxy.fetch': async (_cred, url) => {
+ if (url.includes('/api/correspondents/')) {
+ return {
+ status: 200, ok: true, headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ results: items, next: null }),
+ };
+ }
+ if (url.includes('/api/document_types/')) {
+ return {
+ status: 200, ok: true, headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ results: [{ id: 10, name: 'Invoice' }], next: null }),
+ };
+ }
+ if (url.includes('/api/tags/')) {
+ return {
+ status: 200, ok: true, headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ results: [], next: null }),
+ };
+ }
+ if (url.includes('/api/storage_paths/')) {
+ return {
+ status: 200, ok: true, headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ results: [{ id: 99, name: 'Path 1' }], next: null }),
+ };
+ }
+ return {
+ status: 200, ok: true, headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ count: 0, results: [] }),
+ };
+ },
+ },
+ });
+ setApi(api);
+
+ const { SendDialog } = await import('../src/ui/SendDialog.jsx');
+
+ const groups = [{
+ emailId: 'm1',
+ subject: 'Invoice 10',
+ sender: { name: 'ACME', email: 'billing@acme-hosting.example' },
+ items: [{
+ key: 'item-1',
+ name: 'invoice.pdf',
+ size: 1024,
+ selected: true,
+ sha256: 'abc',
+ md5: 'def',
+ }],
+ }];
+
+ const connection = { state: 'connected', base: 'https://paperless.example', credentialId: 'c1' };
+ const ui = await mount(SendDialog, { groups, connection });
+
+ // Wait for taxonomies to load
+ for (let i = 0; i < 10; i++) await settle();
+
+ // Correspondent, Document type, and Storage path are inputs with combobox role
+ const comboboxes = ui.$$('input[role="combobox"]');
+ expect(comboboxes).toHaveLength(3);
+ expect(ui.$('select')).toBeNull();
+
+ await ui.unmount();
+ });
+});
+
+describe('TagPicker', () => {
+ afterEach(() => {
+ setApi(null);
+ document.body.innerHTML = '';
+ });
+
+ const tags = [
+ { id: 101, name: 'Important' },
+ { id: 102, name: 'Tax 2026' },
+ { id: 103, name: 'Warranty' },
+ ];
+
+ it('renders nothing when tags list is empty or undefined', async () => {
+ fakeApi({ translations: manifest.locales, locale: 'en' });
+ setApi(fakeApi({ translations: manifest.locales, locale: 'en' }));
+ const ui = await mount(TagPicker, { tags: [] });
+ expect(ui.container.innerHTML).toBe('');
+ await ui.unmount();
+ });
+
+ it('renders multiselect combobox with tags as options and selected tags as chips', async () => {
+ fakeApi({ translations: manifest.locales, locale: 'en' });
+ setApi(fakeApi({ translations: manifest.locales, locale: 'en' }));
+ const onChange = vi.fn();
+ const ui = await mount(TagPicker, { tags, value: [101], onChange });
+
+ // Renders selected chip
+ const chip = ui.$('[role="checkbox"][aria-label="Important"]');
+ expect(chip).toBeTruthy();
+ expect(chip.getAttribute('aria-checked')).toBe('true');
+
+ // Remove chip
+ const removeBtn = ui.$('button[aria-label="Remove Important"]');
+ expect(removeBtn).toBeTruthy();
+ await click(removeBtn);
+ expect(onChange).toHaveBeenCalledWith([]);
+ expect(ui.$('[role="listbox"]')).toBeNull();
+
+ await ui.unmount();
+ });
+
+ it('allows selecting new tags from dropdown', async () => {
+ fakeApi({ translations: manifest.locales, locale: 'en' });
+ setApi(fakeApi({ translations: manifest.locales, locale: 'en' }));
+ const onChange = vi.fn();
+ const ui = await mount(TagPicker, { tags, value: [101], onChange });
+
+ const input = ui.$('input[role="combobox"]');
+ await click(input);
+
+ const options = ui.$$('[role="option"]');
+ const warrantyOpt = options.find((o) => o.textContent.includes('Warranty'));
+ expect(warrantyOpt).toBeTruthy();
+ await click(warrantyOpt);
+
+ expect(onChange).toHaveBeenCalledWith([101, 103]);
+ await ui.unmount();
+ });
+});
+
+
diff --git a/test-utils/ui-kit.mjs b/test-utils/ui-kit.mjs
index 578c54d..46546e4 100644
--- a/test-utils/ui-kit.mjs
+++ b/test-utils/ui-kit.mjs
@@ -10,7 +10,7 @@ import * as React from 'react';
const h = React.createElement;
const cls = (name) => `bk-${name}`;
-export const KIT_VERSION = 1;
+export const KIT_VERSION = 2;
export function Icon({ name }) {
return h('span', { 'data-icon': name ?? 'puzzle', 'aria-hidden': 'true' });
@@ -55,6 +55,328 @@ export function Select({ options, children, onChange, block, ...rest }) {
options?.map((o) => h('option', { key: o.value, value: o.value, disabled: o.disabled }, o.label)), children);
}
+export function Combobox({
+ options = [],
+ value,
+ onChange,
+ placeholder,
+ disabled = false,
+ block = false,
+ multiple = false,
+ clearable = true,
+ id,
+ ariaLabel,
+ className,
+}) {
+ void block;
+ void className;
+ const autoId = React.useId();
+ const inputId = id ?? autoId;
+ const listboxId = `${inputId}-listbox`;
+
+ const containerRef = React.useRef(null);
+ const inputRef = React.useRef(null);
+ const optionRefs = React.useRef([]);
+
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [query, setQuery] = React.useState('');
+ const [isTyping, setIsTyping] = React.useState(false);
+ const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
+
+ const selectedValues = React.useMemo(() => {
+ if (multiple) {
+ return Array.isArray(value) ? value : value ? [value] : [];
+ }
+ return [];
+ }, [multiple, value]);
+
+ const singleValue = React.useMemo(() => {
+ if (multiple) return '';
+ return typeof value === 'string' ? value : '';
+ }, [multiple, value]);
+
+ const selectedOption = React.useMemo(() => {
+ if (multiple || !singleValue) return null;
+ return options.find((o) => o.value === singleValue) || null;
+ }, [multiple, singleValue, options]);
+
+ const selectedOptions = React.useMemo(() => {
+ if (!multiple) return [];
+ return options.filter((o) => selectedValues.includes(o.value));
+ }, [multiple, options, selectedValues]);
+
+ React.useEffect(() => {
+ if (!isTyping) {
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOption ? selectedOption.label : '');
+ }
+ }
+ }, [selectedOption, isTyping, multiple]);
+
+ const filteredOptions = React.useMemo(() => {
+ if (!isTyping || !query.trim()) return options;
+ const q = query.trim().toLowerCase();
+ return options.filter((o) => o.label.toLowerCase().includes(q));
+ }, [options, isTyping, query]);
+
+ React.useEffect(() => {
+ if (isOpen) {
+ if (multiple) {
+ setHighlightedIndex(filteredOptions.length > 0 ? 0 : -1);
+ } else {
+ const idx = filteredOptions.findIndex((o) => o.value === singleValue);
+ setHighlightedIndex(idx >= 0 ? idx : filteredOptions.length > 0 ? 0 : -1);
+ }
+ } else {
+ setHighlightedIndex(-1);
+ }
+ }, [isOpen]);
+
+ React.useEffect(() => {
+ if (isOpen && highlightedIndex >= 0 && optionRefs.current[highlightedIndex]) {
+ const el = optionRefs.current[highlightedIndex];
+ if (typeof el?.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
+ }
+ }, [isOpen, highlightedIndex]);
+
+ React.useEffect(() => {
+ if (!isOpen) return;
+ const onDocMouseDown = (e) => {
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
+ setIsOpen(false);
+ setIsTyping(false);
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOption ? selectedOption.label : '');
+ }
+ }
+ };
+ document.addEventListener('mousedown', onDocMouseDown);
+ return () => document.removeEventListener('mousedown', onDocMouseDown);
+ }, [isOpen, selectedOption, multiple]);
+
+ const toggleMultipleOption = (opt) => {
+ if (opt.disabled) return;
+ const exists = selectedValues.includes(opt.value);
+ const next = exists ? selectedValues.filter((v) => v !== opt.value) : [...selectedValues, opt.value];
+ onChange?.(next);
+ setQuery('');
+ setIsTyping(false);
+ if (isOpen) {
+ inputRef.current?.focus();
+ }
+ };
+
+ const selectOption = (opt) => {
+ if (opt.disabled) return;
+ if (multiple) {
+ toggleMultipleOption(opt);
+ } else {
+ onChange?.(opt.value);
+ setIsOpen(false);
+ setIsTyping(false);
+ setQuery(opt.value ? opt.label : '');
+ inputRef.current?.focus();
+ }
+ };
+
+ const handleClear = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (multiple) {
+ onChange?.([]);
+ } else {
+ onChange?.('');
+ }
+ setQuery('');
+ setIsTyping(false);
+ setIsOpen(false);
+ };
+
+ const handleToggle = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (disabled) return;
+ if (isOpen) {
+ setIsOpen(false);
+ setIsTyping(false);
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOption ? selectedOption.label : '');
+ }
+ } else {
+ setIsOpen(true);
+ setIsTyping(false);
+ inputRef.current?.focus();
+ }
+ };
+
+ const handleKeyDown = (e) => {
+ if (disabled) return;
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (!isOpen) {
+ setIsOpen(true);
+ setIsTyping(false);
+ } else if (filteredOptions.length > 0) {
+ setHighlightedIndex((prev) => (prev + 1 < filteredOptions.length ? prev + 1 : 0));
+ }
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (!isOpen) {
+ setIsOpen(true);
+ setIsTyping(false);
+ } else if (filteredOptions.length > 0) {
+ setHighlightedIndex((prev) => (prev - 1 >= 0 ? prev - 1 : filteredOptions.length - 1));
+ }
+ } else if (e.key === 'Enter') {
+ if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
+ e.preventDefault();
+ selectOption(filteredOptions[highlightedIndex]);
+ }
+ } else if (e.key === 'Escape') {
+ if (isOpen) {
+ e.preventDefault();
+ setIsOpen(false);
+ setIsTyping(false);
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOption ? selectedOption.label : '');
+ }
+ }
+ } else if (e.key === 'Tab') {
+ if (isOpen) {
+ setIsOpen(false);
+ setIsTyping(false);
+ if (multiple) {
+ setQuery('');
+ } else {
+ setQuery(selectedOption ? selectedOption.label : '');
+ }
+ }
+ } else if (e.key === 'Backspace' && multiple && query === '' && selectedValues.length > 0) {
+ const next = selectedValues.slice(0, -1);
+ onChange?.(next);
+ }
+ };
+
+ const hasValue = multiple ? selectedValues.length > 0 : Boolean(singleValue);
+
+ return h('div', { ref: containerRef, className: cls('combobox') },
+ h('div', { className: cls('combobox-control') },
+ multiple ? selectedOptions.map((opt) => (
+ h('span', {
+ key: opt.value,
+ role: 'checkbox',
+ 'aria-checked': true,
+ 'aria-label': opt.label,
+ className: cls('combobox-chip'),
+ },
+ opt.label,
+ !disabled ? h('button', {
+ type: 'button',
+ tabIndex: -1,
+ onMouseDown: (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ onClick: (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleMultipleOption(opt);
+ },
+ 'aria-label': `Remove ${opt.label}`,
+ }, '×') : null,
+ )
+ )) : null,
+ h('input', {
+ ref: inputRef,
+ id: inputId,
+ type: 'text',
+ role: 'combobox',
+ autoComplete: 'off',
+ disabled,
+ 'aria-expanded': isOpen,
+ 'aria-controls': listboxId,
+ 'aria-autocomplete': 'list',
+ 'aria-label': ariaLabel,
+ 'aria-activedescendant': isOpen && highlightedIndex >= 0 ? `${listboxId}-opt-${highlightedIndex}` : undefined,
+ value: query,
+ placeholder: multiple && selectedValues.length > 0 ? '' : placeholder,
+ onChange: (e) => {
+ setQuery(e.target.value);
+ setIsTyping(true);
+ if (!isOpen) setIsOpen(true);
+ },
+ onFocus: () => {
+ if (!disabled && !isOpen) {
+ setIsOpen(true);
+ setIsTyping(false);
+ }
+ },
+ onClick: () => {
+ if (!disabled && !isOpen) {
+ setIsOpen(true);
+ setIsTyping(false);
+ }
+ },
+ onKeyDown: handleKeyDown,
+ }),
+ clearable && hasValue && !disabled ? h('button', {
+ type: 'button',
+ tabIndex: -1,
+ onMouseDown: (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ onClick: handleClear,
+ 'aria-label': 'Clear',
+ }, '×') : null,
+ h('button', {
+ type: 'button',
+ tabIndex: -1,
+ disabled,
+ onMouseDown: (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ onClick: handleToggle,
+ 'aria-label': isOpen ? 'Close' : 'Open',
+ }, isOpen ? '▴' : '▾'),
+ ),
+ isOpen ? h('div', {
+ id: listboxId,
+ role: 'listbox',
+ 'aria-multiselectable': multiple || undefined,
+ 'aria-label': ariaLabel ?? placeholder,
+ className: cls('combobox-listbox'),
+ },
+ filteredOptions.length === 0
+ ? h('div', { className: cls('combobox-empty') }, 'No results')
+ : filteredOptions.map((opt, i) => {
+ const isSelected = multiple ? selectedValues.includes(opt.value) : opt.value === singleValue;
+ return h('div', {
+ key: opt.value,
+ ref: (el) => { optionRefs.current[i] = el; },
+ id: `${listboxId}-opt-${i}`,
+ role: 'option',
+ 'aria-selected': isSelected,
+ 'aria-disabled': opt.disabled,
+ 'data-highlighted': highlightedIndex === i || undefined,
+ onMouseDown: (e) => e.preventDefault(),
+ onClick: () => selectOption(opt),
+ onMouseEnter: () => setHighlightedIndex(i),
+ }, opt.label);
+ }),
+ ) : null,
+ );
+}
+
export function Checkbox({ checked, onChange, label, hint, disabled = false, id, ariaLabel }) {
const autoId = React.useId();
const boxId = id ?? autoId;