diff --git a/src/components/PlacesView.tsx b/src/components/PlacesView.tsx index 6b0a8bd..71d3d6b 100644 --- a/src/components/PlacesView.tsx +++ b/src/components/PlacesView.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { useI18n, type Key } from '../lib/i18n'; import { VERDICT_ORDER } from '../lib/assess'; import { fold } from '../lib/text'; +import { useDragScroll } from '../lib/useDragScroll'; import type { Assessment, Place, PlaceCategory, Profile } from '../lib/types'; import { CategoryIcon, Icon, VerdictTag } from './ui'; @@ -45,8 +46,9 @@ interface Props { export function PlacesView({ profile, filters, onFilters, results, counts, assessments, onSelect }: Props) { const { t } = useI18n(); const [shown, setShown] = useState(PAGE); + const chipsRef = useRef(null); + useDragScroll(chipsRef); const visible = useMemo(() => results.slice(0, shown), [results, shown]); - const activeFilters = (filters.onlySaved ? 1 : 0) + (filters.onlyFit ? 1 : 0); const groups = useMemo(() => { const m = new Map(); for (const p of results) m.set(p.cat, [...(m.get(p.cat) ?? []), p]); @@ -64,20 +66,17 @@ export function PlacesView({ profile, filters, onFilters, results, counts, asses
set({ query: e.target.value })} /> -
- {activeFilters ? t('places.filtersActive', { n: activeFilters }) : t('places.filters')} -
- set({ onlySaved: e.target.checked })} /> - +
+ +
-
- set({ onlyFit: e.target.checked })} /> - -
-
-
+
{(['all', ...CATS] as const).filter((c) => c === 'all' || c === filters.cat || (counts[c] ?? 0) > 0).map((c) => ( )} )) ) : ( -
    +
      {visible.map((p) => )}
    )} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 85c6ad0..435efd0 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -139,7 +139,8 @@ const pl = { 'places.category': 'Rodzaj miejsca', 'places.onlyFit': 'Pokaż tylko pasujące lub częściowo pasujące', 'places.filters': 'Filtry', - 'places.filtersActive': 'Filtry ({n} aktywne)', + 'places.fav': 'Zapisane', + 'places.fit': 'Pasujące', 'sheet.expand': 'Rozwiń panel', 'sheet.collapse': 'Zwiń panel', 'places.count': 'Wyniki: {n}', @@ -931,7 +932,8 @@ const en: Dict = { 'places.category': 'Type of place', 'places.onlyFit': 'Show only matching or partly matching', 'places.filters': 'Filters', - 'places.filtersActive': 'Filters ({n} active)', + 'places.fav': 'Saved', + 'places.fit': 'Fits me', 'sheet.expand': 'Expand panel', 'sheet.collapse': 'Collapse panel', 'places.count': 'Results: {n}', diff --git a/src/lib/useDragScroll.ts b/src/lib/useDragScroll.ts new file mode 100644 index 0000000..71da536 --- /dev/null +++ b/src/lib/useDragScroll.ts @@ -0,0 +1,67 @@ +import { useEffect, type RefObject } from 'react'; + +const THRESHOLD_PX = 5; + +/** + * Lets a mouse user grab a horizontally scrolling row and drag it (touch already scrolls natively), and turns the vertical wheel + * into sideways scrolling while the row can still move that way. A drag does not count as a click on the item under the pointer. + */ +export function useDragScroll(ref: RefObject) { + useEffect(() => { + const el = ref.current; + if (!el) return; + let startX = 0, startLeft = 0, down = false, dragging = false; + + const onDown = (e: PointerEvent) => { + if (e.pointerType !== 'mouse' || e.button !== 0) return; + down = true; + dragging = false; + startX = e.clientX; + startLeft = el.scrollLeft; + }; + const onMove = (e: PointerEvent) => { + if (!down) return; + const dx = e.clientX - startX; + if (!dragging && Math.abs(dx) > THRESHOLD_PX) { + dragging = true; + el.classList.add('is-dragging'); + el.setPointerCapture(e.pointerId); + } + if (dragging) el.scrollLeft = startLeft - dx; + }; + const end = (e: PointerEvent) => { + if (!down) return; + down = false; + if (!dragging) return; + if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId); + el.classList.remove('is-dragging'); + // the release after a drag must not "click" the chip under the pointer + const swallow = (ev: Event) => { ev.stopPropagation(); ev.preventDefault(); }; + el.addEventListener('click', swallow, { capture: true, once: true }); + window.setTimeout(() => el.removeEventListener('click', swallow, { capture: true }), 0); + dragging = false; + }; + const onWheel = (e: WheelEvent) => { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; // already a sideways gesture (trackpad) + const max = el.scrollWidth - el.clientWidth; + if (max <= 0) return; + const next = el.scrollLeft + e.deltaY; + if ((e.deltaY < 0 && el.scrollLeft <= 0) || (e.deltaY > 0 && el.scrollLeft >= max)) return; // at the end: let the page scroll + e.preventDefault(); + el.scrollLeft = Math.min(max, Math.max(0, next)); + }; + + el.addEventListener('pointerdown', onDown); + el.addEventListener('pointermove', onMove); + el.addEventListener('pointerup', end); + el.addEventListener('pointercancel', end); + el.addEventListener('wheel', onWheel, { passive: false }); + return () => { + el.removeEventListener('pointerdown', onDown); + el.removeEventListener('pointermove', onMove); + el.removeEventListener('pointerup', end); + el.removeEventListener('pointercancel', end); + el.removeEventListener('wheel', onWheel); + }; + }, [ref]); +} diff --git a/src/styles/app.css b/src/styles/app.css index 7826615..f69c274 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -149,8 +149,6 @@ body { margin: 0; } .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) */ @@ -325,7 +323,6 @@ body { margin: 0; } .kbb-chips--scroll { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 0.25rem; margin-right: -1rem; padding-right: 1rem; } .kbb-chips--scroll::-webkit-scrollbar { display: none; } .kbb-chips--scroll .kbb-chip { flex: none; white-space: nowrap; } -@media (min-width: 64em) { .kbb-chips--scroll { flex-wrap: wrap; overflow: visible; margin-right: 0; padding-right: 0; } } .kbb-group { margin-top: 1.25rem; } .kbb-group__h { margin: 0 0 0.5rem; padding-bottom: 0.25rem; border-bottom: 2px solid #dfe1e2; font-size: 1.15rem; } .kbb-facts-h { display: flex; align-items: center; margin: 1.25rem 0 0.5rem; font-size: 1.1rem; } @@ -596,3 +593,42 @@ body { margin: 0; } .kbb-spinner { width: 2.75rem; height: 2.75rem; border: 0.35rem solid #c9d3da; border-top-color: #005ea2; border-radius: 50%; animation: kbb-spin 0.9s linear infinite; } @keyframes kbb-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .kbb-maploading::after { animation: none; width: 100%; opacity: 0.6; } .kbb-spinner { animation-duration: 3s; } } + +/* ---- category filter: one slim scrolling row instead of a wall of big pills ---- */ +.kbb-chips--scroll { gap: 0.4rem; margin: 0.4rem -1rem 0.25rem -0.4rem; padding: 0.4rem 1rem 0.5rem 0.4rem; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: thin; scrollbar-color: #a9aeb1 transparent; + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent); } +.kbb-chips--scroll::-webkit-scrollbar { display: block; height: 0.3rem; } +.kbb-chips--scroll::-webkit-scrollbar-thumb { background: #a9aeb1; border-radius: 0.15rem; } +.kbb-chips--scroll .kbb-chip { scroll-snap-align: start; gap: 0.35rem; min-height: 2.25rem; padding: 0.2rem 0.7rem 0.2rem 0.55rem; border: 1px solid #a9aeb1; border-radius: 1.2rem; font-size: 0.9rem; font-weight: 600; color: #1b1b1b; background: #fff; } +.kbb-chips--scroll .kbb-chip:hover { border-color: #005ea2; background: #f0f7fc; } +.kbb-chips--scroll .kbb-chip.is-on { border-color: #005ea2; background: #005ea2; color: #fff; } +.kbb-chips--scroll .kbb-chip__n { padding: 0; background: none; font-size: 0.8rem; font-weight: 400; color: #565c65; } +.kbb-chips--scroll .kbb-chip.is-on .kbb-chip__n { background: none; color: rgba(255, 255, 255, 0.85); } +.kbb-chips--scroll .kbb-cat--sm { width: 1.25rem; height: 1.25rem; border-radius: 0; background: none; color: #005ea2; } +.kbb-chips--scroll .kbb-chip.is-on .kbb-cat--sm { background: none; color: #fff; } +.kbb-chips--scroll .kbb-chip:first-child { padding-left: 0.8rem; } +@media (max-width: 63.99em), (pointer: coarse) { .kbb-chips--scroll .kbb-chip { min-height: 2.75rem; } } + +.kbb-chips--scroll .kbb-chip:focus-visible { outline: 3px solid #2491ff; outline-offset: 1px; } + +/* two on/off filters, side by side under the search box */ +.kbb-toggles { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; } +.kbb-toggle { appearance: none; display: inline-flex; align-items: center; gap: 0.35rem; min-height: 2.25rem; padding: 0.2rem 0.8rem 0.2rem 0.65rem; border: 1px solid #a9aeb1; border-radius: 1.2rem; background: #fff; color: #1b1b1b; font: inherit; font-size: 0.9rem; font-weight: 600; cursor: pointer; } +.kbb-toggle .usa-icon { fill: #005ea2; } +.kbb-toggle:hover { border-color: #005ea2; background: #f0f7fc; } +.kbb-toggle.is-on { border-color: #005ea2; background: #e7f2fa; box-shadow: inset 0 0 0 1px #005ea2; } +.kbb-toggle:focus-visible { outline: 3px solid #2491ff; outline-offset: 1px; } +@media (max-width: 63.99em), (pointer: coarse) { .kbb-toggle { min-height: 2.75rem; } } + +@media (hover: hover) { .kbb-chips--scroll { cursor: grab; } } +.kbb-chips--scroll.is-dragging { cursor: grabbing; user-select: none; scroll-snap-type: none; } +.kbb-chips--scroll.is-dragging .kbb-chip { pointer-events: none; } + +/* place tiles: real space between them, and "show all" as a proper button under the list */ +.kbb-places { display: grid; gap: 0.6rem; } +.kbb-places > li { margin: 0 !important; } +/* quiet by design: it is a shortcut, the tiles are the content */ +.kbb-more.usa-button { display: flex; align-items: center; justify-content: flex-start; gap: 0.25rem; width: 100%; min-height: 2.75rem; margin: 0.25rem 0 0; padding: 0.4rem 0.25rem; border-radius: 0.25rem; background: transparent; box-shadow: none; color: #454545; font-size: 0.9rem; font-weight: 400; text-align: left; } +.kbb-more.usa-button:hover { background: #f0f0f0; color: #005ea2; text-decoration: underline; } +.kbb-more.usa-button:focus-visible { outline: 3px solid #2491ff; outline-offset: 1px; } +.kbb-more .usa-icon { flex: none; fill: currentColor; }