Works fine
This commit is contained in:
parent
d9927fc6db
commit
5f5e4d64a6
4 changed files with 126 additions and 22 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useI18n, type Key } from '../lib/i18n';
|
import { useI18n, type Key } from '../lib/i18n';
|
||||||
import { VERDICT_ORDER } from '../lib/assess';
|
import { VERDICT_ORDER } from '../lib/assess';
|
||||||
import { fold } from '../lib/text';
|
import { fold } from '../lib/text';
|
||||||
|
import { useDragScroll } from '../lib/useDragScroll';
|
||||||
import type { Assessment, Place, PlaceCategory, Profile } from '../lib/types';
|
import type { Assessment, Place, PlaceCategory, Profile } from '../lib/types';
|
||||||
import { CategoryIcon, Icon, VerdictTag } from './ui';
|
import { CategoryIcon, Icon, VerdictTag } from './ui';
|
||||||
|
|
||||||
|
|
@ -45,8 +46,9 @@ interface Props {
|
||||||
export function PlacesView({ profile, filters, onFilters, results, counts, assessments, onSelect }: Props) {
|
export function PlacesView({ profile, filters, onFilters, results, counts, assessments, onSelect }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [shown, setShown] = useState(PAGE);
|
const [shown, setShown] = useState(PAGE);
|
||||||
|
const chipsRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDragScroll(chipsRef);
|
||||||
const visible = useMemo(() => results.slice(0, shown), [results, shown]);
|
const visible = useMemo(() => results.slice(0, shown), [results, shown]);
|
||||||
const activeFilters = (filters.onlySaved ? 1 : 0) + (filters.onlyFit ? 1 : 0);
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const m = new Map<PlaceCategory, Place[]>();
|
const m = new Map<PlaceCategory, Place[]>();
|
||||||
for (const p of results) m.set(p.cat, [...(m.get(p.cat) ?? []), p]);
|
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
|
||||||
<div role="search">
|
<div role="search">
|
||||||
<label className="usa-label margin-top-0" htmlFor="q">{t('places.search')}</label>
|
<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 })} />
|
<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">
|
<div className="kbb-toggles" role="group" aria-label={t('places.filters')}>
|
||||||
<summary>{activeFilters ? t('places.filtersActive', { n: activeFilters }) : t('places.filters')}</summary>
|
<button type="button" aria-pressed={filters.onlySaved} className={`kbb-toggle ${filters.onlySaved ? 'is-on' : ''}`} title={t('places.onlySaved')} onClick={() => set({ onlySaved: !filters.onlySaved })}>
|
||||||
<div className="usa-checkbox margin-top-2">
|
<Icon name={filters.onlySaved ? 'favorite' : 'favorite_border'} size={2} />{t('places.fav')}
|
||||||
<input className="usa-checkbox__input" id="onlysaved" type="checkbox" checked={filters.onlySaved} onChange={(e) => set({ onlySaved: e.target.checked })} />
|
</button>
|
||||||
<label className="usa-checkbox__label" htmlFor="onlysaved">{t('places.onlySaved')}</label>
|
<button type="button" aria-pressed={filters.onlyFit} className={`kbb-toggle ${filters.onlyFit ? 'is-on' : ''}`} title={t('places.onlyFit')} onClick={() => set({ onlyFit: !filters.onlyFit })}>
|
||||||
|
<Icon name="check_circle" size={2} />{t('places.fit')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="usa-checkbox margin-top-2">
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div className="kbb-chips kbb-chips--scroll margin-top-2" role="group" aria-label={t('places.cats')}>
|
<div ref={chipsRef} className="kbb-chips kbb-chips--scroll margin-top-2" role="group" aria-label={t('places.cats')}>
|
||||||
{(['all', ...CATS] as const).filter((c) => c === 'all' || c === filters.cat || (counts[c] ?? 0) > 0).map((c) => (
|
{(['all', ...CATS] as const).filter((c) => c === 'all' || c === filters.cat || (counts[c] ?? 0) > 0).map((c) => (
|
||||||
<button key={c} type="button" aria-pressed={filters.cat === c} className={`kbb-chip ${filters.cat === c ? 'is-on' : ''}`} onClick={() => set({ cat: c })}>
|
<button key={c} type="button" aria-pressed={filters.cat === c} className={`kbb-chip ${filters.cat === c ? 'is-on' : ''}`} onClick={() => set({ cat: c })}>
|
||||||
{c !== 'all' && <CategoryIcon cat={c} size="sm" />}{t(`cat.${c}` as Key)}<span className="kbb-chip__n">{counts[c] ?? 0}</span>
|
{c !== 'all' && <CategoryIcon cat={c} size="sm" />}{t(`cat.${c}` as Key)}<span className="kbb-chip__n">{counts[c] ?? 0}</span>
|
||||||
|
|
@ -94,18 +93,18 @@ export function PlacesView({ profile, filters, onFilters, results, counts, asses
|
||||||
groups.map(([cat, list]) => (
|
groups.map(([cat, list]) => (
|
||||||
<section key={cat} aria-labelledby={`grp-${cat}`} className="kbb-group">
|
<section key={cat} aria-labelledby={`grp-${cat}`} className="kbb-group">
|
||||||
<h2 id={`grp-${cat}`} className="kbb-group__h"><CategoryIcon cat={cat} />{t(`cat.${cat}` as Key)} <span className="text-base-dark text-normal">({list.length})</span></h2>
|
<h2 id={`grp-${cat}`} className="kbb-group__h"><CategoryIcon cat={cat} />{t(`cat.${cat}` as Key)} <span className="text-base-dark text-normal">({list.length})</span></h2>
|
||||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
<ul className="kbb-places usa-list--unstyled margin-0 padding-0">
|
||||||
{list.slice(0, PER_GROUP).map((p) => <PlaceRow key={p.id} place={p} assessment={assessments.get(p.id)} onSelect={onSelect} />)}
|
{list.slice(0, PER_GROUP).map((p) => <PlaceRow key={p.id} place={p} assessment={assessments.get(p.id)} onSelect={onSelect} />)}
|
||||||
</ul>
|
</ul>
|
||||||
{list.length > PER_GROUP && (
|
{list.length > PER_GROUP && (
|
||||||
<button type="button" className="usa-button usa-button--unstyled" onClick={() => set({ cat })}>
|
<button type="button" className="usa-button usa-button--outline kbb-more" onClick={() => set({ cat })}>
|
||||||
{t('places.groupMore', { n: list.length })}
|
<span>{t('places.groupMore', { n: list.length })}</span><Icon name="navigate_next" size={2} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
<ul className="kbb-places usa-list--unstyled margin-0 padding-0">
|
||||||
{visible.map((p) => <PlaceRow key={p.id} place={p} assessment={assessments.get(p.id)} onSelect={onSelect} />)}
|
{visible.map((p) => <PlaceRow key={p.id} place={p} assessment={assessments.get(p.id)} onSelect={onSelect} />)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,8 @@ const pl = {
|
||||||
'places.category': 'Rodzaj miejsca',
|
'places.category': 'Rodzaj miejsca',
|
||||||
'places.onlyFit': 'Pokaż tylko pasujące lub częściowo pasujące',
|
'places.onlyFit': 'Pokaż tylko pasujące lub częściowo pasujące',
|
||||||
'places.filters': 'Filtry',
|
'places.filters': 'Filtry',
|
||||||
'places.filtersActive': 'Filtry ({n} aktywne)',
|
'places.fav': 'Zapisane',
|
||||||
|
'places.fit': 'Pasujące',
|
||||||
'sheet.expand': 'Rozwiń panel',
|
'sheet.expand': 'Rozwiń panel',
|
||||||
'sheet.collapse': 'Zwiń panel',
|
'sheet.collapse': 'Zwiń panel',
|
||||||
'places.count': 'Wyniki: {n}',
|
'places.count': 'Wyniki: {n}',
|
||||||
|
|
@ -931,7 +932,8 @@ const en: Dict = {
|
||||||
'places.category': 'Type of place',
|
'places.category': 'Type of place',
|
||||||
'places.onlyFit': 'Show only matching or partly matching',
|
'places.onlyFit': 'Show only matching or partly matching',
|
||||||
'places.filters': 'Filters',
|
'places.filters': 'Filters',
|
||||||
'places.filtersActive': 'Filters ({n} active)',
|
'places.fav': 'Saved',
|
||||||
|
'places.fit': 'Fits me',
|
||||||
'sheet.expand': 'Expand panel',
|
'sheet.expand': 'Expand panel',
|
||||||
'sheet.collapse': 'Collapse panel',
|
'sheet.collapse': 'Collapse panel',
|
||||||
'places.count': 'Results: {n}',
|
'places.count': 'Results: {n}',
|
||||||
|
|
|
||||||
67
src/lib/useDragScroll.ts
Normal file
67
src/lib/useDragScroll.ts
Normal file
|
|
@ -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<HTMLElement | null>) {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
@ -149,8 +149,6 @@ body { margin: 0; }
|
||||||
.usa-tag { white-space: nowrap; }
|
.usa-tag { white-space: nowrap; }
|
||||||
.kbb-sheet__handle { display: none; }
|
.kbb-sheet__handle { display: none; }
|
||||||
.kbb-nav__icon { 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) {
|
@media (max-width: 63.99em), (pointer: coarse) {
|
||||||
/* touch targets >= 44 px (WCAG 2.5.5 / platform guidance) */
|
/* 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 { 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::-webkit-scrollbar { display: none; }
|
||||||
.kbb-chips--scroll .kbb-chip { flex: none; white-space: nowrap; }
|
.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 { 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-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; }
|
.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; }
|
.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); } }
|
@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; } }
|
@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; }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue