Files
supabase/apps/docs/features/docs/Reference.navigation.client.tsx
Danny White ec53175b8a refactor(ui): rename text-brand to text-primary (#50564)
## What kind of change does this PR introduce?

Refactor. Follow-up to #49871.

## What is the current behavior?

Branded (green) text still uses the `text-brand` classname while the
colour comes from `--primary`.

## What is the new behavior?

**Rename-only:** `text-brand` → `text-primary` across callsites and
docs. Leftover `bg-brand` / `border-brand` alias to `brand-default`. No
intentional colour changes in this PR.

This better matches how we treat our green in other components and
props, like `variant="primary"` for green buttons.

## To test

On light mode: smoke-test that branded text still looks like #49871
(readable green, not the bright fill).

-
[Homepage](https://zone-www-dot-com-git-dnywh-depr-316-text-brand-de2380-supabase.vercel.app/):
“Scale to millions” uses `text-primary`
- [Docs
homepage](https://docs-git-dnywh-depr-316-text-brand-to-primary-supabase.vercel.app/docs):
branded links like “More on self-hosting” are still readable
- [Typography
docs](https://design-system-git-dnywh-depr-316-text-brand-to-primary-supabase.vercel.app/design-system/docs/typography):
documents `text-primary`
- [Colour
usage](https://design-system-git-dnywh-depr-316-text-brand-to-primary-supabase.vercel.app/design-system/docs/color-usage):
`text-primary` is visibly darker than `bg-brand-default` _on light mode_
- [Studio auth
providers](https://studio-staging-git-dnywh-depr-316-text-brand-to-60fa6c-supabase.vercel.app/dashboard/project/_/auth/providers):
enabled provider badge text readable; status dot stays bright green

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Style**
* Updated branded text, link hover states, icons, badges, indicators,
and highlighted content across the Design System, Docs, Studio, Learn,
UI Library, and marketing experiences to use the primary theme color.
* Updated syntax highlighting and table-of-contents styling for
consistent primary-color presentation.
  * Refined brand color fallback behavior for bright fills and borders.

* **Documentation**
* Updated color-usage and typography guidance to recommend the primary
text utility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 16:19:03 +10:00

381 lines
11 KiB
TypeScript

'use client'
import {
NavSectionCaret,
NavSectionContent,
NavSectionList,
} from '~/components/Navigation/NavSection'
import type { AbbrevApiReferenceSection } from '~/features/docs/Reference.utils'
import { isElementInViewport } from '~/features/ui/helpers.dom'
import { BASE_PATH } from '~/lib/constants'
import { debounce } from 'lodash-es'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Collapsible } from 'radix-ui'
import type { HTMLAttributes, MouseEvent, PropsWithChildren } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import { cn } from 'ui'
export const ReferenceContentInitiallyScrolledContext = createContext<boolean>(false)
let patchCount = 0
let originalPushState: typeof history.pushState | null = null
let originalReplaceState: typeof history.replaceState | null = null
const pathnameListeners = new Set<() => void>()
function notifyPathnameListeners() {
pathnameListeners.forEach((callback) => callback())
}
function subscribeToPathname(callback: () => void) {
pathnameListeners.add(callback)
if (patchCount === 0) {
window.addEventListener('popstate', notifyPathnameListeners)
originalPushState = history.pushState.bind(history)
history.pushState = (...args) => {
originalPushState!(...args)
notifyPathnameListeners()
}
originalReplaceState = history.replaceState.bind(history)
history.replaceState = (...args) => {
originalReplaceState!(...args)
notifyPathnameListeners()
}
}
patchCount++
return () => {
pathnameListeners.delete(callback)
patchCount--
if (patchCount === 0) {
window.removeEventListener('popstate', notifyPathnameListeners)
history.pushState = originalPushState!
history.replaceState = originalReplaceState!
originalPushState = null
originalReplaceState = null
}
}
}
function getPathname() {
if (typeof window === 'undefined') return ''
const pathname = window.location.pathname
return pathname.startsWith(BASE_PATH) ? pathname.slice(BASE_PATH.length) : pathname
}
function getServerPathname() {
return ''
}
function useCurrentPathname() {
return useSyncExternalStore(subscribeToPathname, getPathname, getServerPathname)
}
export function ReferenceContentScrollHandler({
libPath,
version,
isLatestVersion,
children,
}: PropsWithChildren<{
libPath: string
version: string
isLatestVersion: boolean
}>) {
const [initiallyScrolled, setInitiallyScrolled] = useState(false)
const pathname = usePathname()
useEffect(() => {
if (!initiallyScrolled) {
const initialSelectedSection = pathname.replace(
`/reference/${libPath}/${isLatestVersion ? '' : `${version}/`}`,
''
)
if (initialSelectedSection) {
const section = document.getElementById(initialSelectedSection)
if (section) {
window.scrollTo(0, section.offsetTop - 60 /* space for header + padding */)
section.querySelector('h2')?.focus()
}
}
setInitiallyScrolled(true)
}
}, [pathname, libPath, version, isLatestVersion, initiallyScrolled])
return (
<ReferenceContentInitiallyScrolledContext.Provider value={initiallyScrolled}>
{children}
</ReferenceContentInitiallyScrolledContext.Provider>
)
}
export function ReferenceNavigationScrollHandler({
children,
...rest
}: PropsWithChildren & HTMLAttributes<HTMLDivElement>) {
const parentRef = useRef<HTMLElement | null>(null)
const ref = useRef<HTMLDivElement | null>(null)
const initialScrollHappened = useContext(ReferenceContentInitiallyScrolledContext)
useEffect(() => {
if (!ref.current) return
let scrollingParent: HTMLElement = ref.current
while (scrollingParent && !(scrollingParent.scrollHeight > scrollingParent.clientHeight)) {
const parent = scrollingParent.parentElement
if (!parent) break
scrollingParent = parent
}
parentRef.current = scrollingParent
}, [])
const scrollActiveIntoView = useCallback(() => {
const currentLink = ref.current?.querySelector('[aria-current=page]') as HTMLElement
if (currentLink && !isElementInViewport(currentLink)) {
// Calculate the offset of the current link relative to scrollingParent
// and scroll the parent to the top of the link.
const offsetTop = currentLink.offsetTop
const parentOffsetTop = parentRef.current?.offsetTop ?? 0
const scrollPosition = offsetTop - parentOffsetTop
parentRef.current?.scrollTo({
top: scrollPosition - 60 /* space for header + padding */,
})
}
}, [])
useEffect(() => {
if (initialScrollHappened) {
scrollActiveIntoView()
}
}, [initialScrollHappened, scrollActiveIntoView])
useEffect(() => {
const debouncedScrollActiveIntoView = debounce(scrollActiveIntoView, 150)
window.addEventListener('scrollend', debouncedScrollActiveIntoView)
return () => window.removeEventListener('scrollend', debouncedScrollActiveIntoView)
}, [scrollActiveIntoView])
return (
<div ref={ref} {...rest}>
{children}
</div>
)
}
function deriveHref(basePath: string, section: AbbrevApiReferenceSection) {
return 'slug' in section ? `${basePath}/${section.slug}` : ''
}
function getLinkStyles(isActive: boolean, className?: string) {
return cn(
'text-sm text-foreground-lighter',
!isActive && 'hover:text-foreground',
isActive && 'text-primary',
'transition-colors',
className
)
}
/**
* Creates a function that navigates to a reference subsection.
*
* Since reference "pages" are actually an agglomeration of many "pages", we
* don't want to actually complete a full page navigation.
*
* @param href - The path to the navigation target.
* @param sectionSlug - The slug of the section to navigate to.
* @returns A function that navigates to the reference subsection.
*/
function createReferenceSubsectionNavigator(href: string, sectionSlug?: string) {
return function navigateToReferenceSubsection(evt: MouseEvent) {
if (sectionSlug) {
evt.preventDefault()
history.pushState({}, '', `${BASE_PATH}${href}`)
const domElement = document.getElementById(sectionSlug)
domElement?.scrollIntoView()
domElement?.querySelector('h2')?.focus()
}
}
}
export function RefInternalLink({
href,
sectionSlug,
children,
}: {
href: string
sectionSlug?: string
children: React.ReactNode
}) {
const onClick = useCallback(
(evt: MouseEvent) => createReferenceSubsectionNavigator(href, sectionSlug)(evt),
[href, sectionSlug]
)
return (
<Link href={href} onClick={onClick}>
{children}
</Link>
)
}
export function RefLink({
basePath,
section,
skipChildren = false,
className,
realNavigation,
}: {
basePath: string
section: AbbrevApiReferenceSection
skipChildren?: boolean
className?: string
// Spike (DOCS-1268): when true, this link does a real navigation instead of
// the scroll-hijack below — used only by the API reference, whose endpoints
// are now real pages. Undefined everywhere else preserves current behavior.
realNavigation?: boolean
}) {
const ref = useRef<HTMLAnchorElement>(null)
const pathname = useCurrentPathname()
const href = deriveHref(basePath, section)
const isActive =
pathname === href || (pathname === basePath && href.replace(basePath, '') === '/introduction')
useEffect(() => {
if (ref.current) {
ref.current.ariaCurrent = isActive ? 'page' : null
ref.current.className = getLinkStyles(isActive, className)
}
}, [isActive, className])
const onClick = useCallback(
(evt: MouseEvent) => {
if (realNavigation) return
createReferenceSubsectionNavigator(href, section.slug)(evt)
},
[href, section.slug, realNavigation]
)
if (!('title' in section)) return null
const isCompoundSection =
!skipChildren && 'items' in section && section.items && section.items.length > 0
return (
<>
{isCompoundSection ? (
<CompoundRefLink basePath={basePath} section={section} realNavigation={realNavigation} />
) : (
<Link
ref={ref}
// Scroll-hijack links never navigate, so disable prefetch. Real API
// pages omit the prop and keep Next.js's default prefetch behavior.
{...(!realNavigation ? { prefetch: false } : {})}
href={href}
className={getLinkStyles(isActive, className)}
onClick={onClick}
>
{section.title}
</Link>
)}
</>
)
}
function useCompoundRefLinkActive(basePath: string, section: AbbrevApiReferenceSection) {
const [open, _setOpen] = useState(false)
const pathname = useCurrentPathname()
const parentHref = deriveHref(basePath, section)
const isParentActive = pathname === parentHref
const childHrefs = useMemo(
() => new Set((section.items || []).map((item) => deriveHref(basePath, item))),
[basePath, section]
)
const isChildActive = childHrefs.has(pathname)
const isActive = isParentActive || isChildActive
const setOpen = (open: boolean) => {
// Disable closing if the section is active, to prevent the currently active
// link disappearing
if (open || !isActive) _setOpen(open)
}
if (isActive && !open) {
setOpen(true)
}
return { open, setOpen, isActive }
}
function CompoundRefLink({
basePath,
section,
realNavigation,
}: {
basePath: string
section: AbbrevApiReferenceSection
realNavigation?: boolean
}) {
const { open, setOpen, isActive } = useCompoundRefLinkActive(basePath, section)
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Trigger asChild disabled={isActive}>
<button
tabIndex={0}
className={cn(
'group',
'cursor-pointer',
'w-full',
'flex items-center justify-between gap-2'
)}
>
<span className={getLinkStyles(false)}>{section.title}</span>
<NavSectionCaret className="group-disabled:cursor-not-allowed group-disabled:opacity-10" />
</button>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<NavSectionContent>
<NavSectionList>
{(section.items || []).map((item, idx) => {
return (
<li key={`${section.id}-${idx}`}>
<RefLink
basePath={basePath}
section={item}
className="block py-1.25"
realNavigation={realNavigation}
/>
</li>
)
})}
</NavSectionList>
</NavSectionContent>
</Collapsible.Content>
</Collapsible.Root>
)
}