diff --git a/test/ui/mapTheme.test.js b/test/ui/mapTheme.test.js index 6e6358b0..e7a323a8 100644 --- a/test/ui/mapTheme.test.js +++ b/test/ui/mapTheme.test.js @@ -232,7 +232,9 @@ describe('the page', () => { it('carries the fullscreen toggle on the first heading, not floating above the box', () => { expect(viewJsx).toMatch(/panels=\{\(controls, expandButton\) =>/); - expect(viewJsx).toMatch(/\{t\('map\.groupMap'\)\}\s*\{expandButton\}/); + // The text is wrapped in its own span so a phone can hide it without hiding the button next to + // it - see Map.mobile.less, which repeats it as the fold pill's own label below 430px. + expect(viewJsx).toMatch(/\{t\('map\.groupMap'\)\}<\/span>\s*\{expandButton\}/); // The map renders it itself only where no panel took it. expect(mapJsx).toMatch(/\{!controlsInPanels && expandButton\}/); // Smaller in there than on the map, and it needs two classes to beat Semi's own height. diff --git a/ui/src/components/map/Map.jsx b/ui/src/components/map/Map.jsx index 4a38f4b9..080d18df 100644 --- a/ui/src/components/map/Map.jsx +++ b/ui/src/components/map/Map.jsx @@ -301,6 +301,26 @@ export default function Map({ cooperativeGestures, }); + // MapLibre's own attribution control opens itself - a paragraph of text over the map, not the + // "i" it collapses to - until the map is first panned or zoomed. That visibility runs on its + // own `maplibregl-compact-show` class rather than the `
` element's native `open` + // state (maplibre-gl.css keys the attribution text's `display` off the class alone), so + // removing the class is what actually collapses it; the class is what `_toggleAttribution` + // re-adds on a click, so the "i" still opens and closes it same as before. + // + // Only below the width Map.mobile.less's own breakpoint uses: a map that size is small enough + // that the full text is the map, and wide desktop embeds keep the attribution they had. Added + // synchronously (`AttributionControl.onAdd` runs, and adds the class, inside the `Map` + // constructor above, before the style has even loaded) and again on `load` in case MapLibre + // re-adds it once the style is ready. + const collapseAttribution = () => { + const container = mapContainerRef.current; + if (!container || container.offsetWidth > 430) return; + container.querySelector('.maplibregl-ctrl-attrib')?.classList.remove('maplibregl-compact-show'); + }; + collapseAttribution(); + mapRef.current.once('load', collapseAttribution); + // Left, because the panels now live in the top right corner and a zoom button hiding behind // them helps nobody. Where drawing is enabled MapLibre simply stacks these below its tools. mapRef.current.addControl( diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index 10449a1a..956f6798 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -437,6 +437,7 @@ "map.noHomeAddressLink": "Benutzereinstellungen", "map.noHomeAddressAfter": ", um den Entfernungsfilter zu nutzen.", "map.groupMap": "Karte", + "map.foldToggle": "Kartenfilter ein- oder ausblenden", "map.groupListings": "Inserate", "map.searchPlaceholder": "Adresse oder Ort suchen", "map.searchLabel": "Adresssuche auf der Karte", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index fba73de7..40f66d9d 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -437,6 +437,7 @@ "map.noHomeAddressLink": "user settings", "map.noHomeAddressAfter": " to use the distance filter.", "map.groupMap": "Map", + "map.foldToggle": "Show or hide the map filters", "map.groupListings": "Listings", "map.searchPlaceholder": "Search an address or place", "map.searchLabel": "Address search on the map", diff --git a/ui/src/locales/es.json b/ui/src/locales/es.json index 722498d0..c61150ae 100644 --- a/ui/src/locales/es.json +++ b/ui/src/locales/es.json @@ -437,6 +437,7 @@ "map.noHomeAddressLink": "los ajustes de usuario", "map.noHomeAddressAfter": " para usar el filtro de distancia.", "map.groupMap": "Mapa", + "map.foldToggle": "Mostrar u ocultar los filtros del mapa", "map.groupListings": "Anuncios", "map.searchPlaceholder": "Buscar una dirección o un lugar", "map.searchLabel": "Búsqueda de direcciones en el mapa", diff --git a/ui/src/locales/fr.json b/ui/src/locales/fr.json index 80a04d75..cd2be30f 100644 --- a/ui/src/locales/fr.json +++ b/ui/src/locales/fr.json @@ -437,6 +437,7 @@ "map.noHomeAddressLink": "les paramètres utilisateur", "map.noHomeAddressAfter": " pour utiliser le filtre de distance.", "map.groupMap": "Carte", + "map.foldToggle": "Afficher ou masquer les filtres de la carte", "map.groupListings": "Annonces", "map.searchPlaceholder": "Rechercher une adresse ou un lieu", "map.searchLabel": "Recherche d'adresse sur la carte", diff --git a/ui/src/locales/it.json b/ui/src/locales/it.json index 6f4f1a23..6646be41 100644 --- a/ui/src/locales/it.json +++ b/ui/src/locales/it.json @@ -437,6 +437,7 @@ "map.noHomeAddressLink": "impostazioni utente", "map.noHomeAddressAfter": " per usare il filtro per distanza.", "map.groupMap": "Mappa", + "map.foldToggle": "Mostra o nascondi i filtri della mappa", "map.groupListings": "Annunci", "map.searchPlaceholder": "Cerca un indirizzo o un luogo", "map.searchLabel": "Ricerca di indirizzi sulla mappa", diff --git a/ui/src/locales/tr.json b/ui/src/locales/tr.json index 37bcf562..66073257 100644 --- a/ui/src/locales/tr.json +++ b/ui/src/locales/tr.json @@ -406,6 +406,7 @@ "map.noHomeAddressLink": "kullanıcı ayarlarından", "map.noHomeAddressAfter": " uzaklık filtresini kullanmak için yapılandırın.", "map.groupMap": "Harita", + "map.foldToggle": "Harita filtrelerini göster veya gizle", "map.groupListings": "İlanlar", "map.searchPlaceholder": "Adres veya yer ara", "map.searchLabel": "Haritada adres arama", diff --git a/ui/src/views/listings/Map.jsx b/ui/src/views/listings/Map.jsx index 60a23cfe..3a0e12df 100644 --- a/ui/src/views/listings/Map.jsx +++ b/ui/src/views/listings/Map.jsx @@ -17,10 +17,12 @@ import { groupListingsByPosition, } from './mapUtils.js'; import { Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19'; +import { IconChevronDown } from '@douyinfe/semi-icons'; import _RangeSlider from 'react-range-slider-input'; import 'react-range-slider-input/dist/style.css'; import './Map.less'; +import './Map.mobile.less'; import { xhrDelete, errorMessage } from '../../services/xhr.js'; import { Link, useNavigate, useSearchParams } from 'react-router'; import ListingDeletionModal from '../../components/ListingDeletionModal.jsx'; @@ -661,112 +663,138 @@ export default function MapView() { what the map is showing, so they read as one panel with a line between them rather than as two identical boxes four pixels apart, neither of them with a heading. */
- {/* The fullscreen toggle rides on this heading rather than floating above the - panel: it is a control over the map as a whole, and this is the line that names - the map. */} -
+ {/* The fold toggle: a plain checkbox driving CSS, not React state, so the panel + starts folded on a phone and stays open everywhere else without a resize + listener - see Map.mobile.less for the media query that pins it open above the + breakpoint and the one that ties `:checked` to the body's visibility. + Unchecked by default: nothing here reads the fold state back, it is view, not + data, so there is nothing to initialise beyond the folded starting point. */} + +
- {controls} - -
- -
{t('map.groupListings')}
- -
- - {t('map.filterJobLabel')} - - -
+ + + +
+ {/* The fullscreen toggle rides on this heading rather than floating above the + panel: it is a control over the map as a whole, and this is the line that + names the map. The text repeats the fold label above once unfolded, so + Map.mobile.less hides it below the fold breakpoint and leaves the button. */} +
+ {t('map.groupMap')} + {expandButton} +
+ {controls} + +
+ +
{t('map.groupListings')}
+ +
+ + {t('map.filterJobLabel')} + + +
- {/* Disabled rather than hidden, and it says why one line below. A control that + {/* Disabled rather than hidden, and it says why one line below. A control that cannot work is the honest place for that sentence - it used to be a full-width banner above the map, on every visit, for a fact that never changes. */} -
- - {t('map.filterDistanceLabel')} - - -
+
+ + {t('map.filterDistanceLabel')} + + +
- {/* Locked rather than hidden, for the same reason as the ring above. Unlike the + {/* Locked rather than hidden, for the same reason as the ring above. Unlike the distance ring, which recolours pins, this one hides them: a commute ceiling is asked as "show me only what I could live with". */} -
- - {t('map.filterCommuteLabel')} - - -
+
+ + {t('map.filterCommuteLabel')} + + +
-
- - {t('map.filterPriceLabel')} - -
-
- {formatEuroCompact(priceRange[0], locale)} - {formatEuroCompact(priceRange[1] || getMaxPrice(), locale)} +
+ + {t('map.filterPriceLabel')} + +
+
+ {formatEuroCompact(priceRange[0], locale)} + {formatEuroCompact(priceRange[1] || getMaxPrice(), locale)} +
+
-
-
- {!hasHome && ( -
- {t('map.noHomeAddressBefore')} - {t('map.noHomeAddressLink')} - {t('map.noHomeAddressAfter')} -
- )} + {!hasHome && ( +
+ {t('map.noHomeAddressBefore')} + {t('map.noHomeAddressLink')} + {t('map.noHomeAddressAfter')} +
+ )} -
- 0 && hasHome} hasHome={hasHome} /> +
+ 0 && hasHome} hasHome={hasHome} /> +
)} /> diff --git a/ui/src/views/listings/Map.mobile.less b/ui/src/views/listings/Map.mobile.less new file mode 100644 index 00000000..7564c4ed --- /dev/null +++ b/ui/src/views/listings/Map.mobile.less @@ -0,0 +1,127 @@ +@import '../../tokens.less'; + +/* The filter panel's fold/unfold. A checkbox drives it, not React state: the panel starts folded + on a phone and pinned open everywhere else through the media query below, with no resize + listener needed for either direction. Kept in its own file and behind a single media query so + upstream merges stay simple. */ + +.map-panel__fold-input { + /* Not `display: none` - a hidden checkbox is not focusable, and the label above still needs to + toggle it from the keyboard. */ + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.map-panel__fold-label { + display: none; +} + +@media (max-width: 430px) { + /* `.map-panel__fold-label` and `.map-panel__body` position themselves against this box (see + below), which on every other width holds the single dropdown box to the shape it needs. + Stretched to the map's own bounds here instead, so each of the two has the whole map to place + itself against rather than the collapsed, zero-size box an otherwise-empty `.map-shell__ui` + would leave them with once neither is `.map-panel`'s own in-flow child any more. */ + .map-shell__ui { + inset: 0; + width: auto; + max-height: none; + overflow: visible; + } + + /* `.map-panel` drew the desktop dropdown's own box - background, border, blur, padding - and on + a phone that box was wrapping the pill too, a card behind a chip nobody asked for. `contents` + removes the box without removing what is inside it: the pill and the popup below still exist + and still respond to the checkbox, just no longer sharing one growing shape or one position. + Each now carries its own box straight against `.map-shell__ui` instead. */ + .map-panel { + display: contents; + } + + /* The pill: fixed to the map's bottom-left corner and never resized by the popup opening, unlike + the single box this used to be one growing piece of. Its own background, border and blur are + what `.map-panel` supplied before - moved here now that the box that grows is a separate + element. */ + .map-panel__fold-label { + position: absolute; + left: 12px; + /* Matches MapLibre's own margin around its bottom-right attribution control, so the pill sits + in the same row as the "i" on the opposite corner rather than floating above it. The two + overlap until the map is first panned or zoomed, since the attribution runs its full text + rather than collapsing to that icon until then; z-index below is what keeps the pill on top + and clickable through that, rather than moving to dodge it. */ + bottom: 10px; + z-index: 15; + display: inline-flex; + width: fit-content; + align-items: center; + gap: @space-2; + padding: 10px 16px; + border-radius: 999px; + cursor: pointer; + background: @color-map-panel; + border: 1px solid @color-border; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: @shadow-sm; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: @color-map-panel-text; + } + + .map-panel__fold-chevron { + transition: transform @transition-fast; + } + + .map-panel__fold-input:checked ~ .map-panel__fold-label .map-panel__fold-chevron { + transform: rotate(180deg); + } + + /* Closed by default (the checkbox above starts unchecked) and, unlike the pill, not part of the + page until then - the map controls and every listing filter are what used to cover the whole + map on a phone. */ + .map-panel__body { + display: none; + } + + /* Open: its own floating box - background, border, blur, shadow, the same look `.map-panel` + drew before - anchored just above the pill rather than grown out of it, so the pill stays put + in its own corner while this appears and disappears above it. */ + .map-panel__fold-input:checked ~ .map-panel__body { + display: flex; + flex-direction: column; + gap: 12px; + position: absolute; + left: 12px; + right: 12px; + /* Pill height (36px) + its own 10px margin + a little breathing room between the two boxes. */ + bottom: 56px; + /* Leaves the search box and MapLibre's own zoom/compass controls reachable above it rather + than sliding the sheet in under them. */ + max-height: calc(100% - 56px - 100px); + overflow-y: auto; + padding: 14px 16px; + background: @color-map-panel; + border: 1px solid @color-border; + border-radius: @radius-card; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: @shadow-md; + z-index: 15; + } + + /* The body's own "Map" heading would repeat the fold label word for word once open; only its + fullscreen button still earns a row here. */ + .map-panel__body > .map-panel__groupTitle:first-child .map-panel__groupTitle-text { + display: none; + } +}