diff --git a/e2e/interpretation.spec.ts b/e2e/interpretation.spec.ts
index e7a52cd56..f39d3647f 100644
--- a/e2e/interpretation.spec.ts
+++ b/e2e/interpretation.spec.ts
@@ -148,20 +148,22 @@ test.describe('Entry Filtering', () => {
test('should expose customer/project/user filters defaulting to "all"', async ({ page }) => {
// The five relation filters (customer, project, team, user, activity) are
- // searchable comboboxes that each default to their "Alle" (value 0) entry.
+ // searchable comboboxes. Each defaults to its "Alle" (value 0) entry, which in
+ // the unified control shows as no value chip — an empty search field.
const filters = page.locator('form.filter-bar .searchable-select');
expect(await filters.count()).toBeGreaterThanOrEqual(5);
- await expect(filters.first().locator('.searchable-select-value')).toHaveText('Alle');
+ await expect(filters.first().locator('.tag-list .tag')).toHaveCount(0);
});
test('should keep the page functional after changing a filter', async ({ page }) => {
- // Open the first relation combobox (customer) and pick a real option past the
- // leading "Alle" entry — only when the seed data provides one.
- await page.locator('form.filter-bar .searchable-select-trigger').first().click();
- await expect(page.locator('.combobox-input').first()).toBeVisible();
- const realOption = page.locator('.combobox-content .combobox-item').nth(1);
- if (await realOption.isVisible()) {
- await realOption.click();
+ // Type into the first relation combobox (customer) to open + filter the list,
+ // then pick a real option — only when the seed data provides a match. Typing
+ // filters out the leading "Alle" entry, so the first result is a real option.
+ const firstCombo = page.locator('form.filter-bar .searchable-select').first();
+ await firstCombo.locator('.combobox-input').pressSequentially('e', { delay: 30 });
+ const option = firstCombo.locator('.combobox-item').first();
+ if (await option.isVisible().catch(() => false)) {
+ await option.click();
} else {
await page.keyboard.press('Escape');
}
diff --git a/frontend/src/components/SearchableSelect.test.tsx b/frontend/src/components/SearchableSelect.test.tsx
index de8f3107b..147129c68 100644
--- a/frontend/src/components/SearchableSelect.test.tsx
+++ b/frontend/src/components/SearchableSelect.test.tsx
@@ -50,12 +50,13 @@ describe('SearchableSelect (jsdom)', () => {
await waitFor(() => expect(onChange).toHaveBeenCalledWith(8))
})
- it('single: shows the current value and the "all" entry clears it to 0', async () => {
+ it('single: shows the current value as a chip and the "all" entry clears it to 0', async () => {
const onChange = vi.fn()
render(() => )
- // The chosen value is shown in the (compact) control.
- expect(screen.getByRole('button', { name: 'Customer' })).toHaveTextContent('Apollo')
+ // Single and multi share the chip layout, so the chosen value shows as its chip
+ // (the input beside it stays a bare search box).
+ expect(screen.getByRole('listitem')).toHaveTextContent('Apollo')
fireEvent.click(screen.getByRole('button', { name: 'Customer' }))
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(4))
@@ -63,12 +64,13 @@ describe('SearchableSelect (jsdom)', () => {
await waitFor(() => expect(onChange).toHaveBeenCalledWith(0))
})
- it('single: with no selection and no allLabel, shows the "none" placeholder', () => {
+ it('single: with no selection, shows an empty search control and no "all" entry', () => {
render(() => )
- // No "all" option requested → the compact control falls back to the generic
- // "none" placeholder rather than a blank control.
+ // Nothing chosen → no value chip, just the search input + ▾ trigger; and with no
+ // allLabel requested, the "all" entry is not offered.
expect(screen.getByRole('button', { name: 'Customer' })).toBeInTheDocument()
+ expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
expect(screen.queryByText('All')).not.toBeInTheDocument()
})
diff --git a/frontend/src/components/SearchableSelect.tsx b/frontend/src/components/SearchableSelect.tsx
index f1113056c..9d9974b18 100644
--- a/frontend/src/components/SearchableSelect.tsx
+++ b/frontend/src/components/SearchableSelect.tsx
@@ -1,9 +1,8 @@
import { Combobox } from '@ark-ui/solid/combobox'
import { createMemo, createSignal, For, type JSX, Show } from 'solid-js'
-import { Portal } from 'solid-js/web'
import type { NamedOption } from '../api/queries'
-import { ComboboxContent, ComboSearchIcon, comboCollection, type ComboItem } from '../lib/comboboxParts'
+import { ComboboxContent, comboCollection, type ComboItem } from '../lib/comboboxParts'
import { m } from '../paraglide/messages.js'
// The "all / none" pseudo-option (value 0) for an optional single select. A
@@ -12,18 +11,14 @@ import { m } from '../paraglide/messages.js'
const ALL_VALUE = '__all__'
/**
- * A searchable relation combobox for ordinary FORMS (not the admin grid),
- * built on the same Ark UI Combobox primitive + combobox CSS as the inline-grid
- * editor ({@link ChipSelect}), so it looks identical to the worklog table's cell
- * editor — but with a plain controlled form API (value / onChange) instead of
- * the grid's field/commit/cancel contract.
+ * A searchable relation combobox for ordinary FORMS (not the admin grid).
*
- * Two layouts share all the logic:
- * - SINGLE: a compact control showing the chosen label + a ▾; the search field
- * lives at the top of the (body-portalled) popup. An optional "all" (value 0)
- * entry clears the selection.
- * - MULTI: removable chips + a leading magnifier + the search input, all in the
- * control; the popup lists the options.
+ * Single and multi share ONE layout so they read the same (a maintainer request):
+ * the editable input IS the search field (WAI-ARIA 1.2 combobox), the chosen
+ * value(s) sit as chips beside it, and a trailing ▾ opens the list. Single differs
+ * only in that its chip is not removable (pick another option, or the "all" entry,
+ * to change it) and selecting closes the popup. Opening always shows ALL options —
+ * the current selection highlights, it never pre-filters the list.
*
* Boundary: Ark deals in string[]; relation ids are carried verbatim as the
* option's string value and converted back to numbers only in onChange.
@@ -71,42 +66,40 @@ export function SearchableSelect(props: {
})
const collection = createMemo(() => comboCollection(filteredItems()))
- // Single-select control display: the chosen label, or the "all" label when
- // nothing is chosen (value 0), falling back to a generic "none" placeholder.
- const singleDisplay = (): string => {
- const first = selectedValues()[0]
- if (first !== undefined) {
- return labelOf(first)
- }
-
- return props.allLabel ?? m.app_select_none()
- }
-
const removeValue = (value: string): void => {
props.onChange(selectedValues().filter((current) => current !== value).map(Number))
}
- const searchInput = (): JSX.Element => (
- { inputEl = element }}
- class="combobox-input"
- aria-label={props.label}
- placeholder={isMulti() && selectedValues().length === 0 ? m.app_type_to_add() : m.app_type_to_search()}
- />
- )
+ // The placeholder is only an invitation to search — shown while nothing is chosen.
+ // Once a value is picked its chip carries the display, so the input reads as a
+ // bare search box.
+ const placeholder = (): string => {
+ if (selectedValues().length > 0) {
+ return ''
+ }
+
+ return isMulti() ? m.app_type_to_add() : m.app_type_to_search()
+ }
return (
{props.label}
- {/* Multi: a leading magnifier in the control. Single's search is in the popup. */}
-
-
-
-
- {(value) => (
-
- {labelOf(value)}
+ {/* Announce selection changes — Ark's own live region only narrates the
+ highlighted option during navigation. */}
+
+ {selectedValues().map(labelOf).join(', ')}
+
+
+ {/* The chosen value(s) as chips — multi: removable; single: one, non-removable.
+ Outside Root so they share the field's wrapping flex row with the
+ (display:contents) input + ▾. */}
+
+
+ {(value) => (
+
+ {labelOf(value)}
+
-
- )}
-
-
-
-
- {/* Announce selection changes — Ark's own live region only narrates the
- highlighted option during navigation. */}
-
- {selectedValues().map(labelOf).join(', ')}
-
+
+
+ )}
+
+
setInputValue(details.inputValue)}
+ onInputValueChange={(details) => {
+ // Ark echoes a single-select's chosen label back into the input; suppress that
+ // so the input stays a bare (empty) search box and the value shows only as its
+ // chip. That also means reopening never pre-filters by the current selection
+ // (WAI-ARIA APG: the selection highlights, it does not filter). A real query —
+ // which differs from the chosen label — passes through untouched, so typing
+ // (even the keystroke that opens the list) filters normally.
+ const isSelectionEcho = !isMulti() && selectedValues().some((value) => labelOf(value) === details.inputValue)
+ setInputValue(isSelectionEcho ? '' : details.inputValue)
+ }}
onOpenChange={(details) => {
- // Single's search input lives in the popup — focus it once the popup
- // mounts so typing starts immediately. Clear a stale filter on close.
if (details.open) {
requestAnimationFrame(() => { if (inputEl?.isConnected) inputEl.focus() })
} else {
+ // Drop a typed query on close so the next open starts fresh with all options.
setInputValue('')
}
}}
@@ -158,34 +153,27 @@ export function SearchableSelect(props: {
closeOnSelect={!isMulti()}
allowCustomValue={false}
inputBehavior="autohighlight"
- positioning={{ sameWidth: true, gutter: 2, flip: true, fitViewport: true }}
+ positioning={{ strategy: 'fixed', sameWidth: true, gutter: 2, flip: true, fitViewport: true }}
>
{props.label}
- {/* Multi: search input next to the chips. Single: a value + ▾ trigger. */}
-
- {singleDisplay()}
- ▾
-
- }
- >
- {searchInput()}
- ▾
-
+ { inputEl = element }}
+ class="combobox-input"
+ aria-label={props.label}
+ aria-required={props.required === true ? 'true' : undefined}
+ placeholder={placeholder()}
+ />
+ ▾
- {/* Body-portal so the popup floats above cards/dialogs and is never clipped. */}
-
-
-
-
-
+ {/* Rendered inline (NOT body-portalled) with a fixed positioning strategy. The
+ popup stays in this field's DOM subtree, so when the field sits in a modal
+ dialog its focus trap can't inert the popup (a body-portalled popup is a
+ sibling of the dialog → inert → dead, the Billing-modal bug); `fixed` still
+ lets it break out of any card/table `overflow` and clamp to the viewport. */}
+
+
+
diff --git a/frontend/src/lib/chipSelect.tsx b/frontend/src/lib/chipSelect.tsx
index c4a02878b..0b781ad0e 100644
--- a/frontend/src/lib/chipSelect.tsx
+++ b/frontend/src/lib/chipSelect.tsx
@@ -313,7 +313,7 @@ export function ChipSelect(props: {
focus-out logic so opening it doesn't save/close the row. */}
-
+
diff --git a/frontend/src/lib/comboboxParts.tsx b/frontend/src/lib/comboboxParts.tsx
index 7b4fe9b44..42c3c1256 100644
--- a/frontend/src/lib/comboboxParts.tsx
+++ b/frontend/src/lib/comboboxParts.tsx
@@ -26,21 +26,26 @@ export const ComboSearchIcon = (): JSX.Element => (
/**
* The dropdown body shared by both comboboxes: an optional sticky search row at the
- * top (single-select only — multi keeps its search inline in the control), the
- * option list, and the empty state. Rendered inside the caller's `Combobox.Root` →
- * `Portal` → `Combobox.Positioner`, so Ark's context resolves normally.
+ * top, the option list, and the empty state. Rendered inside the caller's
+ * `Combobox.Root` → `Portal` → `Combobox.Positioner`, so Ark's context resolves
+ * normally.
+ *
+ * `popupSearch` decides where the search lives, decoupled from `multiple`: the grid
+ * cell editor ({@link ChipSelect}) puts a single-select's search HERE (a narrow
+ * column can't host an inline field), while the form control ({@link SearchableSelect})
+ * always searches inline and leaves this off.
*/
export function ComboboxContent(props: {
items: ComboItem[]
- multiple: boolean
- searchInput: () => JSX.Element
+ popupSearch?: boolean
+ searchInput?: () => JSX.Element
}): JSX.Element {
return (
-
+
- {props.searchInput()}
+ {props.searchInput?.()}
diff --git a/frontend/src/pages/Auswertung.test.tsx b/frontend/src/pages/Auswertung.test.tsx
index b602bac4f..b9c23b1ac 100644
--- a/frontend/src/pages/Auswertung.test.tsx
+++ b/frontend/src/pages/Auswertung.test.tsx
@@ -63,8 +63,9 @@ describe('Auswertung', () => {
await waitFor(() => expect(getByRole('rowheader', { name: 'ACME' })).toBeInTheDocument())
getJson.mockClear()
- // Change a filter so the query key actually changes, then submit.
- const ticket = container.querySelector('.filter-grid input[type=text]') as HTMLInputElement
+ // Change a filter so the query key actually changes, then submit. Exclude the
+ // OptionSelect search inputs (.combobox-input), which now precede the ticket field.
+ const ticket = container.querySelector('.filter-grid input[type=text]:not(.combobox-input)') as HTMLInputElement
fireEvent.input(ticket, { target: { value: 'ABC-1' } })
fireEvent.click(getByRole('button', { name: 'Refresh' }))
diff --git a/frontend/src/pages/WorklogSync.test.tsx b/frontend/src/pages/WorklogSync.test.tsx
index 8eca36d82..acd70f1bc 100644
--- a/frontend/src/pages/WorklogSync.test.tsx
+++ b/frontend/src/pages/WorklogSync.test.tsx
@@ -204,7 +204,9 @@ describe('WorklogSync', () => {
fireEvent.click(screen.getByRole('button', { name: 'Users' }))
fireEvent.input(screen.getByRole('combobox', { name: 'Users' }), { target: { value: 'ali' } })
fireEvent.click(await screen.findByRole('option', { name: 'alice' }))
- await waitFor(() => expect(screen.getByRole('listitem')).toHaveTextContent('alice'))
+ // Single-select fields (the trigger ticket system) now also show their value as a
+ // chip, so several listitems can be present — assert the alice chip is among them.
+ await waitFor(() => expect(screen.getAllByRole('listitem').some((li) => li.textContent?.includes('alice'))).toBe(true))
fireEvent.click(screen.getByRole('button', { name: 'Trigger a run' }))
await waitFor(() => expect(createSyncRun).toHaveBeenCalledTimes(1))
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index 6ca2a0c22..ccc39759a 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -778,6 +778,18 @@ main:has(.tracking) {
outline-offset: -1px;
}
+/* Native