Files
supabase/apps/www/components/Contribute/SimilarSolvedThreads.tsx
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

313 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { submitSimilarThreadFeedback, updateSimilarThreadFeedback } from '~/app/contribute/actions'
import type {
SimilarSolvedThread,
SimilarThreadFeedbackReaction,
ThreadSource,
} from '~/types/contribute'
import { ChevronDown, ThumbsDown, ThumbsUp } from 'lucide-react'
import { useRef, useState } from 'react'
import {
Badge,
Button,
Card,
CardContent,
CardFooter,
CardHeader,
cn,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
Label,
TextArea,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from 'ui'
import { ChannelIcon } from './Icons'
function getChannelFromUrl(url: string): ThreadSource {
const u = url.toLowerCase()
if (u.includes('discord')) return 'discord'
if (u.includes('reddit')) return 'reddit'
if (u.includes('github')) return 'github'
// TODO: handle unknown URL patterns more explicitly, don’t just default to GitHub
return 'github'
}
interface SimilarSolvedThreadsProps {
threads: SimilarSolvedThread[]
parentThreadId: string
}
const SimilarThreadCard = ({
thread,
className,
}: {
thread: SimilarSolvedThread
className?: string
}) => {
const channel = getChannelFromUrl(thread.external_activity_url || '')
const filteredStack = thread.stack?.filter((s) => s !== 'Other') ?? []
const hasStack = filteredStack.length > 0
const url = thread.external_activity_url || null
const linkClassName = cn(
'border-b border-border px-6 py-4 flex items-center gap-3 overflow-hidden hover:bg-surface-200 transition-colors',
className
)
const content = (
<>
<div className="flex items-center justify-center bg-surface-200 dark:bg-surface-300 h-10 w-10 rounded-md shrink-0">
<ChannelIcon channel={channel} />
</div>
<div className="min-w-0 flex-1 flex flex-col">
<h4 className="text-base text-foreground truncate block">{thread.subject}</h4>
{thread.problem_description ? (
<p className="text-sm text-foreground-lighter leading-relaxed line-clamp-2">
{thread.problem_description}
</p>
) : null}
{hasStack ? (
<div className="flex flex-wrap gap-x-1.5 gap-y-1 overflow-hidden pt-1">
{filteredStack.map((tech) => (
<Badge key={tech} variant="default">
{tech}
</Badge>
))}
</div>
) : null}
</div>
</>
)
if (url) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={linkClassName}
aria-label={`View thread: ${thread.subject}`}
>
{content}
</a>
)
}
return <div className={linkClassName}>{content}</div>
}
export const SimilarSolvedThreads = ({ threads, parentThreadId }: SimilarSolvedThreadsProps) => {
const [isExpanded, setIsExpanded] = useState(true)
const [submittedReaction, setSubmittedReaction] = useState<SimilarThreadFeedbackReaction | null>(
null
)
const [feedbackId, setFeedbackId] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogReaction, setDialogReaction] = useState<SimilarThreadFeedbackReaction>('positive')
const [dialogFeedback, setDialogFeedback] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const isClosingProgrammatically = useRef(false)
const handleThumbClick = async (reaction: SimilarThreadFeedbackReaction) => {
if (submittedReaction) return
setIsSubmitting(true)
const result = await submitSimilarThreadFeedback({
parentThreadId,
reaction,
similarThreadKey: null,
})
setIsSubmitting(false)
if (result.success) {
setFeedbackId(result.id ?? null)
setDialogReaction(reaction)
setDialogFeedback('')
setDialogOpen(true)
}
}
const persistAndCloseDialog = async () => {
if (!feedbackId) return
setIsSubmitting(true)
const result = await updateSimilarThreadFeedback(
feedbackId,
dialogReaction,
dialogFeedback.trim() || null
)
setIsSubmitting(false)
if (result.success) {
setSubmittedReaction(dialogReaction)
isClosingProgrammatically.current = true
setDialogOpen(false)
queueMicrotask(() => {
isClosingProgrammatically.current = false
})
}
}
const handleDialogOpenChange = (open: boolean) => {
setDialogOpen(open)
if (!open && feedbackId && !isClosingProgrammatically.current) {
persistAndCloseDialog()
}
}
return (
<Card className={cn('relative')}>
<CardHeader className={cn('p-0', !isExpanded && 'border-b-0')}>
<button
tabIndex={0}
type="button"
onClick={() => setIsExpanded((prev) => !prev)}
className="flex w-full items-center gap-1.5 px-(--card-padding-x) py-4 text-left text-xs font-mono uppercase text-card-foreground"
>
Related threads
<span className="text-foreground-muted tabular-nums font-normal">({threads.length})</span>
<ChevronDown
className={`h-3.5 w-3.5 text-foreground-lighter transition-transform duration-200 ${
isExpanded ? 'rotate-0' : '-rotate-90'
}`}
/>
</button>
</CardHeader>
{isExpanded && (
<>
<CardContent className="p-0">
{threads.map((thread, idx) => (
<SimilarThreadCard
key={thread.thread_key || idx}
thread={thread}
className={idx === threads.length - 1 ? 'border-b-0' : undefined}
/>
))}
</CardContent>
<CardFooter className="flex items-center justify-between min-h-[58px]">
{submittedReaction ? (
<span className="text-sm text-foreground-muted">
Thanks for helping improve related threads
</span>
) : (
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
tabIndex={0}
type="button"
onClick={() => handleThumbClick('positive')}
disabled={isSubmitting}
className="p-1 rounded-sm hover:bg-surface-200 transition-colors disabled:opacity-50"
aria-label="Relevant"
>
<ThumbsUp className="h-4 w-4 text-foreground-muted" />
</button>
</TooltipTrigger>
<TooltipContent>Relevant</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
tabIndex={0}
type="button"
onClick={() => handleThumbClick('negative')}
disabled={isSubmitting}
className="p-1 rounded-sm hover:bg-surface-200 transition-colors disabled:opacity-50"
aria-label="Irrelevant"
>
<ThumbsDown className="h-4 w-4 text-foreground-muted" />
</button>
</TooltipTrigger>
<TooltipContent>Irrelevant</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)}
</CardFooter>
</>
)}
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Help improve related threads</DialogTitle>
<DialogDescription>How relevant were these threads to your issue?</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="space-y-4">
<fieldset className="space-y-1">
<legend className="text-sm text-foreground">Relevance</legend>
<div className="flex items-center gap-1">
<label
className={cn(
'p-2 rounded-sm cursor-pointer transition-colors',
dialogReaction === 'positive'
? 'bg-surface-300 text-foreground'
: 'hover:bg-surface-200 text-foreground-lighter'
)}
>
<input
type="radio"
name="relevance"
value="positive"
checked={dialogReaction === 'positive'}
onChange={() => setDialogReaction('positive')}
className="sr-only"
aria-label="Relevant"
/>
<ThumbsUp className="h-4 w-4" aria-hidden />
</label>
<label
className={cn(
'p-2 rounded-sm cursor-pointer transition-colors',
dialogReaction === 'negative'
? 'bg-surface-300 text-foreground'
: 'hover:bg-surface-200 text-foreground-lighter'
)}
>
<input
type="radio"
name="relevance"
value="negative"
checked={dialogReaction === 'negative'}
onChange={() => setDialogReaction('negative')}
className="sr-only"
aria-label="Irrelevant or unhelpful"
/>
<ThumbsDown className="h-4 w-4" aria-hidden />
</label>
</div>
</fieldset>
<div className="space-y-1">
<Label htmlFor="feedback">
Additional feedback <span className="text-foreground-muted">(optional)</span>
</Label>
<TextArea
id="feedback"
placeholder="What was helpful or missing?"
rows={4}
value={dialogFeedback}
onChange={(e) => setDialogFeedback(e.target.value)}
className="text-sm resize-none"
/>
</div>
</DialogSection>
<DialogFooter>
<Button onClick={persistAndCloseDialog} disabled={isSubmitting} loading={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit feedback'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}