fix(planner): give day plan rows their full width until the controls are wanted

The hover controls took their room at rest, so a place's line and a note's
text broke early. A place now gives way only while hovered, a note's controls
fade in over the end of its text in the note's own colour, and a picked place
shows none. All three controls of a place come out together: the add-booking
ticket waited for a React hover state and came a beat late. A click no longer
leaves them showing; keyboard focus and an open menu do.
This commit is contained in:
Maurice
2026-10-14 10:45:10 +02:00
committed by Maurice
parent 2fe8590c0b
commit 341520f98b
7 changed files with 107 additions and 28 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5380,
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
"src/components/Planner/DayPlanSidebar.tsx": 2973,
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
"src/components/Planner/PlaceFormModal.tsx": 1250,
@@ -4015,8 +4015,7 @@ describe('DayPlanSidebar', () => {
expect(document.querySelectorAll('img[src*="a.jpg"]')).toHaveLength(1)
})
it('FE-PLANNER-DAYPLAN-165: the add-booking shortcut appears while the row is hovered', async () => {
const user = userEvent.setup()
it('FE-PLANNER-DAYPLAN-165: the add-booking shortcut comes out with the row hover in CSS, like the "…"', () => {
const place = buildPlace({ id: 1, name: 'Hover place' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
@@ -4024,13 +4023,12 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [a] }, onAddBookingToAssignment,
})} />)
const row = dragRow(screen.getByText('Hover place'))
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
fireEvent.mouseEnter(row)
await user.click(screen.getByLabelText('Add booking'))
// Already in the row, hidden and inert until the row is hovered. Waiting for a
// React hover state put it on screen a beat after the arrows and the "…".
const button = screen.getByLabelText('Add booking')
expect(button).toHaveClass('dp-add-booking')
fireEvent.click(button)
expect(onAddBookingToAssignment).toHaveBeenCalledWith(10, 11)
fireEvent.mouseLeave(row)
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-166: the lock tooltip switches once a stop is pinned, and unlocking clears it', async () => {
@@ -34,6 +34,7 @@ import { routeModeIcon, useRouteModeOptions } from './routeModes'
import { Tooltip } from '../shared/Tooltip'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { BarButton, MoreButton, SoftPill, TimePill, tintOf } from './planParts'
import './dayPlanRows.css'
/** The round white button a row carries on its right (a route on the map, a journey's legs). */
const ROW_ROUND = 'grid flex-none place-items-center rounded-full bg-surface-card shadow-sm transition-colors'
@@ -60,7 +61,7 @@ import { useDayNotes } from '../../hooks/useDayNotes'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
import { withDayPlanDefaults } from './DayPlanSidebar.defaults'
import { noteSurface } from './noteSurface'
import { dayBodyStyle, noteSurface } from './noteSurface'
import { findTodayDayId } from './today'
import { markdownLinkComponents } from '../shared/markdownLink'
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
@@ -1282,7 +1283,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const canManageShare = can('share_manage', trip)
const [lockHoverId, setLockHoverId] = useState(null)
const [undoHover, setUndoHover] = useState(false)
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
// Transit rows fold their itinerary out inline (#1065).
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
@@ -1731,7 +1731,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
className="border-t border-edge-faint px-1.5 pb-1.5 pt-1.5"
// The activity list — the largest region and the one behind the
// densest text, so its tint is the faintest of the three.
style={{ background: dayTintBackground(dayTint, 'activity', '--day-tint-activity') ?? undefined }}
style={dayBodyStyle(dayTintBackground(dayTint, 'activity', '--day-tint-activity'))}
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault()
@@ -1975,8 +1975,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
selectPlace()
}}
onContextMenu={e => ctxMenu.open(e, placeMenu())}
onMouseEnter={() => setHoveredAssignmentId(assignment.id)}
onMouseLeave={() => setHoveredAssignmentId(null)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
paddingBlock: 6, paddingInlineEnd: 4, paddingInlineStart: gripsShown ? 2 : 8,
@@ -2134,7 +2132,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)}
</div>
{/* The row's own controls, with room between them so each is its own target. */}
<div className="flex flex-none items-center gap-2.5 ps-1">
<div className="dp-row-actions flex flex-none items-center gap-2.5 ps-1">
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button type="button" onClick={moveUp} disabled={idx === 0} aria-label={t('dayplan.moveUp')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge">
<ChevronUp size={12} strokeWidth={2} />
@@ -2143,14 +2141,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>}
{canEditDays && onAddBookingToAssignment && (hoveredAssignmentId === assignment.id || isPlaceSelected) && (
{canEditDays && onAddBookingToAssignment && (
<Tooltip label={t('reservations.addBooking')} placement="top">
<button type="button" aria-label={t('reservations.addBooking')}
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
}}
className="grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm hover:text-content"
className="dp-add-booking grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm hover:text-content"
>
<Ticket size={12} strokeWidth={2} />
</button>
@@ -2500,7 +2498,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
background: noteSkin.background,
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: canEditDays ? 'pointer' : 'default', userSelect: 'none',
}}
'--dp-note-tint': noteSkin.background,
} as React.CSSProperties}
>
{canEditDays && !dragDisabled && <div className="dp-grip flex flex-none cursor-grab items-center text-content-faint opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-40">
<GripVertical size={13} strokeWidth={1.8} />
@@ -2521,17 +2520,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
)}
</div>
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
{canEditDays && <div className="dp-note-actions flex flex-none items-center">
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button type="button" onClick={e => { e.stopPropagation(); void moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} aria-label={t('dayplan.moveUp')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge"><ChevronUp size={12} strokeWidth={2} /></button>
<button type="button" onClick={e => { e.stopPropagation(); void moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} aria-label={t('dayplan.moveDown')} className="flex text-content-faint hover:text-content disabled:cursor-default disabled:text-edge"><ChevronDown size={12} strokeWidth={2} /></button>
</div>}
{canEditDays && (
</div>
<MoreButton label={t('files.menu')} size={24} items={[
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
]} />
)}
</div>}
</div>
</React.Fragment>
)
@@ -0,0 +1,69 @@
/* Day plan rows: the controls that only show on hover take no room at rest
either, so a row's text runs to the edge of its card. A place gives way while
they are out (its one line truncates). A note's text wraps over several
lines, so its controls fade in over the end of the text instead of
reflowing it. Hover, keyboard focus and an open menu bring them out, the
focus a click leaves on a row does not; on touch they stay in the row, like
the reorder arrows (index.css). */
@media (hover: hover) and (pointer: fine) {
.dp-row:not(:hover):not(:focus-visible):not(:has(:focus-visible)):not(:has([data-menu-open])) .dp-row-actions {
max-width: 0;
overflow: hidden;
padding-inline-start: 0;
}
/* The fade is the note's own surface: its tint over the day's tint (a plugin
may set one) over the card, drawn as layers because a tint alone is
see-through. All come from theme variables, so it matches in light and
dark and for every note colour. */
.dp-note-actions {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
padding-inline: 32px 4px;
border-start-end-radius: 11px;
border-end-end-radius: 11px;
background:
linear-gradient(to left, var(--dp-note-tint) 60%, transparent),
linear-gradient(to left, var(--dp-day-tint, transparent) 60%, transparent),
linear-gradient(to left, var(--bg-card) 60%, transparent);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
[dir='rtl'] .dp-note-actions {
background:
linear-gradient(to right, var(--dp-note-tint) 60%, transparent),
linear-gradient(to right, var(--dp-day-tint, transparent) 60%, transparent),
linear-gradient(to right, var(--bg-card) 60%, transparent);
}
.dp-row:hover .dp-note-actions,
.dp-row:focus-visible .dp-note-actions,
.dp-row:has(:focus-visible) .dp-note-actions,
.dp-row:has([data-menu-open]) .dp-note-actions {
opacity: 1;
pointer-events: auto;
}
}
/* The add-booking ticket beside a place's arrows and "...": out with the row's
hover in CSS like them. A React hover state re-rendered the whole day list
first, so it always came out a beat behind the others. A picked place shows
no controls of its own; on touch, with no hover, the ticket stays out. */
.dp-add-booking {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.dp-row:hover .dp-add-booking,
.dp-row:focus-visible .dp-add-booking,
.dp-row:has(:focus-visible) .dp-add-booking,
.dp-row:has([data-menu-open]) .dp-add-booking {
opacity: 1;
pointer-events: auto;
}
@media (hover: none) {
.dp-add-booking {
opacity: 1;
pointer-events: auto;
}
}
@@ -1,3 +1,5 @@
import type { CSSProperties } from 'react'
/**
* The four surfaces a note colour produces (#1629).
*
@@ -34,3 +36,12 @@ export function noteSurface(color: string | null | undefined): NoteSurface {
iconColor: color,
}
}
/**
* A day's activity area in the plan: its tint as the background, and named as
* a variable for the note fades drawn over it (dayPlanRows.css), so a fade
* matches a day a plugin has tinted too.
*/
export function dayBodyStyle(tint: string | null | undefined): CSSProperties {
return { background: tint ?? undefined, '--dp-day-tint': tint ?? 'transparent' } as CSSProperties
}
+5 -3
View File
@@ -49,7 +49,9 @@ export interface MenuEntry {
/**
* The "…" of a row or card: the actions a right-click also offers, for the
* mouse that doesn't right-click and for the keyboard. Quiet at rest, it comes
* forward with its row's hover or focus (the row carries the `group` class).
* forward with its row's hover or keyboard focus (the row carries the `group`
* class). Keyboard focus only: a row a click selected keeps the focus, and the
* button stayed out on it after the mouse had left.
*/
export function MoreButton({ label, items, size = 26, alwaysVisible = false, className = '' }: {
label: string
@@ -66,8 +68,8 @@ export function MoreButton({ label, items, size = 26, alwaysVisible = false, cla
<>
<Tooltip label={label} disabled={!!menu.menu}>
<button type="button" aria-label={label} aria-haspopup="menu"
onClick={e => menu.open(e, entries, true)}
className={`grid flex-none place-items-center rounded-full text-content-muted transition-opacity hover:bg-surface-card hover:text-content focus-visible:opacity-100 ${alwaysVisible || menu.menu ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100'} ${className}`}
onClick={e => menu.open(e, entries, true)} data-menu-open={menu.menu ? '' : undefined}
className={`grid flex-none place-items-center rounded-full text-content-muted transition-opacity hover:bg-surface-card hover:text-content focus-visible:opacity-100 ${alwaysVisible || menu.menu ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 group-has-[:focus-visible]:opacity-100 [@media(hover:none)]:opacity-100'} ${className}`}
style={{ width: size, height: size }}>
<MoreHorizontal size={15} strokeWidth={2} />
</button>
+3 -3
View File
@@ -253,9 +253,9 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
background: none !important;
padding: 1px 2px;
}
/* Reveal on row hover (and on keyboard focus, so the buttons are reachable by Tab). */
.dp-row:hover .reorder-buttons,
.dp-row:focus-within .reorder-buttons {
/* Reveal on row hover and on keyboard focus (Tab), not on the focus a click leaves on the row. */
.dp-row:hover .reorder-buttons, .dp-row:focus-visible .reorder-buttons,
.dp-row:has(:focus-visible) .reorder-buttons {
opacity: 1;
pointer-events: auto;
}