Files
supabase/apps/docs/components/Feedback/Feedback.tsx
T
Danny White 6f6badae51 fix(eslint): promote require-explicit-tabindex to error (#48170)
## 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
2026-07-23 05:21:15 +10:00

236 lines
7.8 KiB
TypeScript

'use client'
import { createClient } from '@supabase/supabase-js'
import { IS_PLATFORM } from '~/lib/constants'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import { gotrueClient, useConstant, useIsLoggedIn, type Database } from 'common'
import { Check, MessageSquareQuote, X } from 'lucide-react'
import { usePathname } from 'next/navigation'
import {
forwardRef,
useReducer,
useRef,
useState,
type CSSProperties,
type MouseEventHandler,
} from 'react'
import { Button, cn } from 'ui'
import { getSanitizedTabParams } from './Feedback.utils'
import { FeedbackModal, type FeedbackFields } from './FeedbackModal'
const FeedbackButton = forwardRef<
HTMLButtonElement,
{ isYes: boolean; onClick: MouseEventHandler; visible: boolean }
>(({ isYes, onClick, visible }, ref) => {
const isLoggedIn = useIsLoggedIn()
if (!isLoggedIn) return null
return (
<button
tabIndex={0}
ref={ref}
className={cn(
'mt-0',
'flex items-center gap-1',
'text-xs text-foreground-lighter',
'hover:text-foreground text-left',
!visible && 'opacity-0 invisible',
'[transition-property:opacity,color]',
'[transition-delay:700ms,0ms]'
)}
onClick={onClick}
>
{isYes ? <>What went well?</> : <>How can we improve?</>}
<MessageSquareQuote size={14} strokeWidth={1.5} />
</button>
)
})
FeedbackButton.displayName = 'FeedbackButton'
type Response = 'yes' | 'no'
enum StateType {
Unanswered = 'unanswered',
Followup = 'followup',
}
type State = { type: StateType.Unanswered } | { type: StateType.Followup; response: Response }
type Action = { event: 'VOTED'; response: Response }
const initialState = { type: StateType.Unanswered } satisfies State
function reducer(state: State, action: Action) {
switch (action.event) {
case 'VOTED':
if (state.type === StateType.Unanswered)
return { type: StateType.Followup, response: action.response }
default:
return state
}
}
function Feedback({ className }: { className?: string }) {
const [state, dispatch] = useReducer(reducer, initialState)
const [modalOpen, setModalOpen] = useState(false)
const feedbackButtonRef = useRef<HTMLButtonElement>(null)
const pathname = usePathname() ?? ''
const sendTelemetryEvent = useSendTelemetryEvent()
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
const supabase = useConstant(() =>
IS_PLATFORM && supabaseUrl && supabaseAnonKey
? createClient<Database>(supabaseUrl, supabaseAnonKey)
: undefined
)
const unanswered = state.type === 'unanswered'
const isYes = 'response' in state && state.response === 'yes'
const isNo = 'response' in state && state.response === 'no'
const showYes = unanswered || isYes
const showNo = unanswered || isNo
async function sendFeedbackVote(response: Response) {
if (!supabase) return
const { error } = await supabase
.from('feedback')
.insert({ vote: response, page: pathname, metadata: { query: getSanitizedTabParams() } })
if (error) console.error(error)
}
function handleVote(response: Response) {
sendTelemetryEvent({
action: 'docs_feedback_clicked',
properties: { response },
})
sendFeedbackVote(response)
dispatch({ event: 'VOTED', response })
// Focus so screen reader users are aware of the new element
setTimeout(() => {
feedbackButtonRef.current?.focus()
// Wait for element to show up first
}, 700)
}
function refocusButton() {
setTimeout(() => {
feedbackButtonRef.current?.focus()
// Wait for modal to disappear and button to become focusable again
}, 100)
}
async function handleSubmit({ comment, title }: FeedbackFields) {
if (supabase) {
const userId = (await gotrueClient.getSession()).data.session?.user?.id ?? null
const { error } = await supabase.from('feedback_comments').insert({
page: pathname,
// @ts-expect-error -- the comment modal only opens after a vote, so state.response is set
vote: state.response,
title,
comment,
user_id: userId,
metadata: { query: getSanitizedTabParams() },
})
if (error) console.error(error)
}
setModalOpen(false)
refocusButton()
}
return (
<section className={cn('@container', className)} aria-labelledby="feedback-title">
<h3 id="feedback-title" className="block font-mono text-xs text-foreground-light mb-3">
Is this helpful?
</h3>
<div className="relative flex flex-col gap-2 @[12rem]:gap-4 @[12rem]:flex-row @[12rem]:items-center">
<div
style={{ '--container-flex-gap': '0.5rem' } as CSSProperties}
className="relative flex gap-2 items-center"
>
<Button
variant="outline"
rounded
className={cn(
'px-1 w-7 h-7',
'text-foreground-light',
'[transition-property:opacity,transform,color] [transition-duration:150ms,250ms,250ms]',
'motion-reduce:[transition-duration:150ms,1ms,300ms]',
'[transition-timing-function:cubic-bezier(.76,0,.23,1)]',
!isNo && 'hover:text-warning hover:border-warning-500',
isNo && `bg-warning text-warning-200 border-warning! disabled:opacity-100`,
!showNo && 'opacity-0 invisible'
)}
onClick={() => handleVote('no')}
disabled={state.type === StateType.Followup}
>
<X size={14} strokeWidth={2} className="text-current" />
<span className="sr-only">No</span>
</Button>
<Button
variant="outline"
rounded
className={cn(
'px-1 w-7 h-7',
'text-foreground-light',
'[transition-property:opacity,transform,color] [transition-duration:150ms,250ms,250ms]',
'motion-reduce:[transition-duration:150ms,1ms,300ms]',
'[transition-timing-function:cubic-bezier(.76,0,.23,1)]',
!isYes && 'hover:text-brand-600 hover:border-brand-500',
isYes &&
'bg-brand text-brand-200 border-brand! disabled:opacity-100 -translate-x-[calc(100%+var(--container-inline-flex-gap,0.5rem))]',
!showYes && 'opacity-0 invisible'
)}
onClick={() => handleVote('yes')}
disabled={state.type === StateType.Followup}
>
<Check size={14} strokeWidth={2} />
<span className="sr-only">Yes</span>
</Button>
</div>
<div
className={cn(
'flex flex-col gap-0.5',
'@[12rem]:absolute @[12rem]:left-9',
'text-xs',
'opacity-0 invisible',
'text-left',
'-translate-x-2',
'[transition-property:opacity,transform]',
'[transition-duration:450ms,300ms]',
'[transition-delay:200ms,0ms]',
'[transition-timing-function:cubic-bezier(.76,0,.23,1)]',
'motion-reduce:[transition-duration:150ms,1ms]',
'ease-out!',
state.type === StateType.Followup && 'opacity-100 visible translate-x-0'
)}
>
{state.type === StateType.Followup && (
<>
<span className="text-foreground-light">Thanks for your feedback!</span>
<FeedbackButton
ref={feedbackButtonRef}
onClick={() => setModalOpen(true)}
isYes={isYes}
visible={true}
/>
</>
)}
</div>
</div>
<FeedbackModal
visible={modalOpen}
page={pathname}
onCancel={() => {
setModalOpen(false)
refocusButton()
}}
onSubmit={handleSubmit}
/>
</section>
)
}
export { Feedback }