mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 05:39:11 +03:00
## What kind of change does this PR introduce? Accessibility cleanup (DEPR-628). ## What is the current behavior? Leftover call sites still use ad-hoc focus recipes (`ring-foreground-muted`, `outline-brand`, Dialog/Sheet `focus:` rings, etc.) instead of the shared utilities from #41575. ## What is the new behavior? Converts those leftovers across `packages/ui`, Studio, www, docs, and design-system to `focus-ring`, preferring `focus-visible`. Keeps documented exceptions (`group-focus-visible`, InputGroup `:has()`). ## To test Tab through controls (keyboard only). Expect a consistent offset ring on `:focus-visible`, not a green/brand/custom stack, and no ring animation. ### www (marketing) Preview: https://zone-www-dot-com-git-danny-depr-628-focus-ring-fbccf9-supabase.vercel.app - Global nav on `/`: Product, Developers, Solutions dropdowns; logo; hamburger + mobile menu - `/features`: view toggles and feature cards - `/company`: card links - `/changelog`: timeline / entry links - `/partners/catalog`: grid/list toggle and partner cards - `/pricing`: compute section expand control - Product / Modules / Solutions sticky navs on product pages (e.g. `/database`, `/storage`) - `/state-of-startups`: TwoOptionToggle if present ### docs Preview: https://docs-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app - Any guide page: top nav dropdowns and items - Narrow viewport: hamburger, then mobile menu links + close - Guide with PromptPanel / tabs: tab to prompt actions and tab list ### studio (dashboard) Preview: https://studio-staging-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app - Project home: Connect section tiles; drag-handle focus on sortable sections - Integrations marketplace (`/project/<ref>/integrations`): featured cards, list/grid toggle, list rows - Auth (`/project/<ref>/auth/oauth-apps`, `/project/<ref>/auth/providers`): open create/edit sheet, tab to close (X) - Database policies (`/project/<ref>/database/policies`): open policy editor sheet, tab to close - Storage policies (`/project/<ref>/storage/files/policies`): bucket section links; policy modal close - Query performance (`/project/<ref>/observability/query-performance`): info icon buttons on metrics - Replication pipeline detail (if available): slot lag / status info icons - Support (`/support/new`): attachment add/remove controls - Table editor: spreadsheet import preview checkboxes; row text/JSON editor TwoOptionToggle - Any Dialog/Sheet/toast close (X): ring on keyboard focus only, not mouse click ### design-system Preview: https://design-system-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app - Colour palette swatches (keyboard focus) - Form patterns sidepanel example: avatar / focusable control in the example ## Additional context - Linear: [DEPR-628](https://linear.app/supabase/issue/DEPR-628) - Follow-ups: form-group CSS (DEPR-629), Storage columns selection (DEPR-630), ESLint rule (DEPR-632) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Accessibility & Usability** * Standardized keyboard focus indicators across navigation, dialogs, forms, buttons, toggles, links, and tooltips using a consolidated focus style. * Improved toggle controls to use proper button semantics (instead of clickable text), including `aria-pressed`/disabled handling and better keyboard navigation. * **Visual Updates** * Harmonized hover/focus ring visuals across the design system, Studio, documentation, and marketing pages while preserving existing layout and interaction behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
378 lines
16 KiB
TypeScript
378 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 } 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">
|
|
<div 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">
|
|
<div className="flex items-center gap-2 text-foreground-light hover:text-foreground cursor-pointer! hover:cursor-pointer! transition-colors">
|
|
<Checkbox
|
|
id="self-hosted-filter"
|
|
checked={showSelfHostedOnly}
|
|
onCheckedChange={() => setShowSelfHostedOnly(!showSelfHostedOnly)}
|
|
className="[&_input]:m-0"
|
|
/>
|
|
<label
|
|
htmlFor="self-hosted-filter"
|
|
className="text-sm leading-none! flex-1 text-left"
|
|
>
|
|
Show only self-hosted features
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="hidden md:flex flex-col gap-4">
|
|
<h2 className="text-sm text-foreground-lighter">Filter by tags:</h2>
|
|
<div className="flex flex-col gap-2.5">
|
|
{products
|
|
.sort((a, b) => (a.toLowerCase() > b.toLowerCase() ? 1 : -1))
|
|
.map((product) => (
|
|
<div
|
|
key={product}
|
|
className="flex items-center gap-2 text-foreground-light hover:text-foreground cursor-pointer! hover:cursor-pointer! transition-colors"
|
|
>
|
|
<Checkbox
|
|
id={product}
|
|
checked={selectedProducts.includes(product)}
|
|
onCheckedChange={() => handleProductChange(product)}
|
|
className="[&_input]:m-0"
|
|
/>
|
|
<label
|
|
htmlFor={product}
|
|
className="text-sm leading-none! capitalize flex-1 text-left"
|
|
>
|
|
{product}
|
|
</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="text-foreground-muted text-xs">
|
|
Features selected: {filteredFeatures.length}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
tabIndex={HAS_ACTIVE_FILTERS ? 0 : -1}
|
|
block
|
|
variant="dashed"
|
|
onClick={() => {
|
|
setSelectedProducts([])
|
|
setSearchTerm('')
|
|
setShowSelfHostedOnly(false)
|
|
}}
|
|
className={cn(
|
|
'opacity-0 transition-opacity hidden md:block',
|
|
HAS_ACTIVE_FILTERS && 'block! opacity-100'
|
|
)}
|
|
>
|
|
Clear all filters
|
|
</Button>
|
|
</div>
|
|
</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>
|
|
<div className="flex items-center rounded-lg border border-muted">
|
|
<button
|
|
tabIndex={0}
|
|
title="Grid view"
|
|
onClick={() => setViewMode('grid')}
|
|
className={cn(
|
|
'relative flex items-center justify-center w-8 h-8 rounded-l-lg focus-visible:z-10 focus-ring',
|
|
viewMode === 'grid'
|
|
? 'bg-surface-300 text-foreground'
|
|
: 'bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200'
|
|
)}
|
|
>
|
|
<LayoutGrid size={14} />
|
|
</button>
|
|
<button
|
|
tabIndex={0}
|
|
title="Matrix view"
|
|
onClick={() => setViewMode('matrix')}
|
|
className={cn(
|
|
'relative flex items-center justify-center w-8 h-8 border-l border-muted rounded-r-lg focus-visible:z-10 focus-ring',
|
|
viewMode === 'matrix'
|
|
? 'bg-surface-300 text-foreground'
|
|
: 'bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200'
|
|
)}
|
|
>
|
|
<Table2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{viewMode === 'matrix' ? (
|
|
<FeaturesMatrix features={filteredFeatures} />
|
|
) : !filteredFeatures?.length ? (
|
|
<p className="text-foreground-lighter text-sm">
|
|
No features found with these filters
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 md:gap-4">
|
|
{filteredFeatures.map((feature) => (
|
|
<Link
|
|
key={`feat-${feature.title}`}
|
|
href={`/features/${feature.slug}`}
|
|
className="flex flex-col justify-start items-stretch group cursor-pointer rounded-xl focus-ring"
|
|
>
|
|
<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>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SectionContainer>
|
|
</DefaultLayout>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default FeaturesPage
|