Fix mobile layout
This commit is contained in:
parent
96703b6b30
commit
9d4b857893
9 changed files with 187 additions and 42 deletions
64
src/App.tsx
64
src/App.tsx
|
|
@ -9,6 +9,7 @@ import { DEFAULT_FILTERS, PlacesView, filterPlaces, type Filters } from './compo
|
|||
import { ProfileView } from './components/ProfileView';
|
||||
import { RouteView, type RouteForm } from './components/RouteView';
|
||||
import { SettingsPanel } from './components/SettingsPanel';
|
||||
import { SheetHandle, type Sheet } from './components/SheetHandle';
|
||||
import { Alert, Icon } from './components/ui';
|
||||
import { assessPlace, buildIndex } from './lib/assess';
|
||||
import { findAccount, userKey, type Account } from './lib/auth';
|
||||
|
|
@ -26,7 +27,7 @@ import { NoCreditsError, vetReport } from './lib/vet';
|
|||
import { GuideBanner } from './components/GuideBanner';
|
||||
import { findAlternatives } from './lib/route';
|
||||
import { applySettings, defaultSettings, isSettings, type Settings } from './lib/settings';
|
||||
import { clearLocal, newId, useLocalState, useOnline } from './lib/store';
|
||||
import { clearLocal, isMetered, newId, useLocalState, useMedia, useOnline } from './lib/store';
|
||||
import type { Assessment, Lang, Place, Profile, Snapshot, UserReport, Verdict } from './lib/types';
|
||||
|
||||
type View = 'places' | 'route' | 'dashboard' | 'about' | 'profile';
|
||||
|
|
@ -62,7 +63,10 @@ export default function App() {
|
|||
|
||||
const lang: Lang = account ? settings.lang : loginLang;
|
||||
const [view, setView] = useState<View>('places');
|
||||
const [pane, setPane] = useState<'list' | 'map'>('list');
|
||||
// Mobile portrait: the map fills the screen and the panel is a bottom sheet (peek / half / full).
|
||||
const [sheet, setSheet] = useState<Sheet>('half');
|
||||
const sheetMode = useMedia('(max-width: 63.99em) and (orientation: portrait)');
|
||||
const setPane = useCallback((p: 'list' | 'map') => setSheet(p === 'map' ? 'peek' : 'half'), []);
|
||||
const online = useOnline();
|
||||
|
||||
const i18n = useMemo<I18n>(() => {
|
||||
|
|
@ -152,7 +156,7 @@ export default function App() {
|
|||
const fit = inReach.filter((p) => { const v = assessments.get(p.id)?.verdict; return v === 'match' || v === 'partial'; }).length;
|
||||
setReachState({ minutes, summary: { n: inReach.length, fit } });
|
||||
setReachLines(r.lines);
|
||||
setPane('map');
|
||||
setSheet('half');
|
||||
};
|
||||
|
||||
const select = useCallback((id: string | null) => {
|
||||
|
|
@ -199,6 +203,11 @@ export default function App() {
|
|||
...(photo ? { photoId: id } : {}), vetting: { state: 'running' },
|
||||
};
|
||||
if (photo) await putPhoto(id, photo.dataUrl);
|
||||
// the on-device model is ~90 MB: ask before downloading it on a metered link
|
||||
if (photo && isMetered() && !window.confirm(t('vet.metered'))) {
|
||||
setUserReports((cur) => [...cur, { ...rep, vetting: { state: 'failed', reason: 'skipped' } }]);
|
||||
return;
|
||||
}
|
||||
setUserReports((cur) => [...cur, rep]);
|
||||
try {
|
||||
const result = await vetReport(rep, photo ? { dataUrl: photo.dataUrl, blur: photo.blur } : null);
|
||||
|
|
@ -224,7 +233,7 @@ export default function App() {
|
|||
setRouteOptions(o);
|
||||
setRouteSel(0);
|
||||
setRouteEnds(from && to ? { from, to } : null);
|
||||
if (o?.[0] && o[0].result.status !== 'none') setPane('map');
|
||||
if (o?.[0] && o[0].result.status !== 'none') setSheet('half');
|
||||
};
|
||||
useEffect(() => { setRouteOptions(null); setRouteEnds(null); setGuiding(false); }, [profile]);
|
||||
useEffect(() => { if (view !== 'route' || !route || route.status === 'none') setGuiding(false); }, [view, route]);
|
||||
|
|
@ -303,12 +312,12 @@ export default function App() {
|
|||
};
|
||||
|
||||
const official = account?.role === 'official';
|
||||
const nav: { id: View; label: string }[] = [
|
||||
{ id: 'places', label: t('nav.places') },
|
||||
{ id: 'route', label: t('nav.route') },
|
||||
...(official ? [{ id: 'dashboard' as View, label: t('nav.dashboard') }] : []),
|
||||
{ id: 'about', label: t('nav.about') },
|
||||
{ id: 'profile', label: t('nav.profile') },
|
||||
const nav: { id: View; label: string; icon: string }[] = [
|
||||
{ id: 'places', label: t('nav.places'), icon: 'location_on' },
|
||||
{ id: 'route', label: t('nav.route'), icon: 'directions_walk' },
|
||||
...(official ? [{ id: 'dashboard' as View, label: t('nav.dashboard'), icon: 'assessment' }] : []),
|
||||
{ id: 'about', label: t('nav.about'), icon: 'info' },
|
||||
{ id: 'profile', label: t('nav.profile'), icon: 'account_circle' },
|
||||
];
|
||||
const needsProfile = !!account && !profile;
|
||||
const split = !!account && !needsProfile && (view === 'places' || view === 'route');
|
||||
|
|
@ -325,10 +334,10 @@ export default function App() {
|
|||
|
||||
return (
|
||||
<I18nContext.Provider value={i18n}>
|
||||
<div className="kbb-app">
|
||||
<div className={`kbb-app ${guiding && view === 'route' ? 'is-guiding' : ''}`}>
|
||||
<a className="usa-skipnav" href="#main">{t('skip')}</a>
|
||||
|
||||
<section className="usa-site-alert usa-site-alert--info usa-site-alert--slim" aria-label={t('banner.proto')}>
|
||||
<section className="kbb-proto usa-site-alert usa-site-alert--info usa-site-alert--slim" aria-label={t('banner.proto')}>
|
||||
<div className="usa-alert">
|
||||
<div className="usa-alert__body">
|
||||
<p className="usa-alert__text">
|
||||
|
|
@ -346,15 +355,16 @@ export default function App() {
|
|||
{t('app.name')}
|
||||
</p>
|
||||
<nav aria-label={t('nav.label')} className="kbb-head__nav">
|
||||
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
|
||||
<ul className="kbb-nav usa-button-group usa-button-group--segmented margin-0 padding-0">
|
||||
{nav.map((n) => (
|
||||
<li className="usa-button-group__item" key={n.id}>
|
||||
<button
|
||||
type="button" aria-current={view === n.id ? 'page' : undefined}
|
||||
className={`usa-button usa-button--small ${view === n.id ? '' : 'usa-button--outline'}`}
|
||||
className={`kbb-nav__btn usa-button usa-button--small ${view === n.id ? '' : 'usa-button--outline'}`}
|
||||
onClick={() => setView(n.id)}
|
||||
>
|
||||
{n.label}
|
||||
<Icon name={n.icon} size={3} className="kbb-nav__icon" />
|
||||
<span>{n.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -380,28 +390,11 @@ export default function App() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{split && (
|
||||
<div className="desktop:display-none bg-base-lightest border-bottom-1px border-base-lighter padding-1">
|
||||
<div role="group" aria-label={t('view.label')} className="grid-container padding-0">
|
||||
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
|
||||
{(['list', 'map'] as const).map((p) => (
|
||||
<li className="usa-button-group__item" key={p}>
|
||||
<button type="button" aria-pressed={pane === p} className={`usa-button usa-button--small ${pane === p ? '' : 'usa-button--outline'}`} onClick={() => setPane(p)}>
|
||||
<Icon name={p === 'list' ? 'list' : 'map'} size={3} className="margin-right-05" />
|
||||
{t(p === 'list' ? 'view.list' : 'view.map')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{guiding && view === 'route' && route && route.status !== 'none' && (
|
||||
<GuideBanner result={route} onStop={() => setGuiding(false)} onPos={setUserPos} onReroute={reroute} />
|
||||
)}
|
||||
|
||||
<main id="main" ref={mainRef} tabIndex={-1} className={`kbb-main ${split ? `kbb-main--split is-${pane}` : ''}`}>
|
||||
<main id="main" ref={mainRef} tabIndex={-1} className={`kbb-main ${split ? `kbb-main--split is-${sheet}` : ''}`}>
|
||||
{loadError && <div className="grid-container padding-y-4"><Alert kind="error" role="alert">{t('common.loadError')}</Alert></div>}
|
||||
{!loadError && !ready && !needsProfile && view !== 'profile' && (
|
||||
<div className="grid-container padding-y-4" role="status">{t('common.loading')}</div>
|
||||
|
|
@ -451,6 +444,8 @@ export default function App() {
|
|||
{split && ready && (
|
||||
<>
|
||||
<div className="kbb-panel">
|
||||
{sheetMode && <SheetHandle sheet={sheet} onSheet={setSheet} />}
|
||||
<div className="kbb-panel__body" {...(sheetMode && sheet === 'peek' ? { inert: true } : {})}>
|
||||
{view === 'places' && hotMode && (
|
||||
<div className="margin-bottom-2">
|
||||
<Alert kind="info" slim>
|
||||
|
|
@ -494,6 +489,7 @@ export default function App() {
|
|||
/>
|
||||
)}
|
||||
<p className="font-sans-3xs text-base-dark margin-top-4">{t('footer.attr')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<MapView
|
||||
key={lang}
|
||||
|
|
@ -505,7 +501,7 @@ export default function App() {
|
|||
routeEnds={view === 'route' ? routeEnds : null}
|
||||
altRoutes={view === 'route' ? altRoutes : []}
|
||||
focus={focus}
|
||||
visible={pane === 'map'}
|
||||
visible={sheet}
|
||||
bbox={snap.meta.bbox}
|
||||
online={online}
|
||||
basemap={basemap}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ interface Props {
|
|||
/** Non-selected route options, drawn faintly for comparison. */
|
||||
altRoutes: LatLon[][];
|
||||
focus: { at: LatLon; n: number } | null;
|
||||
/** Re-measure when the pane becomes visible. */
|
||||
visible: boolean;
|
||||
/** Changes whenever the layout around the map changes (bottom sheet moved); triggers a re-measure. */
|
||||
visible: unknown;
|
||||
bbox: [number, number, number, number];
|
||||
online: boolean;
|
||||
basemap: 'map' | 'satellite';
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
|
|||
const { t } = useI18n();
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
const visible = useMemo(() => results.slice(0, shown), [results, shown]);
|
||||
const activeFilters = (filters.cat !== DEFAULT_FILTERS.cat ? 1 : 0) + (filters.onlySaved ? 1 : 0) + (filters.onlyFit ? 1 : 0);
|
||||
const set = (patch: Partial<Filters>) => { setShown(PAGE); onFilters({ ...filters, ...patch }); };
|
||||
|
||||
return (
|
||||
|
|
@ -55,6 +56,8 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
|
|||
<div role="search">
|
||||
<label className="usa-label margin-top-0" htmlFor="q">{t('places.search')}</label>
|
||||
<input id="q" className="usa-input maxw-full" type="search" value={filters.query} autoComplete="off" onChange={(e) => set({ query: e.target.value })} />
|
||||
<details className="kbb-filters margin-top-1">
|
||||
<summary>{activeFilters ? t('places.filtersActive', { n: activeFilters }) : t('places.filters')}</summary>
|
||||
<label className="usa-label" htmlFor="cat">{t('places.category')}</label>
|
||||
<select id="cat" className="usa-select maxw-full" value={filters.cat} onChange={(e) => set({ cat: e.target.value as Filters['cat'] })}>
|
||||
{CATS.map((c) => <option key={c} value={c}>{t(`cat.${c}` as Key)}</option>)}
|
||||
|
|
@ -67,6 +70,7 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
|
|||
<input className="usa-checkbox__input" id="onlyfit" type="checkbox" checked={filters.onlyFit} onChange={(e) => set({ onlyFit: e.target.checked })} />
|
||||
<label className="usa-checkbox__label" htmlFor="onlyfit">{t('places.onlyFit')}</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<p className="margin-top-3 margin-bottom-1 text-bold" role="status" aria-live="polite">{t('places.count', { n: results.length })}</p>
|
||||
|
|
|
|||
33
src/components/SheetHandle.tsx
Normal file
33
src/components/SheetHandle.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useRef } from 'react';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
|
||||
export type Sheet = 'peek' | 'half' | 'full';
|
||||
const ORDER: Sheet[] = ['peek', 'half', 'full'];
|
||||
const SWIPE_PX = 40;
|
||||
|
||||
/** Drag handle of the mobile bottom sheet: tap cycles, swipe up/down moves one step. */
|
||||
export function SheetHandle({ sheet, onSheet }: { sheet: Sheet; onSheet: (s: Sheet) => void }) {
|
||||
const { t } = useI18n();
|
||||
const startY = useRef<number | null>(null);
|
||||
const moved = useRef(false);
|
||||
const step = (d: 1 | -1) => onSheet(ORDER[Math.min(2, Math.max(0, ORDER.indexOf(sheet) + d))] ?? sheet);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button" className="kbb-sheet__handle" aria-expanded={sheet !== 'peek'}
|
||||
aria-label={t(sheet === 'full' ? 'sheet.collapse' : 'sheet.expand')}
|
||||
onPointerDown={(e) => { startY.current = e.clientY; moved.current = false; e.currentTarget.setPointerCapture(e.pointerId); }}
|
||||
onPointerUp={(e) => {
|
||||
const y0 = startY.current;
|
||||
startY.current = null;
|
||||
if (y0 === null) return;
|
||||
const dy = e.clientY - y0;
|
||||
if (Math.abs(dy) >= SWIPE_PX) { moved.current = true; step(dy < 0 ? 1 : -1); }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (moved.current) { moved.current = false; return; }
|
||||
onSheet(sheet === 'full' ? 'half' : sheet === 'half' ? 'full' : 'half');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -178,7 +178,7 @@ export function VettingSummary({ v, detailed = false }: { v: VetState | undefine
|
|||
if (!v) return null;
|
||||
if (v.state === 'running') return <p className="font-sans-xs margin-y-05" role="status"><Icon name="update" size={2} className="margin-right-05" />{t('vet.running')}</p>;
|
||||
if (v.state === 'failed') {
|
||||
return <p className="font-sans-xs margin-y-05 text-error-dark" role="status"><Icon name="error_outline" size={2} className="margin-right-05" />{t(v.reason === 'no-credits' ? 'vet.noCredits' : 'vet.error')}</p>;
|
||||
return <p className="font-sans-xs margin-y-05 text-error-dark" role="status"><Icon name="error_outline" size={2} className="margin-right-05" />{t(v.reason === 'no-credits' ? 'vet.noCredits' : v.reason === 'skipped' ? 'vet.skipped' : 'vet.error')}</p>;
|
||||
}
|
||||
const r = v.result;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ const pl = {
|
|||
'places.search': 'Nazwa lub adres',
|
||||
'places.category': 'Rodzaj miejsca',
|
||||
'places.onlyFit': 'Pokaż tylko pasujące lub częściowo pasujące',
|
||||
'places.filters': 'Filtry',
|
||||
'places.filtersActive': 'Filtry ({n} aktywne)',
|
||||
'sheet.expand': 'Rozwiń panel',
|
||||
'sheet.collapse': 'Zwiń panel',
|
||||
'places.count': 'Wyniki: {n}',
|
||||
'places.more': 'Pokaż więcej',
|
||||
'places.empty': 'Brak miejsc dla tych kryteriów.',
|
||||
|
|
@ -577,6 +581,8 @@ const pl = {
|
|||
'vet.running': 'Ocena automatyczna w toku…',
|
||||
'vet.noCredits': 'Ocena automatyczna niedostępna: Brak kredytów (No Credits left).',
|
||||
'vet.error': 'Ocena automatyczna nie powiodła się.',
|
||||
'vet.skipped': 'Ocena automatyczna pominięta (połączenie mobilne). Zgłoszenie trafi do urzędu bez wstępnej oceny.',
|
||||
'vet.metered': 'Ocena zdjęcia pobiera model AI (ok. 90 MB). Masz połączenie mobilne lub tryb oszczędzania danych. Pobrać teraz? Anuluj = wyślij zgłoszenie bez oceny.',
|
||||
'vet.validity.valid': 'wiarygodne',
|
||||
'vet.validity.unclear': 'niejednoznaczne',
|
||||
'vet.validity.invalid': 'nieprawidłowe',
|
||||
|
|
@ -699,6 +705,10 @@ const en: Dict = {
|
|||
'places.search': 'Name or address',
|
||||
'places.category': 'Type of place',
|
||||
'places.onlyFit': 'Show only matching or partly matching',
|
||||
'places.filters': 'Filters',
|
||||
'places.filtersActive': 'Filters ({n} active)',
|
||||
'sheet.expand': 'Expand panel',
|
||||
'sheet.collapse': 'Collapse panel',
|
||||
'places.count': 'Results: {n}',
|
||||
'places.more': 'Show more',
|
||||
'places.empty': 'No places for these criteria.',
|
||||
|
|
@ -1236,6 +1246,8 @@ const en: Dict = {
|
|||
'vet.running': 'Automatic assessment running…',
|
||||
'vet.noCredits': 'Automatic assessment unavailable: No Credits left.',
|
||||
'vet.error': 'Automatic assessment failed.',
|
||||
'vet.skipped': 'Automatic assessment skipped (mobile connection). The report goes to the city office without a first look.',
|
||||
'vet.metered': 'Assessing the photo downloads an AI model (about 90 MB). You are on a mobile connection or data saver. Download now? Cancel = submit the report without the assessment.',
|
||||
'vet.validity.valid': 'plausible',
|
||||
'vet.validity.unclear': 'unclear',
|
||||
'vet.validity.invalid': 'invalid',
|
||||
|
|
|
|||
|
|
@ -51,3 +51,22 @@ export function useOnline() {
|
|||
}
|
||||
|
||||
export const newId = () => crypto.randomUUID();
|
||||
|
||||
/** Live `matchMedia` result; used to switch between the bottom-sheet and side-by-side layouts. */
|
||||
export function useMedia(query: string) {
|
||||
const [match, setMatch] = useState(() => window.matchMedia(query).matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(query);
|
||||
const on = () => setMatch(mq.matches);
|
||||
on();
|
||||
mq.addEventListener('change', on);
|
||||
return () => mq.removeEventListener('change', on);
|
||||
}, [query]);
|
||||
return match;
|
||||
}
|
||||
|
||||
/** True on a metered link (cellular or data saver): large downloads should ask first. */
|
||||
export function isMetered(): boolean {
|
||||
const c = (navigator as Navigator & { connection?: { saveData?: boolean; type?: string } }).connection;
|
||||
return !!c && (c.saveData === true || c.type === 'cellular');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,4 +185,4 @@ export interface Vetting {
|
|||
export type VetState =
|
||||
| { state: 'running' }
|
||||
| { state: 'done'; result: Vetting }
|
||||
| { state: 'failed'; reason: 'no-credits' | 'error' };
|
||||
| { state: 'failed'; reason: 'no-credits' | 'error' | 'skipped' };
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; }
|
||||
body { margin: 0; }
|
||||
.kbb-app { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||
.kbb-app { display: flex; flex-direction: column; height: 100%; height: 100dvh; min-height: 0; }
|
||||
.kbb-main { flex: 1; min-height: 0; overflow: auto; background: #fff; }
|
||||
.kbb-main:focus { outline: none; }
|
||||
.kbb-narrow { max-width: 44rem; }
|
||||
|
|
@ -24,9 +24,10 @@ body { margin: 0; }
|
|||
.kbb-main--split { grid-template-columns: minmax(24rem, 30rem) minmax(0, 1fr); }
|
||||
.kbb-panel { border-right: 1px solid #dfe1e2; }
|
||||
}
|
||||
@media (max-width: 63.99em) {
|
||||
.kbb-main--split.is-list .kbb-map-wrap { display: none; }
|
||||
.kbb-main--split.is-map .kbb-panel { display: none; }
|
||||
/* phone held sideways: keep list and map side by side */
|
||||
@media (max-width: 63.99em) and (orientation: landscape) {
|
||||
.kbb-main--split { grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr); }
|
||||
.kbb-panel { border-right: 1px solid #dfe1e2; padding-bottom: 1rem; }
|
||||
}
|
||||
|
||||
.kbb-place-btn { appearance: none; cursor: pointer; font: inherit; color: inherit; }
|
||||
|
|
@ -143,3 +144,83 @@ body { margin: 0; }
|
|||
.kbb-guide__voice { display: flex; }
|
||||
.kbb-guide__select { margin: 0; min-height: 2.5rem; padding: 0.4rem 2rem 0.4rem 0.6rem; background-color: transparent; color: #fff; border: 2px solid #fff; font-size: 0.95rem; }
|
||||
.kbb-guide__select option { color: #1b1b1b; }
|
||||
|
||||
/* ================= mobile ================= */
|
||||
.usa-tag { white-space: nowrap; }
|
||||
.kbb-sheet__handle { display: none; }
|
||||
.kbb-nav__icon { display: none; }
|
||||
.kbb-filters > summary { cursor: pointer; font-weight: 700; color: #005ea2; line-height: 2.75rem; }
|
||||
.kbb-filters > summary:focus-visible { outline: 0.25rem solid #2491ff; }
|
||||
|
||||
@media (max-width: 63.99em), (pointer: coarse) {
|
||||
/* touch targets >= 44 px (WCAG 2.5.5 / platform guidance) */
|
||||
.usa-button--small { min-height: 2.75rem; padding-left: 1rem; padding-right: 1rem; }
|
||||
.usa-button--unstyled { display: inline-flex; align-items: center; min-height: 2.75rem; }
|
||||
.usa-checkbox__label, .usa-radio__label { min-height: 2.75rem; padding-top: 0.6rem; padding-bottom: 0.6rem; }
|
||||
details > summary { min-height: 2.75rem; }
|
||||
.kbb-legend summary { display: flex; align-items: center; min-height: 2.75rem; }
|
||||
.kbb-head__tools .usa-button { min-width: 2.75rem; min-height: 2.75rem; }
|
||||
.maplibregl-ctrl-group button { width: 2.75rem; height: 2.75rem; }
|
||||
.usa-button { min-height: 2.75rem; }
|
||||
.usa-input, .usa-select { height: 2.75rem; }
|
||||
.kbb-pin::after { content: ''; position: absolute; inset: -0.45rem; }
|
||||
.font-sans-3xs, .font-sans-2xs { font-size: 0.875rem !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 63.99em) {
|
||||
/* chrome budget: slim header + bottom tab bar, prototype banner moves out of the way */
|
||||
.kbb-proto { display: none; }
|
||||
.kbb-app { padding-bottom: calc(3.5rem + env(safe-area-inset-bottom)); }
|
||||
.kbb-head { flex-wrap: nowrap; padding: 0.35rem 0.75rem; }
|
||||
.kbb-head__brand { font-size: 1.05rem; line-height: 1.2; min-width: 0; }
|
||||
.kbb-head__tools { gap: 0.5rem; flex: none; }
|
||||
|
||||
.kbb-head__nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; width: auto; order: 0; overflow: visible; background: #fff; border-top: 1px solid #a9aeb1; padding-bottom: env(safe-area-inset-bottom); }
|
||||
.kbb-head__nav .kbb-nav { display: flex; width: 100%; box-shadow: none; }
|
||||
.kbb-head__nav .usa-button-group__item { flex: 1 1 0; min-width: 0; margin: 0; }
|
||||
.kbb-head__nav .kbb-nav__btn.usa-button { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.1rem; width: 100%; min-height: 3.5rem; margin: 0; padding: 0.25rem 0.1rem; border-radius: 0; box-shadow: none; background: #fff; color: #3d4551; font-size: 0.8125rem; font-weight: 400; line-height: 1.1; }
|
||||
.kbb-head__nav .kbb-nav__btn.usa-button[aria-current='page'] { background: #e7f6f8; color: #00548a; font-weight: 700; box-shadow: inset 0 0.25rem 0 #005ea2; }
|
||||
.kbb-head__nav .kbb-nav__btn.usa-button:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }
|
||||
.kbb-nav__icon { display: block; fill: currentColor; }
|
||||
|
||||
.kbb-narrow .usa-intro { font-size: 1.05rem; line-height: 1.4; }
|
||||
.kbb-legend { bottom: auto; top: 7rem; left: auto; right: 0.5rem; max-width: min(17rem, calc(100vw - 4rem)); }
|
||||
.kbb-map-note { top: 3.9rem; right: 3.75rem; }
|
||||
.kbb-guide { padding: 0.5rem 0.75rem; gap: 0.5rem; }
|
||||
.kbb-guide .usa-button, .kbb-guide__select { min-height: 2.75rem; }
|
||||
.kbb-guide__actions { flex-wrap: nowrap; width: 100%; gap: 0.4rem; }
|
||||
.kbb-guide__actions > * { flex: 1 1 0; min-width: 0; }
|
||||
.kbb-guide__actions > .kbb-guide__voice { flex-grow: 1.5; }
|
||||
.kbb-guide .usa-button { padding-left: 0.4rem; padding-right: 0.4rem; font-size: 0.9rem; line-height: 1.1; }
|
||||
.kbb-guide__select { width: 100%; font-size: 0.9rem; padding-right: 1.6rem; }
|
||||
.kbb-guide__headline { font-size: 1.25rem; }
|
||||
.is-guiding { padding-bottom: 0; }
|
||||
.is-guiding .kbb-head { display: none; }
|
||||
}
|
||||
|
||||
/* ---- bottom sheet (phone, portrait) ---- */
|
||||
@media (max-width: 63.99em) and (orientation: portrait) {
|
||||
.kbb-main--split { display: block; position: relative; --sheet-h: 55%; }
|
||||
.kbb-main--split.is-peek { --sheet-h: 6.5rem; }
|
||||
.kbb-main--split.is-half { --sheet-h: 55%; }
|
||||
.kbb-main--split.is-full { --sheet-h: 100%; }
|
||||
.kbb-map-wrap { position: absolute; inset: 0; height: auto; }
|
||||
.kbb-panel { position: absolute; left: 0; right: 0; bottom: 0; z-index: 5; height: var(--sheet-h); padding: 0 1rem 2rem; border-radius: 1rem 1rem 0 0; box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.3); overscroll-behavior: contain; transition: height 0.2s ease; }
|
||||
.kbb-main--split.is-peek .kbb-panel { overflow: hidden; }
|
||||
.kbb-sheet__handle { display: flex; align-items: center; justify-content: center; position: sticky; top: 0; z-index: 2; width: calc(100% + 2rem); min-height: 2.75rem; margin: 0 -1rem; padding: 0; border: 0; background: #fff; border-radius: 1rem 1rem 0 0; touch-action: none; cursor: grab; }
|
||||
.kbb-sheet__handle::before { content: ''; width: 2.75rem; height: 0.3rem; border-radius: 0.15rem; background: #71767a; }
|
||||
.kbb-sheet__handle:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }
|
||||
/* keep OSM attribution and map controls above the sheet */
|
||||
.kbb-map-wrap .maplibregl-ctrl-bottom-left, .kbb-map-wrap .maplibregl-ctrl-bottom-right { bottom: var(--sheet-h); transition: bottom 0.2s ease; }
|
||||
.is-guiding .kbb-panel { display: none; }
|
||||
.is-guiding .kbb-map-wrap .maplibregl-ctrl-bottom-left, .is-guiding .kbb-map-wrap .maplibregl-ctrl-bottom-right { bottom: 0; }
|
||||
}
|
||||
|
||||
/* ---- short screens (phone sideways): reclaim height ---- */
|
||||
@media (max-height: 30em) and (orientation: landscape) {
|
||||
.kbb-app { padding-bottom: calc(3rem + env(safe-area-inset-bottom)); }
|
||||
.kbb-head__nav .kbb-nav__btn.usa-button { min-height: 3rem; flex-direction: row; gap: 0.4rem; }
|
||||
.kbb-head__brand { font-size: 1rem; }
|
||||
.kbb-legend { top: 0.5rem; right: 3.75rem; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .kbb-panel, .kbb-map-wrap .maplibregl-ctrl-bottom-left, .kbb-map-wrap .maplibregl-ctrl-bottom-right { transition: none; } }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue