From 8284cdea3a6c0733341946cbcb88269c203a3950 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 16 Jul 2026 08:58:17 +0200 Subject: [PATCH 1/3] fix(ui): single date-field border and 24px checkboxes on settings forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic .field input rule out-specifies .date-field-wrap > input.date-field {border:0}, so the date input drew its own border inside the pill wrap's — a double border. Exclude .date-field from the generic input border/focus rules so the wrap carries the only border. And bump the settings checkboxes from 20px to 24px (WCAG 2.2 AA target size 2.5.8) with the accent color. Signed-off-by: Sebastian Mendel --- frontend/src/styles/app.css | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 47983de0f..83cc7c511 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -752,7 +752,7 @@ main:has(.tracking) { /* All text-like inputs, selects and textareas in a field share the same recognisable box (checkboxes/radios keep their native look in .field-check). */ -.field input:not([type='checkbox']):not([type='radio']), +.field input:not([type='checkbox']):not([type='radio']):not(.date-field), .field select, .field textarea { background: var(--color-bg); @@ -770,7 +770,7 @@ main:has(.tracking) { resize: vertical; } -.field input:not([type='checkbox']):not([type='radio']):focus-visible, +.field input:not([type='checkbox']):not([type='radio']):not(.date-field):focus-visible, .field select:focus-visible, .field textarea:focus-visible { border-color: var(--color-accent); @@ -823,8 +823,9 @@ main:has(.tracking) { } .field-check input { - height: 20px; - width: 20px; + height: 24px; + width: 24px; + accent-color: var(--color-accent); } .field-hint { From 686ca50d76fb95e4f862ef386532172a8895996e Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 16 Jul 2026 09:51:51 +0200 Subject: [PATCH 2/3] feat(ui): single-select shows its value in the input, not as a chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-select SearchableSelect read like a multi-select — the chosen value sat as a chip beside an empty search box. Rewrite single to a value-in-input (React- Select single) control: the input DISPLAYS the selected label; opening lists all options with the current one highlighted (no pre-filter); typing filters; picking replaces the value; closing without a pick reverts to the selected label. A `searching` flag (set on the native onInput, real typing only) gates filtering so the resting label never pre-filters. Multi keeps its removable chips + inline search unchanged. Signed-off-by: Sebastian Mendel --- .../src/components/SearchableSelect.test.tsx | 28 +++- frontend/src/components/SearchableSelect.tsx | 142 ++++++++++++------ frontend/src/pages/WorklogSync.test.tsx | 5 +- 3 files changed, 125 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/SearchableSelect.test.tsx b/frontend/src/components/SearchableSelect.test.tsx index 147129c68..ab3260908 100644 --- a/frontend/src/components/SearchableSelect.test.tsx +++ b/frontend/src/components/SearchableSelect.test.tsx @@ -50,13 +50,16 @@ describe('SearchableSelect (jsdom)', () => { await waitFor(() => expect(onChange).toHaveBeenCalledWith(8)) }) - it('single: shows the current value as a chip and the "all" entry clears it to 0', async () => { + it('single: shows the current value in the input and the "all" entry clears it to 0', async () => { const onChange = vi.fn() render(() => ) - // 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') + // Single shows the chosen value IN the input (React-Select single), not as a chip. + expect(screen.getByRole('combobox', { name: 'Customer' })).toHaveValue('Apollo') + expect(screen.queryByRole('listitem')).not.toBeInTheDocument() + + // Opening lists every option (the selection highlights, it does not pre-filter): + // 3 real options + the prepended "all" entry. fireEvent.click(screen.getByRole('button', { name: 'Customer' })) await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(4)) @@ -64,6 +67,23 @@ describe('SearchableSelect (jsdom)', () => { await waitFor(() => expect(onChange).toHaveBeenCalledWith(0)) }) + it('single: reverts the input to the selected label when closed without a pick', async () => { + render(() => ) + + const input = screen.getByRole('combobox', { name: 'Customer' }) + expect(input).toHaveValue('Apollo') + + // Open, type a non-committed query (filters the list) … + fireEvent.click(screen.getByRole('button', { name: 'Customer' })) + fireEvent.input(input, { target: { value: 'Ze' } }) + await waitFor(() => expect(input).toHaveValue('Ze')) + + // … then close without picking (toggle the ▾): the input reverts to the chosen + // label, the typed query does not persist. + fireEvent.click(screen.getByRole('button', { name: 'Customer' })) + await waitFor(() => expect(input).toHaveValue('Apollo')) + }) + it('single: with no selection, shows an empty search control and no "all" entry', () => { render(() => ) diff --git a/frontend/src/components/SearchableSelect.tsx b/frontend/src/components/SearchableSelect.tsx index 9d9974b18..38c407d84 100644 --- a/frontend/src/components/SearchableSelect.tsx +++ b/frontend/src/components/SearchableSelect.tsx @@ -1,5 +1,5 @@ import { Combobox } from '@ark-ui/solid/combobox' -import { createMemo, createSignal, For, type JSX, Show } from 'solid-js' +import { createEffect, createMemo, createSignal, For, type JSX } from 'solid-js' import type { NamedOption } from '../api/queries' import { ComboboxContent, comboCollection, type ComboItem } from '../lib/comboboxParts' @@ -13,12 +13,14 @@ const ALL_VALUE = '__all__' /** * A searchable relation combobox for ordinary FORMS (not the admin grid). * - * 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. + * Single and multi read differently on purpose: + * - SINGLE displays the chosen option's label IN the editable input (a + * React-Select single control): the input IS both the value display and the + * search field. Opening lists ALL options (the selection highlights, it does + * NOT pre-filter); typing filters; picking commits and closes; closing without + * a pick reverts the input to the selected label. + * - MULTI keeps the chosen values as removable chips beside a bare inline search + * input, with a trailing ▾ that opens the (always complete) 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. @@ -37,7 +39,15 @@ export function SearchableSelect(props: { required?: boolean }): JSX.Element { let inputEl: HTMLInputElement | undefined - const [inputValue, setInputValue] = createSignal('') + // The input's text, controlled but driven BY Ark: on selection Ark echoes the + // chosen label into it (so a single select shows its value IN the input), while + // typing writes the query. A single select also seeds/reverts it to the selected + // label via the effect below (Ark echoes only on a selection event, not at mount). + const [inputText, setInputText] = createSignal('') + // Single-only filter gate: true while the user is actively typing a query. Set on + // real input (native onInput), reset on close — so opening lists ALL options (the + // resting label never pre-filters) and a typed-but-not-committed query never persists. + const [searching, setSearching] = createSignal(false) const isMulti = (): boolean => props.multiple === true @@ -58,11 +68,38 @@ export function SearchableSelect(props: { // Offer an explicit "all / none" entry only for an optional single select. const showAll = (): boolean => !isMulti() && props.allLabel !== undefined + + // Single: the label the input DISPLAYS when not searching — the chosen option's + // label, or EMPTY when nothing is chosen (value 0). The empty resting state (the + // search placeholder shows through) matches the existing callers' "all = blank + // field"; the "all / none" entry still lives in the dropdown to clear a selection. + const selectedLabel = createMemo(() => { + if (isMulti()) { + return '' + } + const values = selectedValues() + + return values.length > 0 ? labelOf(values[0]!) : '' + }) + + // Single: keep the input showing the selected label whenever the user is NOT typing + // — at mount, on external value changes, and (searching flips false) on close, which + // reverts a typed-but-not-committed query. Skipped while searching so typing shows. + createEffect(() => { + if (!isMulti() && !searching()) { + setInputText(selectedLabel()) + } + }) + + // What the list filters by. A single select only filters once the user is really + // typing, so opening shows ALL options (the selection highlights, never pre-filters). + const activeQuery = createMemo(() => (isMulti() || searching() ? inputText() : '')) + const filteredItems = createMemo(() => { - const query = inputValue().trim().toLowerCase() - const base = query === '' ? allItems() : allItems().filter((item) => item.label.toLowerCase().includes(query)) + const q = activeQuery().trim().toLowerCase() + const base = q === '' ? allItems() : allItems().filter((item) => item.label.toLowerCase().includes(q)) - return showAll() && query === '' ? [{ value: ALL_VALUE, label: props.allLabel ?? '' }, ...base] : base + return showAll() && q === '' ? [{ value: ALL_VALUE, label: props.allLabel ?? '' }, ...base] : base }) const collection = createMemo(() => comboCollection(filteredItems())) @@ -70,15 +107,14 @@ export function SearchableSelect(props: { props.onChange(selectedValues().filter((current) => current !== value).map(Number)) } - // 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 '' + if (isMulti()) { + return selectedValues().length > 0 ? '' : m.app_type_to_add() } - return isMulti() ? m.app_type_to_add() : m.app_type_to_search() + // Single: the input carries its value, so this hint only shows through when that + // value is empty (nothing chosen and no "all" label, or the query is cleared). + return m.app_type_to_search() } return ( @@ -91,25 +127,23 @@ export function SearchableSelect(props: { {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 + ▾. */} + {/* MULTI only: the chosen values as removable chips. A single select shows its + value IN the input (below), so it renders no chip. Outside Root so the chips + share the field's wrapping flex row with the (display:contents) input + ▾. */}
    - + {(value) => (
  • {labelOf(value)} - - - +
  • )}
    @@ -130,23 +164,35 @@ export function SearchableSelect(props: { const first = details.value[0] props.onChange(first === undefined || first === ALL_VALUE ? 0 : Number(first)) }} - inputValue={inputValue()} + inputValue={inputText()} 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) + // Ark owns the input text: typing writes the query, and on selection it + // echoes the chosen label back in (which is what a single select displays). + // The `searching` flag (set on real typing) decides whether that text + // filters — so a resting single label never pre-filters the list. + setInputText(details.inputValue) }} onOpenChange={(details) => { if (details.open) { - requestAnimationFrame(() => { if (inputEl?.isConnected) inputEl.focus() }) + requestAnimationFrame(() => { + if (!inputEl?.isConnected) { + return + } + inputEl.focus() + // Opened by click (not by typing): select the shown label so the first + // keystroke replaces it. + if (!isMulti() && !searching()) { + inputEl.select() + } + }) } else { - // Drop a typed query on close so the next open starts fresh with all options. - setInputValue('') + // Closing without committing: end search mode. For a single select the + // effect then reverts the input to the selected label; multi's search box + // clears. + setSearching(false) + if (isMulti()) { + setInputText('') + } } }} openOnClick @@ -163,6 +209,14 @@ export function SearchableSelect(props: { aria-label={props.label} aria-required={props.required === true ? 'true' : undefined} placeholder={placeholder()} + onInput={() => { + // Fires only on REAL user typing (not Ark's programmatic writes). Single: + // enter search mode so the typed text (captured by onInputValueChange) + // starts filtering the list. Multi always filters, so it is a no-op there. + if (!isMulti()) { + setSearching(true) + } + }} /> diff --git a/frontend/src/pages/WorklogSync.test.tsx b/frontend/src/pages/WorklogSync.test.tsx index acd70f1bc..41d707b14 100644 --- a/frontend/src/pages/WorklogSync.test.tsx +++ b/frontend/src/pages/WorklogSync.test.tsx @@ -204,8 +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' })) - // 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. + // Users is multi-select, so alice shows as a removable chip (listitem). The + // single-select trigger ticket system shows its value IN the input, not as a chip, + // so the alice chip is the listitem to assert on. await waitFor(() => expect(screen.getAllByRole('listitem').some((li) => li.textContent?.includes('alice'))).toBe(true)) fireEvent.click(screen.getByRole('button', { name: 'Trigger a run' })) From db9860ac83a36ee15c6b3ab424d97d3b17be284f Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 16 Jul 2026 09:52:08 +0200 Subject: [PATCH 3/3] feat(ui): redesign the API-token form and align help-icon checkboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token form: create form first (clear heading), token list below. "Full access" is now a mode toggle ("By resource" vs "Full access") that hides the scope grid and shows a warning stating the wildcard grants all current AND future rights (not a snapshot — verified against ApiScope::grants). The scope table is replaced by a narrow content-sized grid with the read/write checkboxes right after each resource and a three-state select-all checkbox per column header. Checkbox consistency: help-icon checkbox rows (.field-check-row, in WorklogSyncPreferences and AdminCrudShell) join the same two-column grid as the plain checkboxes — label right-aligned in column 1, [checkbox + help] in column 2 — instead of a left-aligned flex row. The label is a col1