mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user