Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions e2e/interpretation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/components/SearchableSelect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,25 +50,27 @@ 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(() => <SearchableSelect label="Customer" value={7} onChange={onChange} options={customers} allLabel="All" />)

// 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))

fireEvent.click(screen.getByRole('option', { name: 'All' }))
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(() => <SearchableSelect label="Customer" value={0} onChange={vi.fn()} options={customers} />)

// 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()
})

Expand Down
144 changes: 66 additions & 78 deletions frontend/src/components/SearchableSelect.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 => (
<Combobox.Input
ref={(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 (
<div class="field">
<span>{props.label}</span>
<div class={`searchable-select chip-select${isMulti() ? ' inline-tags' : ''}${props.disabled === true ? ' is-disabled' : ''}`}>
{/* Multi: a leading magnifier in the control. Single's search is in the popup. */}
<Show when={isMulti()}>
<ComboSearchIcon />
<ul class="tag-list">
<For each={selectedValues()}>
{(value) => (
<li class="tag">
<span class="tag-label">{labelOf(value)}</span>
{/* Announce selection changes — Ark's own live region only narrates the
highlighted option during navigation. */}
<span class="visually-hidden" role="status" aria-live="polite">
{selectedValues().map(labelOf).join(', ')}
</span>

{/* 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 + ▾. */}
<ul class="tag-list">
<For each={selectedValues()}>
{(value) => (
<li class="tag">
<span class="tag-label">{labelOf(value)}</span>
<Show when={isMulti()}>
<button
type="button"
class="tag-remove"
Expand All @@ -116,17 +109,11 @@ export function SearchableSelect(props: {
onMouseDown={(event) => event.preventDefault()}
onClick={() => { removeValue(value); inputEl?.focus() }}
>×</button>
</li>
)}
</For>
</ul>
</Show>

{/* Announce selection changes — Ark's own live region only narrates the
highlighted option during navigation. */}
<span class="visually-hidden" role="status" aria-live="polite">
{selectedValues().map(labelOf).join(', ')}
</span>
</Show>
</li>
)}
</For>
</ul>

<Combobox.Root
class="chip-select-root"
Expand All @@ -144,48 +131,49 @@ export function SearchableSelect(props: {
props.onChange(first === undefined || first === ALL_VALUE ? 0 : Number(first))
}}
inputValue={inputValue()}
onInputValueChange={(details) => 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('')
}
}}
openOnClick
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 }}
>
<Combobox.Label class="visually-hidden">{props.label}</Combobox.Label>
<Combobox.Control class="combobox-control">
{/* Multi: search input next to the chips. Single: a value + ▾ trigger. */}
<Show
when={isMulti()}
fallback={
<Combobox.Trigger
class="searchable-select-trigger"
aria-label={props.label}
aria-required={props.required === true ? 'true' : undefined}
>
<span class="searchable-select-value">{singleDisplay()}</span>
<span class="combobox-trigger-glyph" aria-hidden="true">▾</span>
</Combobox.Trigger>
}
>
{searchInput()}
<Combobox.Trigger class="combobox-trigger" tabindex="-1" aria-label={props.label}>▾</Combobox.Trigger>
</Show>
<Combobox.Input
ref={(element) => { inputEl = element }}
class="combobox-input"
aria-label={props.label}
aria-required={props.required === true ? 'true' : undefined}
placeholder={placeholder()}
/>
<Combobox.Trigger class="combobox-trigger" tabindex="-1" aria-label={props.label}>▾</Combobox.Trigger>
</Combobox.Control>
{/* Body-portal so the popup floats above cards/dialogs and is never clipped. */}
<Portal>
<Combobox.Positioner class="combobox-positioner">
<ComboboxContent items={filteredItems()} multiple={isMulti()} searchInput={searchInput} />
</Combobox.Positioner>
</Portal>
{/* 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. */}
<Combobox.Positioner class="combobox-positioner">
<ComboboxContent items={filteredItems()} />
</Combobox.Positioner>
</Combobox.Root>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/chipSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ export function ChipSelect(props: {
focus-out logic so opening it doesn't save/close the row. */}
<Portal>
<Combobox.Positioner class="combobox-positioner" data-chipselect-popup>
<ComboboxContent items={filteredItems()} multiple={props.multiple} searchInput={searchInput} />
<ComboboxContent items={filteredItems()} popupSearch={!props.multiple} searchInput={searchInput} />
</Combobox.Positioner>
</Portal>
</Combobox.Root>
Expand Down
19 changes: 12 additions & 7 deletions frontend/src/lib/comboboxParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Combobox.Content class="combobox-content">
<Show when={!props.multiple}>
<Show when={props.popupSearch === true && props.searchInput !== undefined}>
<div class="combobox-search-row">
<ComboSearchIcon />
{props.searchInput()}
{props.searchInput?.()}
</div>
</Show>
<For each={props.items}>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/pages/Auswertung.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }))

Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/WorklogSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading