From dfaa559b4634dde08ef8cd40ef3b3e3a47104190 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 11 Nov 2026 23:56:53 +0000 Subject: [PATCH] feat(web): filter every league and team list (v0.28.0) A search box above each list of sports, leagues, teams and follows: the sports index, a sport's leagues, a league's teams, the feeds directory and Following. Every word typed must appear in the row's name, in any order and ignoring case ("city man" finds Manchester City). Rendered hidden and shown by app.js, so no-JS readers never see a dead control; re-initialised after in-page navigation. Co-Authored-By: Claude Opus 5.5 --- apps/web/public/app.js | 49 ++++++++++++++++ apps/web/public/styles.css | 18 +++++- apps/web/src/views/components.jsx | 20 ++++++- apps/web/src/views/feeds.jsx | 11 ++-- apps/web/src/views/pages.jsx | 39 ++++++++----- apps/web/test/list-filter.test.jsx | 92 ++++++++++++++++++++++++++++++ package.json | 2 +- 7 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 apps/web/test/list-filter.test.jsx diff --git a/apps/web/public/app.js b/apps/web/public/app.js index c910e8f..484c704 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -2,6 +2,53 @@ form; this file localises times and adds push notifications and passkeys where the browser has them. */ +/* ----------------------------------------------------------- list filter -- */ + +/** + * Filter a list of leagues or teams as you type. + * + * Every word must appear somewhere in the row's `data-filter` text, in any order + * and ignoring case: "city man" keeps Manchester City, "MAN" keeps it too. The + * box is server-rendered `hidden` and only shown here, so with JavaScript off + * nobody sees a control that does nothing. + */ +function listFilterWords(query) { + return String(query || '') + .toLocaleLowerCase() + .split(/\s+/) + .filter(Boolean); +} + +function listFilterMatches(text, words) { + const hay = String(text || '').toLocaleLowerCase(); + return words.every((w) => hay.includes(w)); +} + +function initListFilters(root = document) { + for (const box of root.querySelectorAll('[data-list-filter]')) { + if (box.dataset.ready) continue; + const list = document.getElementById(box.dataset.listFilter); + const input = box.querySelector('input'); + if (!list || !input) continue; + box.dataset.ready = '1'; + box.hidden = false; + const empty = box.querySelector('.list-filter-empty'); + const apply = () => { + const words = listFilterWords(input.value); + let shown = 0; + for (const li of list.children) { + const ok = listFilterMatches(li.dataset.filter ?? li.textContent, words); + li.hidden = !ok; + if (ok) shown++; + } + if (empty) empty.hidden = shown > 0; + }; + input.addEventListener('input', apply); + // A value the browser restored on back/forward must filter too. + if (input.value) apply(); + } +} + /* ------------------------------------------------------------ local time -- */ /** @@ -880,6 +927,7 @@ function initNavigation() { if (push) history.pushState({}, '', url); window.scrollTo(0, 0); localiseTimes(); + initListFilters(); initMarketTabs(); initOwnChannelActions(); initInlinePlayer(); @@ -919,6 +967,7 @@ function initNavigation() { } localiseTimes(); +initListFilters(); reportTimezone(); initMarketTabs(); initOwnChannelActions(); diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css index 9d7930d..43f4983 100644 --- a/apps/web/public/styles.css +++ b/apps/web/public/styles.css @@ -2956,4 +2956,20 @@ ul.group-grid .meta { --live: oklch(0.72 0.19 25); --ok: oklch(0.78 0.16 155); } -} \ No newline at end of file +} +/* ---- league / team list filter (shown by app.js) ---- */ +.list-filter { + margin: 0.75rem 0; +} +.list-filter input[type="search"] { + max-width: 24rem; +} +.list-filter-empty { + margin-top: 0.5rem; +} +ul.sports > li[hidden], +ul.leagues > li[hidden], +ul.teams > li[hidden], +ul.chips > li[hidden] { + display: none; +} diff --git a/apps/web/src/views/components.jsx b/apps/web/src/views/components.jsx index 463704d..bee360f 100644 --- a/apps/web/src/views/components.jsx +++ b/apps/web/src/views/components.jsx @@ -609,9 +609,27 @@ export const EventList = ({ events, emptyText, showBroadcast = false, showOdds = ); +/** + * A filter box for a list of leagues or teams. + * + * Every word typed has to appear in a row, in any order and ignoring case, so + * "city man" finds Manchester City. Rendered `hidden` and switched on by app.js: + * without JavaScript the box would do nothing, and a control that does nothing + * is worse than no control. Rows match on their `data-filter` text, which is the + * name alone, so "upcoming" or "follow" never matches every row. + */ +export const ListFilter = ({ target, label }) => ( + +); + /** A team in the follow picker. */ export const TeamRow = ({ team, user, next }) => ( -
  • +
  • {team.logo_url ? ( ) : ( diff --git a/apps/web/src/views/feeds.jsx b/apps/web/src/views/feeds.jsx index ded6f27..21776a1 100644 --- a/apps/web/src/views/feeds.jsx +++ b/apps/web/src/views/feeds.jsx @@ -1,4 +1,5 @@ import { brand, Word } from '@tipoff/config'; +import { ListFilter } from './components.jsx'; import { Layout } from './Layout.jsx'; /** @@ -31,9 +32,10 @@ export const Feeds = ({ user, sports, leagues }) => (

    By {brand.words.category}

    -
      + +
        {sports.map((s) => ( -
      • +
      • {s.sport.replace(/-/g, ' ')} @@ -50,9 +52,10 @@ export const Feeds = ({ user, sports, leagues }) => ( and a calendar at /calendar/league/<slug>.ics, whether or not it is listed here.

        -
          + +
            {leagues.map((l) => ( -
          • +
          • {l.name} {l.upcoming} upcoming · calendar diff --git a/apps/web/src/views/pages.jsx b/apps/web/src/views/pages.jsx index 0b47006..b99058b 100644 --- a/apps/web/src/views/pages.jsx +++ b/apps/web/src/views/pages.jsx @@ -1,6 +1,6 @@ import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile'; -import { brand, href, Word } from '@tipoff/config'; import { keyVaultCardHtml } from '@profullstack/sealed-secrets/vault-card'; +import { brand, href, Word } from '@tipoff/config'; import { raw } from 'hono/html'; import { assetUrl } from '../lib/asset-version.js'; import { coinpayConfig } from '../lib/coinpay-signin.js'; @@ -11,6 +11,7 @@ import { EventList, FollowButton, KickoffTime, + ListFilter, LocalTime, OddsPanel, oddsOf, @@ -781,9 +782,10 @@ export const SportsIndex = ({
          • {Word.collection}
          • {Word.participant}
          • -
              + +
                {sports.map((s) => ( -
              • +
              • {categoryLabel(s.sport)} @@ -1069,9 +1071,10 @@ export const SportPage = ({ heading={`Watch ${name} now`} blurb="Live channels, playing here. No account, nothing to install." /> -
                  + +
                    {leagues.map((l) => ( -
                  • +
                  • {l.name} {brand.copy.emptyParticipants}

                    ) : ( -
                      - {teams.map((t) => ( - - ))} -
                    + <> + +
                      + {teams.map((t) => ( + + ))} +
                    + )}

                    Upcoming fixtures

                    @@ -1539,9 +1545,10 @@ export const Following = ({ user, events, follows, cleared, calendarUrl }) => ( -
                      + +
                        {follows.map((f) => ( -
                      • +
                      • {f.label}
                        @@ -3236,7 +3243,13 @@ export const Settings = ({ {contactError ? : null} {contactSaved ? : null} - {raw(renderProfileFields({ fields: SIGNUP_FIELDS, values: contact ?? {}, importOpenProfile: true }))} + {raw( + renderProfileFields({ + fields: SIGNUP_FIELDS, + values: contact ?? {}, + importOpenProfile: true, + }), + )}