fix(roadtrip): the two search actions stop cutting their labels in half

"Add manually" did not fit beside "Search" and rendered as "Add manu…",
which is neither the label nor a shape anybody recognises.

The button face now carries one short word, with the full sentence kept as
its accessible name and its tooltip, so nothing is lost to a reader or a
pointer. Every locale got its own short form rather than a clipped version
of the long one: Selbst, A mano, Ručně, Elle, 手動.

Below two hundred pixels the row drops both words instead of shortening
them, and the two actions are their icons. The width is the row's own,
measured, not the window's: this column is resizable, so it can be narrow on
a wide screen and a media query would answer the wrong question.

The search button also stops restating the progress. Its label used to grow
into "Searching 3 of 12" while a run was on, which is the longest string in
the panel and the very sentence the progress row directly underneath already
prints next to the share as a figure. It keeps its word, disabled, with the
spinner in front of it.
This commit is contained in:
Maurice
2026-09-14 13:50:44 +02:00
parent 049fb76965
commit 3841412b22
25 changed files with 133 additions and 15 deletions
@@ -1,7 +1,17 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { afterEach, describe, it, expect, vi } from 'vitest'
import { render, screen, within, fireEvent } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
// jsdom reports every element as zero by side, so the row that decides whether the two
// actions keep their labels can only be driven from here. Zero is what the component
// reads as "not measured yet", which is also the default below.
const { rowWidth } = vi.hoisted(() => ({ rowWidth: { value: 0 } }))
vi.mock('../../hooks/useElementSize', () => ({
useElementSize: () => ({ ref: () => {}, width: rowWidth.value, height: 0 }),
}))
afterEach(() => { rowWidth.value = 0 })
import RoadtripCorridorPanel from './RoadtripCorridorPanel'
import type { CorridorPoi } from './useCorridorPois'
import type { RoadtripCorridor } from './useRoadtripCorridor'
@@ -275,11 +285,21 @@ describe('RoadtripCorridorPanel', () => {
})
it('FE-ROADTRIP-PANEL-013: while searching it reports progress rather than sitting still', () => {
rowWidth.value = 320
const c = corridor({}, { loading: true, progress: { done: 3, total: 12 } })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25')
expect(screen.getByRole('button', { name: /3/ })).toBeDisabled()
// The sentence and the share belong to the row of their own, once. The button used
// to restate them, which made the longest label in the panel and was the one being
// cut off; it keeps its word, and the spinner in front of it says the run is on.
const bar = screen.getByRole('progressbar')
expect(bar).toHaveAttribute('aria-valuenow', '25')
expect(bar).toHaveTextContent('Searching 3 of 12')
const [searchButton] = searchRow()
expect(searchButton).toHaveTextContent('Search')
expect(within(searchButton).queryByText(/Searching/)).toBeNull()
expect(searchButton).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-014: a partial answer says which stretches nobody looked at', () => {
@@ -431,6 +451,52 @@ describe('RoadtripCorridorPanel', () => {
expect(searchRow()).toHaveLength(1)
})
it('FE-ROADTRIP-PANEL-029: a roomy row keeps both words, and the manual one is the short form', () => {
rowWidth.value = 320
wrap(
<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1)])} onAddPoi={vi.fn()} onAddManual={vi.fn()} />,
)
const [searchButton, manual] = searchRow()
expect(searchButton).toHaveTextContent('Search')
// The face of the button is the one word that fits beside it; the sentence stays
// as the name a reader and a tooltip get.
expect(manual).toHaveTextContent('Manual')
expect(manual).toHaveAttribute('aria-label', 'Add manually')
})
it('FE-ROADTRIP-PANEL-030: a narrow row drops the words rather than cutting them short', () => {
// "Add manu…" is neither the label nor a shape anybody recognises. Below the
// threshold both actions are their icon, and both keep their full names.
rowWidth.value = 150
wrap(
<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1)])} onAddPoi={vi.fn()} onAddManual={vi.fn()} />,
)
const [searchButton, manual] = searchRow()
expect(searchButton).toHaveTextContent('')
expect(manual).toHaveTextContent('')
expect(searchButton).toHaveAttribute('aria-label', 'Search')
expect(manual).toHaveAttribute('aria-label', 'Add manually')
// Still one row of two halves, not a stack.
expect(searchButton.className).toContain('flex-1')
expect(manual.className).toContain('flex-1')
})
it('FE-ROADTRIP-PANEL-031: a narrow row loses the words even while a search is running', () => {
// The spinner replaces the magnifier, and there is still no text to cut off.
rowWidth.value = 150
const c = corridor({}, { loading: true, progress: { done: 3, total: 12 } })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={vi.fn()} onAddManual={vi.fn()} />)
const [searchButton, manual] = searchRow()
expect(searchButton).toHaveTextContent('')
expect(manual).toHaveTextContent('')
expect(searchButton).toHaveAttribute('aria-label', 'Search')
// The progress row is where the sentence lives, whatever the panel's width.
expect(screen.getByRole('progressbar')).toHaveTextContent('Searching 3 of 12')
})
it('FE-ROADTRIP-PANEL-027: clicking a row brings that hit into view, and adding it does not', () => {
const onFocusPoint = vi.fn()
const onAddPoi = vi.fn()
@@ -6,6 +6,7 @@ import {
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import EmptyState from '../shared/EmptyState'
import { useElementSize } from '../../hooks/useElementSize'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import CustomSelect from '../shared/CustomSelect'
@@ -55,6 +56,16 @@ interface RoadtripCorridorPanelProps {
/** The steps worth offering: a household socket, a fast AC post, and the two DC tiers. */
const KW_STEPS = [11, 22, 50, 150]
/**
* Below this, the two actions at the foot of the search card are icons alone, in pixels.
*
* Measured from what they need rather than picked round: two buttons, an eight pixel gap
* and their own padding leave each of them about ninety pixels for a fifteen pixel icon,
* a gap and a word. "Search" and the shortest sensible word beside it fit that; a hair
* under it and the first thing to go is the end of the longer word.
*/
const ICON_ONLY_BELOW_PX = 200
const SOCKET_LABEL: Record<string, string> = {
type2: 'Type 2',
type2_combo: 'CCS',
@@ -301,6 +312,16 @@ export default function RoadtripCorridorPanel({
* search from being there the next time somebody opens it.
*/
const [manualOpen, setManualOpen] = useState(false)
/**
* The row the two actions share, measured, because this column is resizable.
*
* A media query would ask about the window, which is the wrong question: the panel
* can be narrow on a wide screen and the labels have to go by what the row itself
* got. Zero means the row has not been measured yet, and that reads as roomy rather
* than cramped so the labels do not flash away on the first paint.
*/
const actionRow = useElementSize<HTMLDivElement>()
const iconOnly = actionRow.width > 0 && actionRow.width < ICON_ONLY_BELOW_PX
const dayOptions = routes.days.map(d => ({
value: String(d.dayId),
@@ -426,25 +447,29 @@ export default function RoadtripCorridorPanel({
Two halves of one row, because the search finds most of what is out there and
not all of it: a good share of the chargers standing at a junction are in
nobody's OpenStreetMap extract. The second button is the way to those, and it
sits beside the search rather than under it so neither is the afterthought. */}
<div className="flex gap-2">
sits beside the search rather than under it so neither is the afterthought.
Below `ICON_ONLY_BELOW_PX` the two drop their labels rather than shortening
them. A truncated word is worse than no word: "Add manu…" is neither the
label nor a shape you recognise, while the plus and the magnifier are both
read at a glance and keep their full names for a pointer and a reader. */}
<div ref={actionRow.ref} className="flex gap-2">
<button
type="button"
onClick={search.search}
disabled={!canSearch}
aria-label={t('roadtrip.poi.search')}
title={iconOnly ? t('roadtrip.poi.search') : undefined}
className="flex h-[32px] min-w-0 flex-1 items-center justify-center gap-2 rounded-lg bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
>
{search.loading
? <RotateCw size={15} className="shrink-0 animate-spin" aria-hidden />
: <Search size={15} strokeWidth={2} className="shrink-0" aria-hidden />}
{/* The label grows into "Searching 3 of 12" while a run is on. Sharing the
row it has to shorten rather than push its neighbour off the panel, and
the panel is resizable down to a width where that matters. */}
<span className="min-w-0 truncate">
{search.loading
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
: t('roadtrip.poi.search')}
</span>
{/* The word stays put while a run is on, and the spinner in front of it says
the run is on. It used to become "Searching 3 of 12", which is both the
longest label in the panel and the very sentence the progress row right
underneath prints, next to the share as a figure. */}
{iconOnly ? null : <span className="min-w-0 truncate">{t('roadtrip.poi.search')}</span>}
</button>
{/* The day is part of the offer: with no drive on the trip at all there is
nowhere for a stop to go, and a dialog that can only be cancelled is worse
@@ -453,10 +478,14 @@ export default function RoadtripCorridorPanel({
<button
type="button"
onClick={() => setManualOpen(true)}
className="flex h-[32px] min-w-0 flex-1 items-center justify-center gap-2 rounded-lg border border-edge bg-surface-card text-body font-semibold text-content transition-colors hover:bg-surface-hover disabled:opacity-50"
// The full sentence is the accessible name and the tooltip; the face of
// the button carries the one word that fits beside "Search".
aria-label={t('roadtrip.poi.addManual')}
title={t('roadtrip.poi.addManual')}
className="flex h-[32px] min-w-0 flex-1 items-center justify-center gap-2 rounded-lg border border-edge bg-surface-card text-body font-semibold text-content transition-colors hover:bg-surface-hover"
>
<Plus size={15} strokeWidth={2.2} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate">{t('roadtrip.poi.addManual')}</span>
{iconOnly ? null : <span className="min-w-0 truncate">{t('roadtrip.poi.addManualShort')}</span>}
</button>
) : null}
</div>