Files
Compass/web/components/profile-form-nav.tsx

274 lines
11 KiB
TypeScript

import clsx from 'clsx'
import {ChevronDownIcon} from 'lucide-react'
import {useEffect, useRef, useState} from 'react'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {useT} from 'web/lib/locale'
type FormSection = {id: string; title: string}
/**
* Which container to index. Defaults to the profile form, which is what this was written for; the
* settings page passes its own, since "a column of `[data-form-section]` elements you can be
* scrolled through" is the whole contract and neither page needs to know about the other.
*/
const DEFAULT_ROOT = '[data-profile-form]'
/**
* The profile form's sections and which one is currently being read.
*
* The list is read from the DOM rather than declared here. The form's markup is the source of truth
* for what exists and in what order, and a hand-kept copy of that list would silently drift the first
* time a section moved — which it did.
*
* Shared by both presentations below so the rail and the mobile chip cannot disagree about where you
* are.
*/
function useFormSections(root: string) {
const [sections, setSections] = useState<FormSection[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
useEffect(() => {
// Bail when the list is unchanged. `setSections` with a fresh array on every call re-renders,
// which mutates the DOM, which fires the observer again — a loop that also thrashed the scroll
// listener below, since it re-subscribes whenever `sections` changes identity.
const read = (form: Element) =>
setSections((prev) => {
const next = Array.from(form.querySelectorAll<HTMLElement>('[data-form-section]')).map(
(node) => ({id: node.id, title: node.dataset.formSection ?? ''}),
)
const same =
prev.length === next.length &&
prev.every((p, i) => p.id === next[i].id && p.title === next[i].title)
return same ? prev : next
})
// Sections come and go with the form's own conditionals — Family only exists once a relationship
// is being sought — so the list is re-read when the form's subtree changes.
let sectionChanges: MutationObserver | undefined
const attach = () => {
const form = document.querySelector(root)
if (!form) return false
read(form)
sectionChanges = new MutationObserver(() => read(form))
sectionChanges.observe(form, {childList: true, subtree: true})
return true
}
// The root is not necessarily in the DOM when this hook mounts — the settings page renders its
// sections behind an async user fetch, so on that page the first query always misses. Giving up
// there meant the index simply never appeared. Wait for it instead.
let rootArrival: MutationObserver | undefined
if (!attach()) {
rootArrival = new MutationObserver(() => {
if (attach()) rootArrival?.disconnect()
})
rootArrival.observe(document.body, {childList: true, subtree: true})
}
return () => {
rootArrival?.disconnect()
sectionChanges?.disconnect()
}
}, [root])
useEffect(() => {
if (sections.length === 0) return
// Read straight from scroll position rather than with an IntersectionObserver. The observer
// version needed a band across the upper viewport to fire in, and a section heading is a few
// pixels tall — during any normal scroll the headings jumped clean over the band and nothing ever
// became active. Position always answers: the active section is simply the last one whose heading
// has passed the line.
const update = () => {
const line = window.innerHeight * 0.25
let current = sections[0].id
for (const {id} of sections) {
const el = document.getElementById(id)
if (!el || el.getBoundingClientRect().top > line) break
current = id
}
setActiveId(current)
}
update()
window.addEventListener('scroll', update, {passive: true})
window.addEventListener('resize', update)
return () => {
window.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
}
}, [sections])
const activeIndex = sections.findIndex((s) => s.id === activeId)
return {
sections,
activeId,
activeIndex: activeIndex === -1 ? 0 : activeIndex,
/**
* `offset` is what is pinned over the top of the page — the sticky section bar plus the native
* top inset. `scrollIntoView` lands the heading at viewport top, which on mobile is underneath
* the bar itself, so the section you picked is the one you cannot see.
*/
goTo: (id: string, offset = 0) => {
const el = document.getElementById(id)
if (!el) return
const top = el.getBoundingClientRect().top + window.scrollY - offset
window.scrollTo({top: Math.max(top, 0), behavior: 'smooth'})
},
}
}
/**
* Sticky section index for wide screens, in the gutter beside the form.
*
* The form is eighteen sections and several thousand pixels tall. Without an index the only way to
* reach Substances is to scroll past everything before it, and there is no way to tell how much is
* left — which is the difference between a form that feels long and one that feels endless.
*/
export function ProfileFormNav(props: {className?: string; root?: string}) {
const {className, root = DEFAULT_ROOT} = props
const {sections, activeId, goTo} = useFormSections(root)
if (sections.length === 0) return null
return (
<nav className={clsx('flex flex-col gap-0.5', className)} aria-label="Form sections">
{sections.map(({id, title}) => {
const isActive = id === activeId
return (
<button
key={id}
type="button"
onClick={() => goTo(id)}
aria-current={isActive ? 'true' : undefined}
className={clsx(
'border-l-2 py-1.5 pl-3 text-left text-sm transition-colors',
isActive
? 'border-primary-500 text-ink-1000'
: 'border-canvas-300 text-ink-500 hover:border-primary-400 hover:text-ink-800',
)}
>
{title}
</button>
)
})}
</nav>
)
}
/**
* The same index for narrow screens, as one sticky row: position in the form, the current section,
* and the full list one tap away.
*
* Not a reflow of the rail — stacked above the form, eighteen links would be a screenful of
* navigation to scroll past before reaching the first field, on the very form whose problem is
* length. The need is greater here than on desktop, since every field stacks and the page runs
* roughly three times longer; the answer just has to cost one row instead of a screen.
*
* It stays pinned for the whole form. It previously hid on scroll down to save 44px of a short
* viewport, but a bar that is only there when you scroll the wrong way reads as gone — you cannot
* see where you are without scrolling backwards first, which is exactly what the index exists to
* avoid.
*/
export function ProfileFormSectionBar(props: {className?: string; root?: string}) {
const {className, root = DEFAULT_ROOT} = props
const {sections, activeId, activeIndex, goTo} = useFormSections(root)
const [open, setOpen] = useState(false)
const barRef = useRef<HTMLDivElement>(null)
const pendingId = useRef<string | null>(null)
const t = useT()
// Scroll once the modal is gone rather than on the tap. The dialog scroll-locks the document while
// it is open, and in the Android WebView that lock is a fixed body whose scroll position is put
// back on release — so a scroll issued while the modal was still leaving got undone, and picking a
// section left the form exactly where it was. The delay clears Modal's 75ms leave transition,
// after which the lock is off.
useEffect(() => {
if (open || !pendingId.current) return
const id = pendingId.current
pendingId.current = null
const timer = setTimeout(() => {
const bar = barRef.current
// What the bar covers where it is pinned: its own height — the native top inset included,
// since that inset is its padding — plus whatever it is pinned below.
const offset = bar ? bar.offsetHeight + (parseFloat(getComputedStyle(bar).top) || 0) : 0
goTo(id, offset)
}, 150)
return () => clearTimeout(timer)
}, [open])
if (sections.length === 0) return null
const active = sections[activeIndex]
return (
<>
{/* `--tnh` is the native top inset (globals.css). The bar pins at `top-0` and carries the inset
as its own padding rather than pinning below it: pinned at `top-[var(--tnh)]` the band
between the viewport top and the bar was left uncovered, so form text scrolled out from
under the bar and reappeared in it, under the Android status bar. The variable is 0 in a
browser, so one rule covers both. */}
<div
ref={barRef}
className={clsx(
'bg-canvas-100/95 border-canvas-300 sticky top-0 z-20 -mx-6 border-b px-6 pt-[var(--tnh)] backdrop-blur',
className,
)}
>
<button
type="button"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
className="flex w-full items-center gap-3 py-3 text-left"
>
<span className="text-ink-400 font-dm-sans tabular-nums" style={{fontSize: '11px'}}>
{activeIndex + 1}/{sections.length}
</span>
<span className="text-ink-900 min-w-0 flex-1 truncate text-sm">{active?.title}</span>
<ChevronDownIcon className="text-ink-500 h-4 w-4 flex-none" />
</button>
</div>
<Modal open={open} setOpen={setOpen}>
<Col className={MODAL_CLASS}>
<div
className="text-ink-400 font-dm-sans mb-2 uppercase"
style={{fontSize: '10px', letterSpacing: '0.18em'}}
>
{t('profile.form.sections', 'Sections')}
</div>
{/* Eighteen rows are taller than the panel, so the list scrolls inside it rather than
spilling past the bottom of the screen. The padding is what the native bottom inset and
the app's own bottom nav (61px, `pb-page-base`) cover: without it the last section sits
under the Android navigation bar, where it can be neither read nor tapped. */}
<Col className="min-h-0 w-full flex-1 overflow-y-auto pb-[calc(61px+var(--bnh))] lg:pb-2">
{sections.map(({id, title}, i) => (
<button
key={id}
type="button"
onClick={() => {
pendingId.current = id
setOpen(false)
}}
aria-current={id === activeId ? 'true' : undefined}
className={clsx(
'border-canvas-200 flex w-full items-center gap-3 border-b py-3 text-left text-sm last:border-b-0',
id === activeId ? 'text-primary-700' : 'text-ink-700',
)}
>
<Row className="text-ink-400 w-6 flex-none tabular-nums" style={{fontSize: '11px'}}>
{i + 1}
</Row>
{title}
</button>
))}
</Col>
</Col>
</Modal>
</>
)
}