Files
supabase/apps/www/pages/features.tsx
Miranda LimonczenkoandClaude Opus 5 60f7903b52 fix(www): group the filter controls and announce the result count (#49410)
Closes FE-4251

## Problem

The filter sidebars are unstructured `div` nesting. Measured on a
preview:

* 9 checkboxes on `/features` and 13 on `/partners/catalog`, none inside
a `fieldset`, a `role="group"`, or any region.
* The `/features` "Filter by tags:" `h2` has no `id`, so nothing can
reference it as a group name.
* The only landmarks on `/features` are two `nav` elements, so the
filter panel is unreachable by landmark navigation.

A screen reader user meets a checkbox announced as "authentication,
checkbox" with nothing conveying that it filters features by tag.

Separately, both result counts update on every filter change with no
live region, so the outcome of toggling a filter is never announced.

## Solution

* Wrap each checkbox set in a labelled `role="group"`.
* Wrap each filter panel in an `aside` labelled "Filters".
* Add `aria-live="polite"` to both result counts.

The two pages name their group differently on purpose. `/features` uses
`aria-labelledby` pointing at the existing `h2`, so the visible heading
is the accessible name. `/partners/catalog` uses `aria-label`, because
`filtersPanel` renders into both the desktop sidebar and the mobile
sheet, so an `id` would appear twice in the DOM. That file already calls
out the duplicate-id hazard at line 152 as its reason for using wrapping
labels.

Chose `role="group"` over `fieldset` and `legend` to avoid resetting UA
styling in a styled sidebar.

The single self-hosted checkbox keeps its own label and needs no group.

## Manual testing

**/features**

1. Open
[/features](https://zone-www-dot-com-git-www-filter-groups-and-live-count-supabase.vercel.app/features)
with Screenreader.
2. Confirm the tag checkboxes report as a group named "Filter by tags:".
3. Confirm a "Filters" landmark appears in landmark navigation.
4. Tick a tag filter. The result count changes and is announced.

**/partners/catalog**

5. Open
[/partners/catalog](https://zone-www-dot-com-git-www-filter-groups-and-live-count-supabase.vercel.app/partners/catalog)
at a desktop width. The filter panel is `hidden md:block`, so the
landmark only exists at md and above.
6. Confirm the category checkboxes report as a group named "Categories".
7. Open the mobile filter sheet at a narrow width and confirm the group
is still named, with no duplicate ids.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Accessibility Improvements**
* Improved screen reader navigation for partner catalog and feature
filters.
  * Added accessible labels and landmarks for filter sections.
  * Updated result counts to be announced when selections change.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:46:03 -07:00

393 lines
16 KiB
TypeScript

import { motion } from 'framer-motion'
import { debounce } from 'lib/helpers'
import { LayoutGrid, Search, Table2 } from 'lucide-react'
import { NextSeo } from 'next-seo'
import { useRouter } from 'next/compat/router'
import Head from 'next/head'
import Link from 'next/link'
import { useCallback, useEffect, useState, type ChangeEvent } from 'react'
import {
Badge,
Button,
Checkbox,
cn,
InputGroup,
InputGroupAddon,
InputGroupInput,
ToggleGroup,
ToggleGroupItem,
} from 'ui'
import {
FeaturesMatrix,
productLabel,
stageBadgeVariant,
stageLabel,
} from '@/components/FeaturesMatrix'
import DefaultLayout from '@/components/Layouts/Default'
import SectionContainer from '@/components/Layouts/SectionContainer'
import Panel from '@/components/Panel'
import { features } from '@/data/features'
import { breadcrumbs } from '@/lib/breadcrumbs'
import { breadcrumbListSchema, serializeJsonLd } from '@/lib/json-ld'
type ViewMode = 'grid' | 'matrix'
// Relevance weights used to rank search results. Title matches are considered
// far more important than body content, so a feature whose title matches the
// query surfaces above features where the term only appears in the description.
// Title weights are deliberately larger than the maximum combined body score
// (SUBTITLE_INCLUDES + DESCRIPTION_INCLUDES), so any title match always ranks
// above a feature that only matches in its subtitle and/or description.
const SEARCH_WEIGHT = {
TITLE_STARTS_WITH: 5,
TITLE_INCLUDES: 4,
SUBTITLE_INCLUDES: 2,
DESCRIPTION_INCLUDES: 1,
} as const
// Score a feature against a (lowercased) search term. Higher is more relevant.
// Returns 0 when nothing matches.
function getSearchScore(feature: (typeof features)[number], term: string): number {
if (!term) return 0
const title = feature.title.toLowerCase()
const subtitle = feature.subtitle.toLowerCase()
const description = feature.description.toLowerCase()
let score = 0
if (title.startsWith(term)) {
score += SEARCH_WEIGHT.TITLE_STARTS_WITH
} else if (title.includes(term)) {
score += SEARCH_WEIGHT.TITLE_INCLUDES
}
if (subtitle.includes(term)) score += SEARCH_WEIGHT.SUBTITLE_INCLUDES
if (description.includes(term)) score += SEARCH_WEIGHT.DESCRIPTION_INCLUDES
return score
}
function FeaturesPage() {
const router = useRouter()
const [searchTerm, setSearchTerm] = useState<string>((router?.query.q as string) || '')
const [selectedProducts, setSelectedProducts] = useState<string[]>(
(router?.query.products as string)?.split(',') || []
)
const [showSelfHostedOnly, setShowSelfHostedOnly] = useState<boolean>(
router?.query.selfHosted === 'true'
)
const [viewMode, setViewMode] = useState<ViewMode>(
(router?.query.view as ViewMode) === 'matrix' ? 'matrix' : 'grid'
)
const HAS_ACTIVE_FILTERS = selectedProducts.length || searchTerm.length || showSelfHostedOnly
const products = Array.from(new Set(features.flatMap((feature) => feature.products)))
// Debounced function to update URL params
const updateQueryParamsDebounced = useCallback(
debounce(() => updateQueryParams(), 300),
[searchTerm, selectedProducts, showSelfHostedOnly, viewMode]
)
const updateQueryParams = () => {
const params = new URLSearchParams()
if (searchTerm) params.set('q', searchTerm)
if (selectedProducts.length > 0) params.set('products', selectedProducts.join(','))
if (showSelfHostedOnly) params.set('selfHosted', 'true')
if (viewMode === 'matrix') params.set('view', 'matrix')
router?.replace({ pathname: '/features', query: params.toString() }, undefined, {
shallow: true,
})
}
// Apply filters based on initial URL params on mount
useEffect(() => {
updateQueryParamsDebounced()
return updateQueryParamsDebounced.cancel // Cleanup on unmount
}, [searchTerm, selectedProducts, updateQueryParamsDebounced])
// Sync state with query parameters when they change
useEffect(() => {
if (router?.query.q !== searchTerm) setSearchTerm((router?.query.q as string) || '')
if (router?.query.products !== selectedProducts.join(',')) {
setSelectedProducts((router?.query.products as string)?.split(',') || [])
}
const selfHostedParam = router?.query.selfHosted === 'true'
if (selfHostedParam !== showSelfHostedOnly) setShowSelfHostedOnly(selfHostedParam)
const viewParam = (router?.query.view as ViewMode) === 'matrix' ? 'matrix' : 'grid'
if (viewParam !== viewMode) setViewMode(viewParam)
}, [router?.query.q, router?.query.products, router?.query.selfHosted, router?.query.view])
// Handle search input change
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
}
// Handle product checkbox change
const handleProductChange = (product: string) => {
setSelectedProducts((prev) =>
prev.includes(product) ? prev.filter((p) => p !== product) : [...prev, product]
)
}
// Filter features based on search term and selected products
const normalizedSearchTerm = searchTerm.toLowerCase()
const filteredFeatures = features
.filter((feature) => {
const matchesSearch =
!normalizedSearchTerm || getSearchScore(feature, normalizedSearchTerm) > 0
const matchesProduct =
selectedProducts.length === 0 ||
feature.products.some((product) => selectedProducts.includes(product))
const matchesSelfHosted =
!showSelfHostedOnly || feature.status?.availableOnSelfHosted === true
return matchesSearch && matchesProduct && matchesSelfHosted
})
// When searching, rank by relevance (title matches weigh more than body
// content), falling back to alphabetical order for ties and no search.
.sort((a, b) => {
if (normalizedSearchTerm) {
const scoreDiff =
getSearchScore(b, normalizedSearchTerm) - getSearchScore(a, normalizedSearchTerm)
if (scoreDiff !== 0) return scoreDiff
}
return a.title > b.title ? 1 : -1
})
const meta = {
title: 'Supabase Features',
description:
'From authentication to storage, everything you need to build and ship your next project.',
}
return (
<>
<NextSeo
title={meta.title}
description={meta.description}
openGraph={{
title: meta.title,
description: meta.description,
url: '/customers',
}}
/>
<Head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: serializeJsonLd(breadcrumbListSchema(breadcrumbs.features)),
}}
/>
</Head>
<DefaultLayout>
<SectionContainer className="py-0! sm:px-0!">
<div className="border border-muted rounded-xl bg-alternative my-4 px-6 py-8 md:py-10 lg:px-16 lg:py-20 xl:px-20 bg-center bg-cover bg-[url('/images/features/features-cover-light.svg')] dark:bg-[url('/images/features/features-cover-dark.svg')]">
<motion.div
className="mx-auto sm:max-w-xl text-center flex flex-col items-center gap-3"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.5, easing: 'easeOut' } }}
>
<h1 className="h1 text-foreground m-0!">Supabase Features</h1>
<p className="text-foreground-light text-base">
Everything you need <br className="md:hidden" /> to build and ship your next
project.
</p>
</motion.div>
</div>
</SectionContainer>
<SectionContainer className="relative grid md:grid-cols-4 md:gap-4 pt-0!">
<div className="relative w-full h-full">
<aside aria-label="Filters" className="mb-4 flex flex-col gap-4 sticky top-20">
<InputGroup className="w-full">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
size="small"
autoComplete="off"
type="search"
placeholder="Search features"
value={searchTerm}
onChange={handleSearchChange}
/>
</InputGroup>
<div className="hidden md:flex flex-col gap-2.5">
<label className="flex cursor-pointer items-center gap-2 text-foreground-light hover:text-foreground transition-colors">
<Checkbox
checked={showSelfHostedOnly}
onCheckedChange={() => setShowSelfHostedOnly(!showSelfHostedOnly)}
className="[&_input]:m-0"
/>
<span className="text-sm leading-none! flex-1 text-left">
Show only self-hosted features
</span>
</label>
</div>
<div className="hidden md:flex flex-col gap-4">
<h2 id="feature-tag-filters" className="text-sm text-foreground-lighter">
Filter by tags:
</h2>
<div
role="group"
aria-labelledby="feature-tag-filters"
className="flex flex-col gap-2.5"
>
{products
.sort((a, b) => (a.toLowerCase() > b.toLowerCase() ? 1 : -1))
.map((product) => (
<label
key={product}
className="flex cursor-pointer items-center gap-2 text-foreground-light hover:text-foreground transition-colors"
>
<Checkbox
checked={selectedProducts.includes(product)}
onCheckedChange={() => handleProductChange(product)}
className="[&_input]:m-0"
/>
<span className="text-sm leading-none! capitalize flex-1 text-left">
{product}
</span>
</label>
))}
</div>
<div
aria-live="polite"
aria-atomic="true"
className="text-foreground-muted text-xs"
>
{`${filteredFeatures.length} features selected`}
</div>
</div>
{HAS_ACTIVE_FILTERS && (
<Button
block
variant="dashed"
onClick={() => {
setSelectedProducts([])
setSearchTerm('')
setShowSelfHostedOnly(false)
}}
className="hidden md:block"
>
Clear all filters
</Button>
)}
</aside>
</div>
<div className="md:col-span-3 min-w-0 flex flex-col gap-4 md:gap-6">
<div className="flex items-center justify-between gap-2">
<span className="text-foreground-muted text-xs">
{filteredFeatures.length} feature{filteredFeatures.length !== 1 ? 's' : ''}
</span>
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(value) => value && setViewMode(value as ViewMode)}
aria-label="Feature layout"
className="flex items-center gap-0 rounded-lg border border-muted"
>
<ToggleGroupItem
value="grid"
title="Grid view"
className={cn(
'relative flex items-center justify-center w-8 h-8 p-0 rounded-none rounded-l-lg focus-visible:z-10 focus-ring',
'cursor-pointer bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200',
'data-[state=on]:cursor-default data-[state=on]:bg-surface-400 data-[state=on]:text-foreground',
'aria-checked:bg-surface-400 aria-checked:text-foreground'
)}
>
<LayoutGrid size={14} />
</ToggleGroupItem>
<ToggleGroupItem
value="matrix"
title="Matrix view"
className={cn(
'relative flex items-center justify-center w-8 h-8 p-0 rounded-none rounded-r-lg border-l border-muted focus-visible:z-10 focus-ring',
'cursor-pointer bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200',
'data-[state=on]:cursor-default data-[state=on]:bg-surface-400 data-[state=on]:text-foreground',
'aria-checked:bg-surface-400 aria-checked:text-foreground'
)}
>
<Table2 size={14} />
</ToggleGroupItem>
</ToggleGroup>
</div>
{viewMode === 'matrix' ? (
<FeaturesMatrix features={filteredFeatures} />
) : !filteredFeatures?.length ? (
<p className="text-foreground-lighter text-sm">
No features found with these filters
</p>
) : (
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 md:gap-4">
{filteredFeatures.map((feature) => (
<li key={`feat-${feature.title}`} className="flex">
<Link
href={`/features/${feature.slug}`}
className="flex flex-col justify-start items-stretch group cursor-pointer rounded-xl focus-ring w-full"
>
<Panel
hasActiveOnHover
outerClassName="h-full"
innerClassName="flex md:flex-col gap-3 sm:gap-2 h-full items-start p-2"
>
<div className="relative rounded-lg min-h-[80px] max-h-[80px] md:max-h-[140px] h-full md:h-auto aspect-square md:w-full md:aspect-video! bg-alternative flex items-center justify-center shadow-inner border border-muted">
<feature.icon className="w-5 h-5 text-foreground-light group-hover:text-foreground transition-colors" />
{feature.status && (
<div className="hidden md:block absolute bottom-1.5 left-1.5">
<Badge
variant={stageBadgeVariant(feature.status.stage)}
className="text-[10px] py-0 px-1.5 h-4 rounded-sm"
>
{stageLabel(feature.status.stage)}
</Badge>
</div>
)}
</div>
<div className="md:p-2 md:pt-1 flex flex-col h-full md:h-auto grow gap-0.5 md:gap-1.5 justify-center md:justify-start">
<h3 className="text-sm md:text-base text-foreground leading-5!">
{feature.title}
</h3>
<p className="text-foreground-light text-sm line-clamp-2">
{feature.subtitle}
</p>
<div className="flex flex-wrap items-center gap-1 mb-0.5">
{feature.status && (
<Badge
variant={stageBadgeVariant(feature.status.stage)}
className="md:hidden text-[10px] py-0 px-1.5 h-4 rounded-sm"
>
{stageLabel(feature.status.stage)}
</Badge>
)}
{feature.products.map((product) => (
<span
key={product}
className="inline-flex items-center text-[10px] font-medium px-1.5 py-0 h-4 rounded bg-surface-200 text-foreground-light border border-muted capitalize"
>
{productLabel(product)}
</span>
))}
</div>
</div>
</Panel>
</Link>
</li>
))}
</ul>
)}
</div>
</SectionContainer>
</DefaultLayout>
</>
)
}
export default FeaturesPage