From acd003ea674bafc075ee7112073672fc29ba74bd Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 28 Sep 2026 16:52:32 +0200 Subject: [PATCH] feat(dialogs): Vacay, Collections, Atlas and Journey in the new dialog look Every popup of the four pages moves to the shared dialog frame the trip uses: head band with its icon, the scrolling body, the footer with its actions, and ConfirmDialog for the questions. - Vacay: settings, removing a year (now says so when it fails), the fusion request, inviting a person, a person's colour and sharing the calendar. - Collections: new and edited lists, adding a place, importing a file and a trip's places, sharing, labels and assigning them, copying to a trip, moving or duplicating, saving a trip's places and deleting a list. The stylesheet of the old import view goes. - Atlas: the country popup with the flag in its head, its choices as rows and the wishlist step; Dawarich's Atlas dialog and its accept dialog on the desktop. - Journey: creating a journey, its settings, linking a trip, inviting a contributor, the entry editor (discarding asks in a dialog) and the photo provider picker; the entry card's menu is the shared one. The phone keeps its own sheets: where a dialog is shared with it (Dawarich, the contributor invite, the provider picker) only the desktop frame changes. --- client/e2e/help/vacay.guide.ts | 6 +- .../AddPlaceToCollectionModal.test.tsx | 11 +- .../Collections/AddPlaceToCollectionModal.tsx | 341 ++++--- .../Collections/BulkAssignLabelModal.test.tsx | 6 +- .../Collections/BulkAssignLabelModal.tsx | 105 +- .../Collections/CopyToTripModal.tsx | 124 +-- .../ImportCollectionModal.test.tsx | 3 +- .../Collections/ImportCollectionModal.tsx | 289 +++--- .../Collections/ImportFromTripModal.test.tsx | 11 +- .../Collections/ImportFromTripModal.tsx | 411 ++++---- .../components/Collections/LabelManager.tsx | 103 +- .../Collections/ListEditorModal.tsx | 278 +++--- .../Collections/MoveToListModal.tsx | 118 ++- .../Collections/SaveTripPlacesToListModal.tsx | 117 ++- .../Collections/ShareCollectionModal.test.tsx | 18 +- .../Collections/ShareCollectionModal.tsx | 300 +++--- .../Dawarich/DawarichAcceptDialog.tsx | 52 +- .../Dawarich/DawarichAtlasDialog.tsx | 65 +- .../Journey/ContributorInviteDialog.test.tsx | 93 +- .../Journey/ContributorInviteDialog.tsx | 257 +++-- .../JourneyDetailPageAddTripDialog.test.tsx | 6 +- .../JourneyDetailPageAddTripDialog.tsx | 120 +-- .../JourneyDetailPageEntryCard.test.tsx | 13 +- .../Journey/JourneyDetailPageEntryCard.tsx | 46 +- .../JourneyDetailPageEntryEditor.test.tsx | 54 +- .../Journey/JourneyDetailPageEntryEditor.tsx | 896 +++++++++--------- .../JourneyDetailPageProviderPicker.test.tsx | 103 +- .../JourneyDetailPageProviderPicker.tsx | 193 ++-- .../JourneyDetailPageSettingsDialog.test.tsx | 54 +- .../JourneyDetailPageSettingsDialog.tsx | 445 +++++---- .../Vacay/VacayPersons.modals.test.tsx | 17 +- client/src/components/Vacay/VacayPersons.tsx | 149 +-- .../VacaySharedCalendars.errors.test.tsx | 11 +- .../components/Vacay/VacaySharedCalendars.tsx | 90 +- client/src/pages/AtlasPage.test.tsx | 4 +- client/src/pages/AtlasPage.tsx | 484 +++++----- client/src/pages/AtlasPage.wiring.test.tsx | 23 +- client/src/pages/CollectionsPage.tsx | 23 +- .../src/pages/CollectionsPage.wiring.test.tsx | 17 +- client/src/pages/JourneyDetailPage.test.tsx | 30 +- client/src/pages/JourneyPage.test.tsx | 13 +- client/src/pages/JourneyPage.tsx | 283 +++--- client/src/pages/JourneyPage.wiring.test.tsx | 22 +- client/src/pages/VacayPage.tsx | 189 ++-- client/src/pages/vacay/useVacay.ts | 17 +- client/src/styles/collections.css | 131 --- 46 files changed, 3338 insertions(+), 2803 deletions(-) diff --git a/client/e2e/help/vacay.guide.ts b/client/e2e/help/vacay.guide.ts index 0eb47bd9b..d9ace441b 100644 --- a/client/e2e/help/vacay.guide.ts +++ b/client/e2e/help/vacay.guide.ts @@ -240,8 +240,7 @@ const SCRIPTS: Record = { }, }, { - // The dialog's close button has no label of its own: it is the button in the header next to the title. - target: p => settings(p).locator('h2').locator('xpath=..').getByRole('button').first(), + target: p => settings(p).getByRole('button', { name: 'Close' }), act: async p => { await closeSettings(p); await settle(p) }, }, ], @@ -272,8 +271,7 @@ const SCRIPTS: Record = { }, }, { - // The dialog's close button has no label of its own: it is the button in the header next to the title. - target: p => settings(p).locator('h2').locator('xpath=..').getByRole('button').first(), + target: p => settings(p).getByRole('button', { name: 'Close' }), act: async p => { await closeSettings(p); await settle(p) }, }, ], diff --git a/client/src/components/Collections/AddPlaceToCollectionModal.test.tsx b/client/src/components/Collections/AddPlaceToCollectionModal.test.tsx index 44282d5b3..8ea653c5a 100644 --- a/client/src/components/Collections/AddPlaceToCollectionModal.test.tsx +++ b/client/src/components/Collections/AddPlaceToCollectionModal.test.tsx @@ -2,7 +2,7 @@ import React from 'react' import type { Mock } from 'vitest' import { http, HttpResponse } from 'msw' -import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render' +import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render' import { server } from '../../../tests/helpers/msw/server' import type { Category } from '@trek/shared' import { useTranslation } from '../../i18n/TranslationContext' @@ -68,6 +68,11 @@ function typeName(value: string): void { fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value } }) } +/** The results panel's own close button; the dialog's head band has one of the same name. */ +function dismissResultsButton(): HTMLElement { + return within(screen.getByRole('group', { name: 'Search' })).getByRole('button', { name: 'Close' }) +} + describe('AddPlaceToCollectionModal', () => { beforeEach(() => { addToast = vi.fn(() => 0) @@ -151,7 +156,7 @@ describe('AddPlaceToCollectionModal', () => { fireEvent.click(screen.getByRole('button', { name: /Search/ })) await screen.findByText('Kissa Sakaiki') - fireEvent.click(screen.getByRole('button', { name: 'Close' })) + fireEvent.click(dismissResultsButton()) expect(screen.queryByText('Kissa Sakaiki')).not.toBeInTheDocument() }) @@ -182,7 +187,7 @@ describe('AddPlaceToCollectionModal', () => { fireEvent.click(screen.getByRole('button', { name: /Search/ })) await screen.findByText('No places found') - fireEvent.click(screen.getByRole('button', { name: 'Close' })) + fireEvent.click(dismissResultsButton()) expect(screen.queryByText('No places found')).not.toBeInTheDocument() }) diff --git a/client/src/components/Collections/AddPlaceToCollectionModal.tsx b/client/src/components/Collections/AddPlaceToCollectionModal.tsx index fca0a8864..bcdcf929e 100644 --- a/client/src/components/Collections/AddPlaceToCollectionModal.tsx +++ b/client/src/components/Collections/AddPlaceToCollectionModal.tsx @@ -1,11 +1,13 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useId, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' -import { Search, MapPin, Plus, Loader2, Link2, Trash2, Check, X } from 'lucide-react' -import Modal from '../shared/Modal' +import { Bookmark, Search, MapPin, MapPinned, Loader2, Trash2, Check, X } from 'lucide-react' +import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' +import { AddRowButton, EditorField, GRID_2, INPUT, PANEL, Segmented, TEXTAREA } from '../shared/dialogParts' import { NumericInput } from '../shared/NumericInput' -import MarkdownToolbar from '../Journey/MarkdownToolbar' +import NoteFormatToolbar from '../shared/NoteFormatToolbar' +import { Tooltip } from '../shared/Tooltip' import { mapsApi } from '../../api/client' import { collectionsApi } from '../../api/collections' import { getCategoryIcon } from '../shared/categoryIcons' @@ -20,6 +22,19 @@ type MapsPlace = Record const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && v !== '' ? Number(v) : undefined) +/** + * The search row, pinned to the top of the scrolling body. The dialog stays open + * after an add, and the row used to sit above the viewport once the form was + * scrolled, so it looked like the button had vanished (#1921). The negative + * margins let its background cover the body's padding while it is stuck. + */ +const STICKY_SEARCH = 'sticky top-0 z-20 -mx-6 -mt-5 bg-surface-card px-6 pb-1 pt-5' +/** A chip of the category row; the picked one wears the category's own colour. */ +const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold' +const CHIP_OFF = 'border-edge-faint bg-surface-card text-content-muted hover:text-content' +const CHIP_NONE_ON = 'border-content bg-surface-card text-content' +const ROW_ACTION = 'grid h-8 w-8 flex-none place-items-center rounded-[9px] text-content-faint hover:bg-surface-secondary' + interface AddPlaceToCollectionModalProps { isOpen: boolean collectionId: number @@ -31,10 +46,11 @@ interface AddPlaceToCollectionModalProps { } /** - * Add a place to the current list — everything in one view: a search field that - * fills in the location when a result is picked, plus name / category / status / - * markdown description / links, all editable together before saving. Stays open - * after each add so several places can be added in a row. + * Add a place to the current list, everything in one view: the name typed into + * the head band, a search that fills it and the location in when a result is + * picked, plus status, category, a markdown description and links, all editable + * together before saving. Stays open after each add so several places can be + * added in a row. */ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement { const { language } = useTranslation() @@ -60,6 +76,8 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec const [saving, setSaving] = useState(false) const descRef = useRef(null) const searchRef = useRef(null) + const labelId = useId() + const fieldId = useId() const reset = () => { setQuery(''); setResults([]); setNoResults(false); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') } useEffect(() => { if (!isOpen) reset() }, [isOpen]) @@ -141,157 +159,202 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/) if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) } } - const coordInputClass = 'w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent' + + const header = ( + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={onClose} + eyebrow={t('collections.addPlace')} + // Typed into the band, as in the planner's place dialog. It takes no focus + // on opening: the dialog starts at the search, which fills the name in. + titleInput={{ value: name, onChange: setName, label: t('common.name'), placeholder: t('common.name') }} + sub={<>{collectionName}} + /> + ) + + const footer = ( + + + {t('common.cancel')} + void save()} + disabled={saving || !name.trim()} + icon={saving ? : } + > + {t('common.add')} + + + ) return ( - - - - - } - > -
- {/* Search — picking a result fills the location below. Pinned to the top of - the scrolling dialog body: the dialog stays open after an add, and the - search row used to sit above the viewport once the form was scrolled, - so it looked like the button had vanished (#1921). The negative margins - let its background cover the body padding while it is stuck. */} -
-
-
-
- - { setQuery(e.target.value); setNoResults(false) }} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }} - placeholder={t('collections.addPlaceSearch')} - className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" - /> -
- + + {/* Search: picking a result fills the name and the location below. */} +
+
+
+
+
- {(results.length > 0 || noResults) && ( -
-
- {t('common.search')} - -
- {noResults ? ( -
{t('planner.noPlacesFound')}
- ) : results.map((r, i) => ( -
+ {(results.length > 0 || noResults) && ( +
+
+ {t('common.search')} + + - ))} +
- )} -
+ {noResults ? ( +
{t('planner.noPlacesFound')}
+ ) : results.map((r, i) => ( + + ))} +
+ )}
+
- {/* Name */} -
- - setName(e.target.value)} placeholder={t('common.name')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" /> + {/* Where it is: filled in by a picked result, but also typeable, so a place + can be added by GPS alone (#1435). */} +
+ + setAddress(e.target.value)} + placeholder={t('places.formAddressPlaceholder')} + className={INPUT} + /> + +
+ + + + + +
+
- {/* Address + coordinates — editable so a place can be added by GPS alone */} -
- - setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={coordInputClass} /> -
- - -
-
+ + + label={t('mobileCollections.status')} + value={status} + onChange={setStatus} + options={STATUS_ORDER.map(s => { + const Icon = STATUS_META[s].icon + return { value: s, label: t(STATUS_META[s].labelKey), icon: } + })} + /> + - {/* Status */} -
-
- {STATUS_ORDER.map(s => { - const Icon = STATUS_META[s].icon - const on = status === s + {categories.length > 0 && ( + +
+ + {categories.map(cat => { + const Icon = getCategoryIcon(cat.icon ?? undefined) + const on = categoryId === cat.id + const col = cat.color || '#6366f1' // theme-lint-disable: the category's own colour, and the default one without a colour is drawn in return ( - ) })}
-
+ + )} - {/* Category */} - {categories.length > 0 && ( -
- -
- - {categories.map(cat => { - const Icon = getCategoryIcon(cat.icon ?? undefined) - const on = categoryId === cat.id - const col = cat.color || '#6366f1' - return ( - - ) - })} -
+ {t('collections.description')}} + action={} + > +