mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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>
393 lines
16 KiB
TypeScript
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
|