Works fine

This commit is contained in:
InfoX 2026-10-03 20:11:00 +02:00
parent d9927fc6db
commit 5f5e4d64a6
Signed by: infox
GPG key ID: 0C5A10019B6B50F6
4 changed files with 126 additions and 22 deletions

View file

@ -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>
)} )}

View file

@ -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
View 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]);
}

View file

@ -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; }