mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
274 lines
11 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|