mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +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
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
'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>
|
||
)
|
||
}
|