mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
## What kind of change does this PR introduce? Accessibility / lint hardening (Safari keyboard focus). ## What is the current behavior? `supabase/require-explicit-tabindex` is `'warn'`. Studio’s ratchet was at 0 but the rule was still ratcheted; www / docs / design-system still had raw `<button>` / `role="button"` call sites without an explicit `tabIndex`. [DEPR-627](https://linear.app/supabase/issue/DEPR-627) · follow-up to #47984 / #48040 ## What is the new behavior? - Shared config: `'supabase/require-explicit-tabindex': 'error'` - Swept www / docs / design-system (+ Studio test fixtures the ratchet skipped) - Removed the rule from the Studio ratchet + baselines ## To test Prefer **Safari**. This PR only adds explicit `tabIndex` to raw `<button>` / `role="button"` call sites — not links, and not controls that already go through `Button` from `ui`. ### Marketing (`www`) ([staging link](https://zone-www-dot-com-git-danny-depr-627-promote-req-7ae43c-supabase.vercel.app/)) - [x] Homepage frameworks / dashboard feature tabs — Tab through each tab button - [x] Product pages (e.g. `/auth`, `/database`) — section tab switchers - [x] Narrow viewport — open the hamburger; Tab through menu buttons - [x] `/partners/catalog` — filter / view controls - [x] Blog view toggle (list ↔ grid) ### Docs ([staging link](https://docs-git-danny-depr-627-promote-require-explici-25e46d-supabase.vercel.app/)) - [x] **Desktop (≥ lg):** top-right **⋯ menu** (hamburger icon) — opens a dropdown that includes Theme. Not a separate theme button. - [x] **Mobile (< lg):** top-right **hamburger** opens the sheet; close (X) is the raw button we tagged. Theme inside the sheet uses `ThemeToggle` / `DropdownMenuTrigger` from `ui` (already supposed to set `tabIndex`). - [x] **Code blocks** — copy / language controls - [x] **Is this helpful?** — X / check are `Button` from `ui` (should already Tab). After voting **while signed in**, the follow-up “What went well?” / “How can we improve?” text button is the raw one we tagged. - [x] **AI Tools → Copy as Markdown** (right rail on a guide) — this is the only GuidesSidebar control this PR changed. “On this page” TOC items are **links**, not covered by this lint. - [x] **Reference docs** (e.g. JS client reference) — section headers that expand/collapse in the left nav (`Collapsible.Trigger`) - [x] **Troubleshooting index** — type in the search field, then Tab to the **clear (X)** control ### Dashboard (`studio`) No production UI changes in this PR (tests + lint config only). Quick Safari smoke that prior tabindex work still holds: - [x] Project sidebar — Tab through primary nav links - [x] Settings → General — Tab through inputs / buttons - [x] Storage → Files — Tab a bucket row / file actions
392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useBreakpoint } from 'common'
|
|
import { ChevronDown, RotateCw, Search, X } from 'lucide-react'
|
|
import { useQueryStates } from 'nuqs'
|
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Button_Shadcn_, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, Input } from 'ui'
|
|
import { MultiSelector } from 'ui-patterns/multi-select'
|
|
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import { type ITroubleshootingMetadata } from './Troubleshooting.utils'
|
|
import {
|
|
TROUBLESHOOTING_CONTAINER_ID,
|
|
TROUBLESHOOTING_DATA_ATTRIBUTES,
|
|
troubleshootingSearchParams,
|
|
} from './Troubleshooting.utils.shared'
|
|
|
|
function useTroubleshootingSearchState() {
|
|
const [_state, _setState] = useQueryStates(troubleshootingSearchParams)
|
|
|
|
const setSelectedProducts = useCallback(
|
|
(products: string[]) => {
|
|
_setState({
|
|
products: products.length === 0 ? [] : products,
|
|
})
|
|
},
|
|
[_setState]
|
|
)
|
|
const setSelectedErrorCodes = useCallback(
|
|
(errorCodes: string[]) => {
|
|
_setState({
|
|
errorCodes: errorCodes.length === 0 ? [] : errorCodes,
|
|
})
|
|
},
|
|
[_setState]
|
|
)
|
|
const setSelectedTags = useCallback(
|
|
(tags: string[]) => {
|
|
_setState({
|
|
tags: tags.length === 0 ? [] : tags,
|
|
})
|
|
},
|
|
[_setState]
|
|
)
|
|
const setSearchState = useCallback(
|
|
(search: string) => {
|
|
_setState({
|
|
search: search.length === 0 ? null : search,
|
|
})
|
|
},
|
|
[_setState]
|
|
)
|
|
|
|
const reset = useCallback(() => {
|
|
setSearchState('')
|
|
setSelectedTags([])
|
|
setSelectedProducts([])
|
|
setSelectedErrorCodes([])
|
|
}, [setSearchState, setSelectedTags, setSelectedProducts, setSelectedErrorCodes])
|
|
|
|
return {
|
|
selectedProducts: _state.products,
|
|
selectedErrorCodes: _state.errorCodes,
|
|
selectedTags: _state.tags,
|
|
searchState: _state.search,
|
|
setSelectedProducts,
|
|
setSelectedErrorCodes,
|
|
setSelectedTags,
|
|
setSearchState,
|
|
reset,
|
|
}
|
|
}
|
|
|
|
function entryMatchesFilter(
|
|
entry: HTMLElement,
|
|
selectedProducts: string[],
|
|
selectedErrorCodes: string[],
|
|
selectedTags: string[],
|
|
searchState: string
|
|
) {
|
|
const content = entry.textContent ?? ''
|
|
const dataKeywords = entry.getAttribute(TROUBLESHOOTING_DATA_ATTRIBUTES.KEYWORDS_LIST_ATTRIBUTE)
|
|
const dataProducts = entry.getAttribute(TROUBLESHOOTING_DATA_ATTRIBUTES.PRODUCTS_LIST_ATTRIBUTE)
|
|
const dataErrors = entry.getAttribute('data-errors')?.split(',') ?? []
|
|
|
|
const productsMatch =
|
|
selectedProducts.length === 0 ||
|
|
selectedProducts.some((product) => dataProducts?.includes(product))
|
|
const tagsMatch =
|
|
selectedTags.length === 0 || selectedTags.some((tag) => dataKeywords?.includes(tag))
|
|
const errorsMatch =
|
|
selectedErrorCodes.length === 0 ||
|
|
selectedErrorCodes.some((error) =>
|
|
dataErrors.some((errorCode) => errorCode.includes(error.toString()))
|
|
)
|
|
const searchMatch =
|
|
searchState === '' || content.toLowerCase().includes(searchState.toLowerCase())
|
|
|
|
return productsMatch && errorsMatch && tagsMatch && searchMatch
|
|
}
|
|
|
|
interface TroubleshootingFilterProps {
|
|
className?: string
|
|
products?: string[]
|
|
errors: ITroubleshootingMetadata['errors']
|
|
keywords: string[]
|
|
}
|
|
|
|
export function TroubleshootingFilter(props: TroubleshootingFilterProps) {
|
|
return (
|
|
<Suspense fallback={<ShimmeringLoader className="h-7 py-0" />}>
|
|
<TroubleshootingFilterMobileCollapsed {...props} />
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
function TroubleshootingFilterMobileCollapsed(props: TroubleshootingFilterProps) {
|
|
const isBelowSmallScreen = useBreakpoint('sm')
|
|
const { selectedProducts, selectedErrorCodes, selectedTags, searchState } =
|
|
useTroubleshootingSearchState()
|
|
|
|
const numberFiltersApplied =
|
|
(selectedProducts.length > 0 ? 1 : 0) +
|
|
(selectedErrorCodes.length > 0 ? 1 : 0) +
|
|
(selectedTags.length > 0 ? 1 : 0) +
|
|
(searchState ? 1 : 0)
|
|
|
|
if (isBelowSmallScreen) {
|
|
return (
|
|
<Collapsible className="border-b">
|
|
<CollapsibleTrigger className="group w-full pb-6 text-foreground-light">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span>Filters</span>
|
|
<ChevronDown size={16} className="group-data-open:rotate-180 transition-transform" />
|
|
</div>
|
|
{numberFiltersApplied > 0 && (
|
|
<div className="group-data-open:hidden text-sm text-left">
|
|
{numberFiltersApplied} filter{numberFiltersApplied > 1 ? 's' : ''} applied
|
|
</div>
|
|
)}
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="flex flex-col gap-2">
|
|
<TroubleshootingFilterInternal {...props} />
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)
|
|
}
|
|
|
|
return <TroubleshootingFilterInternal {...props} />
|
|
}
|
|
|
|
function TroubleshootingFilterInternal({
|
|
keywords,
|
|
products,
|
|
errors,
|
|
className,
|
|
}: TroubleshootingFilterProps) {
|
|
const {
|
|
selectedProducts,
|
|
selectedErrorCodes,
|
|
selectedTags,
|
|
searchState,
|
|
setSelectedProducts,
|
|
setSelectedErrorCodes,
|
|
setSelectedTags,
|
|
setSearchState,
|
|
reset,
|
|
} = useTroubleshootingSearchState()
|
|
|
|
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
|
|
|
const allEntries = useRef<HTMLElement[]>([])
|
|
useEffect(() => {
|
|
const container = document.getElementById(TROUBLESHOOTING_CONTAINER_ID)
|
|
if (!container) return
|
|
|
|
const entries = Array.from(
|
|
container.querySelectorAll(
|
|
`[${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE}="${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY}"]`
|
|
)
|
|
) as HTMLElement[]
|
|
allEntries.current = entries
|
|
}, [])
|
|
|
|
const allErrorCodes: string[] = useMemo(
|
|
() =>
|
|
Array.from(
|
|
new Set(
|
|
errors?.flatMap((error) => {
|
|
const result: string[] = []
|
|
if (error.http_status_code) {
|
|
result.push(String(error.http_status_code))
|
|
}
|
|
if (error.code) {
|
|
result.push(error.code)
|
|
}
|
|
return result
|
|
}) ?? []
|
|
)
|
|
),
|
|
[errors]
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<h2 className="sr-only">Search and filter</h2>
|
|
<div className={cn('flex flex-wrap gap-2 items-center', className)}>
|
|
{!!products && (
|
|
<MultiSelector values={selectedProducts} onValuesChange={setSelectedProducts}>
|
|
<MultiSelector.Trigger badgeLimit={1} className="w-48" label="Products" />
|
|
<MultiSelector.Content>
|
|
<MultiSelector.List>
|
|
{products?.map((product) => (
|
|
<MultiSelector.Item key={`product-${product}`} value={product}>
|
|
{product}
|
|
</MultiSelector.Item>
|
|
))}
|
|
</MultiSelector.List>
|
|
</MultiSelector.Content>
|
|
</MultiSelector>
|
|
)}
|
|
<MultiSelector values={selectedErrorCodes} onValuesChange={setSelectedErrorCodes}>
|
|
<MultiSelector.Trigger badgeLimit={1} className="w-48" label="Error codes" />
|
|
<MultiSelector.Content>
|
|
<MultiSelector.List>
|
|
{allErrorCodes.map((error) => (
|
|
<MultiSelector.Item key={`error-${error}`} value={error}>
|
|
{error}
|
|
</MultiSelector.Item>
|
|
))}
|
|
</MultiSelector.List>
|
|
</MultiSelector.Content>
|
|
</MultiSelector>
|
|
<MultiSelector values={selectedTags} onValuesChange={setSelectedTags}>
|
|
<MultiSelector.Trigger badgeLimit={1} className="w-48" label="Tags" />
|
|
<MultiSelector.Content>
|
|
<MultiSelector.List>
|
|
{keywords?.map((keyword) => (
|
|
<MultiSelector.Item key={`keyword-${keyword}`} value={keyword}>
|
|
{keyword}
|
|
</MultiSelector.Item>
|
|
))}
|
|
</MultiSelector.List>
|
|
</MultiSelector.Content>
|
|
</MultiSelector>
|
|
<div className="relative">
|
|
<Input
|
|
id="troubleshooting-search"
|
|
ref={searchInputRef}
|
|
type="text"
|
|
placeholder="Search by keyword"
|
|
className="pl-8 pr-8 h-[40px] w-60 rounded-md border-alternative placeholder:text-foreground-light"
|
|
value={searchState}
|
|
onChange={(e) => setSearchState(e.target.value)}
|
|
/>
|
|
<Search
|
|
aria-hidden
|
|
className="absolute left-2 top-1/2 -translate-y-1/2 text-foreground-light"
|
|
size={16}
|
|
/>
|
|
{searchState && (
|
|
<button
|
|
tabIndex={0}
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 text-foreground-light border-alternative hover:border-stronger rounded-md p-1 transition-colors"
|
|
onClick={() => {
|
|
setSearchState('')
|
|
searchInputRef.current?.focus()
|
|
}}
|
|
>
|
|
<span className="sr-only">Clear search</span>
|
|
<X size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Button_Shadcn_
|
|
variant="outline"
|
|
className="rounded-md text-foreground-light h-[40px] w-[40px] p-0"
|
|
onClick={reset}
|
|
>
|
|
<RotateCw size={16} />
|
|
<span className="sr-only">Reset filters</span>
|
|
</Button_Shadcn_>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function TroubleshootingFilterEmptyState() {
|
|
return (
|
|
<Suspense>
|
|
<TroubleshootingFilterEmptyStateInternal />
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
function TroubleshootingFilterEmptyStateInternal() {
|
|
const allEntries = useRef<HTMLElement[] | undefined>(undefined)
|
|
const { selectedProducts, selectedErrorCodes, selectedTags, searchState, reset } =
|
|
useTroubleshootingSearchState()
|
|
|
|
const [numberResults, setNumberResults] = useState<number | undefined>(undefined)
|
|
|
|
useEffect(() => {
|
|
const container = document.getElementById(TROUBLESHOOTING_CONTAINER_ID)
|
|
if (!container) return
|
|
|
|
const entries = Array.from(
|
|
container.querySelectorAll(
|
|
`[${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE}="${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY}"]`
|
|
)
|
|
) as HTMLElement[]
|
|
|
|
allEntries.current = entries
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!allEntries.current) return
|
|
|
|
const numberEntries = allEntries.current.filter((entry) => !entry.hidden).length
|
|
setNumberResults(numberEntries)
|
|
}, [searchState, selectedProducts, selectedErrorCodes, selectedTags])
|
|
|
|
return numberResults === 0 ? (
|
|
<span className="flex items-center gap-4 text-foreground-light">
|
|
No results found.
|
|
<Button_Shadcn_ variant="outline" className="flex items-center gap-2" onClick={reset}>
|
|
<RotateCw size={16} /> Reset filters
|
|
</Button_Shadcn_>
|
|
</span>
|
|
) : null
|
|
}
|
|
|
|
/**
|
|
* This component is used to control the visibility of the list of
|
|
* troubleshooting entries.
|
|
*
|
|
* Filtering is done wth imperative DOM manipulation rather than mapping and
|
|
* filtering the target list in React, in order to opt the full troubleshooting
|
|
* list into server-side rendering.
|
|
*/
|
|
export function TroubleshootingListController() {
|
|
return (
|
|
<Suspense>
|
|
<TroubleshootingListControllerInternal />
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
function TroubleshootingListControllerInternal() {
|
|
const allEntries = useRef<HTMLElement[]>([])
|
|
|
|
const { selectedProducts, selectedErrorCodes, selectedTags, searchState } =
|
|
useTroubleshootingSearchState()
|
|
|
|
useEffect(() => {
|
|
const container = document.getElementById(TROUBLESHOOTING_CONTAINER_ID)
|
|
if (!container) return
|
|
|
|
const entries = Array.from(
|
|
container.querySelectorAll(
|
|
`[${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE}="${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY}"]`
|
|
)
|
|
) as HTMLElement[]
|
|
allEntries.current = entries
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!searchState &&
|
|
selectedProducts.length === 0 &&
|
|
selectedErrorCodes.length === 0 &&
|
|
selectedTags.length === 0
|
|
) {
|
|
allEntries.current.forEach((entry) => {
|
|
entry.style.removeProperty('display')
|
|
})
|
|
} else {
|
|
allEntries.current.forEach((entry) => {
|
|
if (
|
|
entryMatchesFilter(entry, selectedProducts, selectedErrorCodes, selectedTags, searchState)
|
|
) {
|
|
entry.style.removeProperty('display')
|
|
} else {
|
|
entry.style.display = 'none'
|
|
}
|
|
})
|
|
}
|
|
}, [searchState, selectedProducts, selectedErrorCodes, selectedTags])
|
|
|
|
return null
|
|
}
|