mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(help): in-app help center with per-screen guides and pictures
A help icon on every screen opens a panel with the screen's overview, its sub-screens, task guides with a framed picture per step, search across guides and the wiki, and a switch to any other screen's help. Guides exist for the dashboard, Vacay, Atlas, Collections, Journey (list, journal, studio), Settings (all tabs), Admin (all tabs) and the trip frame; the trip's own screens are in progress. Pictures are produced by Playwright (client/e2e/help), texts in all 23 languages.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { useHelpContext } from '../../hooks/useHelpContext'
|
||||
|
||||
/**
|
||||
* Renders nothing; registers the screen it sits in with the help center. A
|
||||
* page body may not call hooks itself (see pages/PATTERN.md), so this is the
|
||||
* form a `*Page.tsx` uses.
|
||||
*/
|
||||
export default function HelpAnchor({ id }: { id: string }): null {
|
||||
useHelpContext(id)
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* The one pill the help center is built from: section markers, counters,
|
||||
* breadcrumbs, step numbers. Tones map onto the appearance tokens so a user's
|
||||
* accent reaches every badge, and a badge can nest another (`count`) for the
|
||||
* "3 steps" pattern without a second component.
|
||||
*/
|
||||
export type HelpBadgeTone = 'neutral' | 'accent' | 'soft' | 'success' | 'warning' | 'outline'
|
||||
|
||||
const TONES: Record<HelpBadgeTone, string> = {
|
||||
neutral: 'bg-surface-tertiary text-content-secondary',
|
||||
accent: 'bg-accent text-accent-text',
|
||||
soft: 'bg-accent-subtle text-accent-on',
|
||||
success: 'bg-success-soft text-success',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
outline: 'border border-edge bg-surface-card text-content-secondary',
|
||||
}
|
||||
|
||||
/** The nested counter inverts against its parent so it reads as a badge in a badge. */
|
||||
const COUNT_TONES: Record<HelpBadgeTone, string> = {
|
||||
neutral: 'bg-surface-card text-content',
|
||||
accent: 'bg-accent-text text-accent',
|
||||
soft: 'bg-surface-card text-content',
|
||||
success: 'bg-surface-card text-success',
|
||||
warning: 'bg-surface-card text-warning',
|
||||
outline: 'bg-surface-tertiary text-content',
|
||||
}
|
||||
|
||||
export default function HelpBadge({
|
||||
tone = 'neutral',
|
||||
icon: Icon,
|
||||
count,
|
||||
uppercase = false,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
tone?: HelpBadgeTone
|
||||
icon?: LucideIcon
|
||||
/** A nested badge on the right, e.g. a number. */
|
||||
count?: React.ReactNode
|
||||
uppercase?: boolean
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 h-7 rounded-full pl-2.5 text-caption font-semibold leading-none whitespace-nowrap ${
|
||||
count === undefined ? 'pr-2.5' : 'pr-1'
|
||||
} ${uppercase ? 'uppercase tracking-[0.08em]' : ''} ${TONES[tone]} ${className}`}
|
||||
>
|
||||
{Icon && <Icon className="w-3.5 h-3.5 flex-shrink-0" />}
|
||||
{children}
|
||||
{count !== undefined && (
|
||||
<span className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-caption font-bold ${COUNT_TONES[tone]}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
|
||||
/**
|
||||
* The `?` in the navbar. Same 32px footprint as the theme toggle beside it;
|
||||
* lit while the panel is open so the two read as one control.
|
||||
*/
|
||||
export default function HelpButton({ className }: { className?: string } = {}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const open = useHelpStore(s => s.open)
|
||||
const toggleHelp = useHelpStore(s => s.toggleHelp)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleHelp}
|
||||
title={t('help.center.button')}
|
||||
aria-label={t('help.center.button')}
|
||||
aria-pressed={open}
|
||||
data-help-button
|
||||
className={
|
||||
className ??
|
||||
`hidden sm:flex relative w-8 h-8 p-2 rounded-lg items-center justify-center flex-shrink-0 transition-colors ${
|
||||
open ? 'bg-surface-selected text-content' : 'text-content-muted hover:bg-surface-hover'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { FileText, ArrowUpRight } from 'lucide-react'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
|
||||
/**
|
||||
* A row that leads into the full Help & Docs page. The panel closes on the
|
||||
* way: the docs page has its own navigation and the two side by side would
|
||||
* fight for the width.
|
||||
*/
|
||||
export default function HelpDocLink({ to, title, subtitle }: { to: string; title: string; subtitle?: string }): React.ReactElement {
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={closeHelp}
|
||||
className="group flex items-center gap-2.5 rounded-lg px-2 py-1.5 -mx-2 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-content-faint flex-shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-body text-content-secondary group-hover:text-content truncate transition-colors">{title}</span>
|
||||
{subtitle && <span className="block text-caption text-content-faint truncate">{subtitle}</span>}
|
||||
</span>
|
||||
<ArrowUpRight className="w-3.5 h-3.5 text-content-faint opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
import { getHelpGuide } from '../../help/registry'
|
||||
import HelpGuideView from './HelpGuideView'
|
||||
|
||||
const initial = useHelpStore.getState()
|
||||
|
||||
beforeEach(() => {
|
||||
useHelpStore.setState(initial, true)
|
||||
})
|
||||
|
||||
describe('HelpGuideView: links', () => {
|
||||
it('offers the in-app link of a guide that has one and closes the dialog on the way', () => {
|
||||
useHelpStore.setState({ open: true })
|
||||
render(<HelpGuideView guide={getHelpGuide('widgets')!} />)
|
||||
const link = screen.getByRole('link', { name: /Open Appearance settings/ })
|
||||
expect(link).toHaveAttribute('href', '/settings?tab=appearance')
|
||||
fireEvent.click(link)
|
||||
expect(useHelpStore.getState().open).toBe(false)
|
||||
})
|
||||
|
||||
it('lists every step and has no link for a quick guide', () => {
|
||||
render(<HelpGuideView guide={getHelpGuide('delete-trip')!} />)
|
||||
expect(screen.getAllByLabelText(/^Step \d$/)).toHaveLength(getHelpGuide('delete-trip')!.steps)
|
||||
expect(screen.queryByRole('link', { name: /Open Appearance settings/ })).toBeNull()
|
||||
expect(screen.getByRole('link', { name: /Open in Help & Docs/ })).toHaveAttribute('href', '/help/My-Trips-Dashboard#per-trip-actions')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { CheckCircle2, Lightbulb, ArrowUpRight, ChevronRight, Maximize2, ListOrdered, Camera, Link2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore, docTitle } from '../../store/helpStore'
|
||||
import { guideKey, guideStepKey, guideTipKey, helpMedia, docsRoute, getHelpGuide, getHelpContext, ctxKey, helpContextTrail } from '../../help/registry'
|
||||
import type { HelpGuide } from '../../help/types'
|
||||
import { helpIcon } from './helpIcons'
|
||||
import HelpBadge from './HelpBadge'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import HelpSizeChip from './HelpSizeChip'
|
||||
import { useScreenRoute } from './useScreenRoute'
|
||||
|
||||
/**
|
||||
* One guide, top to bottom: a header card that says where you are and how big
|
||||
* the task is, the steps as a numbered rail with a picture each, what the
|
||||
* reader ends up with, the things worth knowing, and where to go next.
|
||||
*/
|
||||
export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const openLightbox = useHelpStore(s => s.openLightbox)
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
const docIndex = useHelpStore(s => s.docIndex)
|
||||
const openGuide = useHelpStore(s => s.openGuide)
|
||||
const title = t(guideKey(guide.id, 'title'))
|
||||
const screen = getHelpContext(guide.context)
|
||||
const steps = Array.from({ length: guide.steps }, (_, i) => i + 1)
|
||||
const tips = Array.from({ length: guide.tips }, (_, i) => t(guideTipKey(guide.id, i + 1)))
|
||||
const related = (guide.related ?? []).map(getHelpGuide).filter((g): g is HelpGuide => g !== null)
|
||||
const Icon = helpIcon(guide.icon)
|
||||
const ScreenIcon = screen ? helpIcon(screen.icon) : Icon
|
||||
const screenRoute = useScreenRoute(screen?.route)
|
||||
|
||||
return (
|
||||
<div className="trek-help-view flex flex-col gap-6">
|
||||
{/* Where you are, what this is, how big it is */}
|
||||
<header className="rounded-2xl border border-edge bg-surface-card p-5 sm:p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{screen && helpContextTrail(screen).map((c, i, all) => (
|
||||
<React.Fragment key={c.id}>
|
||||
{i > 0 && <ChevronRight className="w-3.5 h-3.5 text-content-faint" />}
|
||||
<HelpBadge tone="outline" icon={i === all.length - 1 ? ScreenIcon : undefined}>{t(ctxKey(c.id, 'title'))}</HelpBadge>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<ChevronRight className="w-3.5 h-3.5 text-content-faint" />
|
||||
<HelpBadge tone="outline">{t('help.center.howTo')}</HelpBadge>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="w-14 h-14 rounded-2xl bg-accent text-accent-text flex items-center justify-center flex-shrink-0 shadow-card">
|
||||
<Icon className="w-6 h-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1.5">
|
||||
<h1 className="text-title font-bold text-content leading-tight">{title}</h1>
|
||||
<p className="text-body text-content-secondary leading-relaxed">{t(guideKey(guide.id, 'goal'))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-edge-secondary">
|
||||
<HelpSizeChip size={guide.size} />
|
||||
<HelpBadge tone="neutral" icon={ListOrdered} count={guide.steps}>{t('help.center.stepsLabel')}</HelpBadge>
|
||||
{guide.media.steps && <HelpBadge tone="neutral" icon={Camera} count={guide.steps}>{t('help.center.screenshot')}</HelpBadge>}
|
||||
{/* One way out of the header: into the screen. The docs link waits at the bottom. */}
|
||||
{screen && screenRoute && (
|
||||
<Link to={screenRoute} onClick={closeHelp} className="ml-auto">
|
||||
<HelpBadge tone="accent" icon={ArrowUpRight} className="hover:bg-accent-hover transition-colors">
|
||||
{t('help.center.goToScreen', { screen: t(ctxKey(screen.id, 'title')) })}
|
||||
</HelpBadge>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Steps, on a rail */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpBadge tone="accent" icon={ListOrdered} uppercase count={guide.steps}>{t('help.center.stepsLabel')}</HelpBadge>
|
||||
</div>
|
||||
<ol className="relative flex flex-col gap-3">
|
||||
{/* The rail: runs behind the step numbers from the first to the last. */}
|
||||
<span className="absolute left-[27px] top-6 bottom-6 w-px bg-edge" aria-hidden="true" />
|
||||
{steps.map(n => {
|
||||
const text = t(guideStepKey(guide.id, n))
|
||||
const src = helpMedia.step(guide.id, n)
|
||||
const alt = t('help.center.imageAlt', { n, title })
|
||||
return (
|
||||
<li
|
||||
key={n}
|
||||
className={`trek-help-step relative grid grid-cols-1 gap-4 rounded-2xl border border-edge bg-surface-card p-3 ${
|
||||
guide.media.steps ? 'md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 items-start">
|
||||
<span
|
||||
aria-label={t('help.center.step', { n })}
|
||||
className="trek-help-step-no relative z-[1] w-8 h-8 rounded-full bg-surface-card text-content text-caption font-bold flex items-center justify-center flex-shrink-0 border border-edge"
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-2">
|
||||
<HelpBadge tone="outline" uppercase className="self-start">
|
||||
{t('help.center.stepOf', { n, total: guide.steps })}
|
||||
</HelpBadge>
|
||||
<p className="rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content leading-relaxed">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
{guide.media.steps && (
|
||||
<figure className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox({ src, alt })}
|
||||
className="trek-help-shot group relative block w-full rounded-xl overflow-hidden border border-edge-secondary bg-surface-tertiary"
|
||||
aria-label={alt}
|
||||
>
|
||||
<img src={src} alt="" loading="lazy" decoding="async" className="block w-full aspect-[16/10] object-cover" />
|
||||
<span className="absolute bottom-2 left-2">
|
||||
<HelpBadge tone="accent" icon={Camera} count={n}>{t('help.center.screenshot')}</HelpBadge>
|
||||
</span>
|
||||
<span className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-inverse text-inverse-text flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-elevated">
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
</figure>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Result */}
|
||||
<section
|
||||
className={`rounded-2xl border border-edge bg-surface-card p-4 grid grid-cols-1 gap-4 items-start ${
|
||||
guide.media.result ? 'md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<HelpBadge tone="success" icon={CheckCircle2} uppercase className="self-start">{t('help.center.result')}</HelpBadge>
|
||||
<p className="rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content leading-relaxed">{t(guideKey(guide.id, 'result'))}</p>
|
||||
</div>
|
||||
{guide.media.result && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox({ src: helpMedia.result(guide.id), alt: t(guideKey(guide.id, 'result')) })}
|
||||
className="trek-help-shot group relative rounded-xl overflow-hidden border border-edge-secondary bg-surface-tertiary"
|
||||
>
|
||||
<img src={helpMedia.result(guide.id)} alt="" loading="lazy" decoding="async" className="block w-full aspect-video object-cover" />
|
||||
<span className="absolute bottom-2 left-2">
|
||||
<HelpBadge tone="success" icon={CheckCircle2}>{t('help.center.result')}</HelpBadge>
|
||||
</span>
|
||||
<span className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-inverse text-inverse-text flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-elevated">
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Tips */}
|
||||
{tips.length > 0 && (
|
||||
<section className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
|
||||
<HelpBadge tone="warning" icon={Lightbulb} uppercase count={tips.length} className="self-start">{t('help.center.tips')}</HelpBadge>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{tips.map((tip, i) => (
|
||||
<li key={i} className="flex gap-3 rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content-secondary leading-relaxed">
|
||||
<span className="w-6 h-6 rounded-full bg-warning-soft text-warning text-caption font-bold flex items-center justify-center flex-shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{tip}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Go there / docs / related */}
|
||||
{(guide.link || guide.docs || related.length > 0) && (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{(guide.link || guide.docs) && (
|
||||
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
|
||||
<HelpBadge tone="neutral" icon={Link2} uppercase className="self-start">{t('help.center.docsSection')}</HelpBadge>
|
||||
{guide.link && (
|
||||
<Link
|
||||
to={guide.link}
|
||||
onClick={closeHelp}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-accent text-accent-text text-body font-semibold px-4 py-2.5 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{t(guideKey(guide.id, 'link'))}
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
{guide.docs && (
|
||||
<HelpDocLink to={docsRoute(guide.docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, guide.docs.slug)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{related.length > 0 && (
|
||||
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
|
||||
<HelpBadge tone="neutral" icon={ChevronRight} uppercase count={related.length} className="self-start">{t('help.center.related')}</HelpBadge>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{related.map(g => {
|
||||
const RelIcon = helpIcon(g.icon)
|
||||
return (
|
||||
<li key={g.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openGuide(g.id)}
|
||||
className="group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<span className="w-7 h-7 rounded-md bg-surface-tertiary text-content-muted flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:text-accent-text transition-colors">
|
||||
<RelIcon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary group-hover:text-content truncate transition-colors">
|
||||
{t(guideKey(g.id, 'title'))}
|
||||
</span>
|
||||
{/* A guide on another screen says which, so the jump is no surprise. */}
|
||||
{g.context === guide.context ? (
|
||||
<HelpBadge tone="neutral" count={g.steps}>{t('help.center.stepsLabel')}</HelpBadge>
|
||||
) : (
|
||||
<HelpBadge tone="outline" icon={ArrowUpRight}>{t(ctxKey(g.context, 'title'))}</HelpBadge>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { ArrowRight, ArrowUpRight, BookOpen, LayoutList, ListOrdered, Compass, ChevronRight, ChevronDown, Camera, LayoutGrid } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore, docTitle } from '../../store/helpStore'
|
||||
import { ctxKey, ctxBulletKey, guideKey, guidesFor, helpMedia, docsRoute, helpContextTrail, childHelpContexts } from '../../help/registry'
|
||||
import type { HelpContext, HelpGuide } from '../../help/types'
|
||||
import { helpIcon } from './helpIcons'
|
||||
import HelpBadge from './HelpBadge'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import HelpSizeChip from './HelpSizeChip'
|
||||
import { useScreenRoute } from './useScreenRoute'
|
||||
|
||||
/**
|
||||
* The overview of a screen: what it is, what is on it, and a card per guide.
|
||||
* With no registered context (a screen that has no help yet) it says so and
|
||||
* leaves the search box as the way forward.
|
||||
*/
|
||||
export default function HelpHome({ context }: { context: HelpContext | null }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const docIndex = useHelpStore(s => s.docIndex)
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
// Folded until asked for: the guides below matter more, the tabs are one click away.
|
||||
const [subScreensOpen, setSubScreensOpen] = useState(false)
|
||||
const screenRoute = useScreenRoute(context?.route)
|
||||
|
||||
if (!context) {
|
||||
return (
|
||||
<div className="trek-help-view flex flex-col items-center text-center gap-2 py-24">
|
||||
<BookOpen className="w-8 h-8 text-content-faint" />
|
||||
<p className="text-subtitle font-semibold text-content">{t('help.center.noContext')}</p>
|
||||
<p className="text-body text-content-muted max-w-[320px]">{t('help.center.noContextHint')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const guides = guidesFor(context)
|
||||
const bullets = Array.from({ length: context.bullets }, (_, i) => t(ctxBulletKey(context.id, i + 1)))
|
||||
const children = childHelpContexts(context.id)
|
||||
const Icon = helpIcon(context.icon)
|
||||
|
||||
return (
|
||||
<div className="trek-help-view flex flex-col gap-6">
|
||||
{/* Which screen, what it is, what help there is */}
|
||||
<header className="rounded-2xl border border-edge bg-surface-card p-5 sm:p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<HelpBadge tone="outline" icon={Compass}>{t('help.center.screens')}</HelpBadge>
|
||||
{helpContextTrail(context).slice(0, -1).map(parent => (
|
||||
<React.Fragment key={parent.id}>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-content-faint" />
|
||||
<HelpBadge tone="outline">{t(ctxKey(parent.id, 'title'))}</HelpBadge>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<ChevronRight className="w-3.5 h-3.5 text-content-faint" />
|
||||
<HelpBadge tone="outline">{t('help.center.overview')}</HelpBadge>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="w-14 h-14 rounded-2xl bg-accent text-accent-text flex items-center justify-center flex-shrink-0 shadow-card">
|
||||
<Icon className="w-6 h-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1.5">
|
||||
<h1 className="text-title font-bold text-content leading-tight">{t(ctxKey(context.id, 'title'))}</h1>
|
||||
<p className="text-body text-content-secondary leading-relaxed">{t(ctxKey(context.id, 'summary'))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-edge-secondary">
|
||||
<HelpBadge tone="accent" icon={ListOrdered} count={guides.length}>{t('help.center.searchGuides')}</HelpBadge>
|
||||
{context.docs.length > 0 && <HelpBadge tone="neutral" icon={BookOpen} count={context.docs.length}>{t('help.center.searchDocs')}</HelpBadge>}
|
||||
{screenRoute && (
|
||||
<Link to={screenRoute} onClick={closeHelp} className="ml-auto">
|
||||
<HelpBadge tone="accent" icon={ArrowUpRight} className="hover:bg-accent-hover transition-colors">
|
||||
{t('help.center.goToScreen', { screen: t(ctxKey(context.id, 'title')) })}
|
||||
</HelpBadge>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Screens under this one (the tabs of Settings, the journal of Journey) */}
|
||||
{children.length > 0 && (
|
||||
<section className="rounded-2xl border border-edge bg-surface-card flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubScreensOpen(o => !o)}
|
||||
aria-expanded={subScreensOpen}
|
||||
className="flex items-center gap-3 p-4 text-left rounded-2xl hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<HelpBadge tone="neutral" icon={LayoutGrid} uppercase count={children.length}>{t('help.center.subScreensLabel')}</HelpBadge>
|
||||
<span className="min-w-0 flex-1 text-caption text-content-muted truncate">
|
||||
{children.map(child => t(ctxKey(child.id, 'title'))).join(' · ')}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-content-faint flex-shrink-0 transition-transform ${subScreensOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{subScreensOpen && (
|
||||
<ul className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2 px-4 pb-4">
|
||||
{children.map(child => (
|
||||
<li key={child.id}>
|
||||
<SubScreenCard screen={child} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* The screen itself */}
|
||||
{context.hero && (
|
||||
<figure className="relative rounded-2xl overflow-hidden border border-edge-secondary bg-surface-tertiary shadow-card">
|
||||
<img
|
||||
src={helpMedia.hero(context.id)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="block w-full aspect-video object-cover"
|
||||
/>
|
||||
<span className="absolute bottom-3 left-3">
|
||||
<HelpBadge tone="accent" icon={Camera}>{t(ctxKey(context.id, 'title'))}</HelpBadge>
|
||||
</span>
|
||||
</figure>
|
||||
)}
|
||||
|
||||
{/* What is on it */}
|
||||
{bullets.length > 0 && (
|
||||
<section className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
|
||||
<HelpBadge tone="neutral" icon={LayoutList} uppercase count={bullets.length} className="self-start">{t('help.center.onThisScreen')}</HelpBadge>
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{bullets.map((text, i) => (
|
||||
<li key={i} className="flex gap-3 rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content-secondary leading-relaxed">
|
||||
<span className="w-6 h-6 rounded-full bg-accent text-accent-text text-caption font-bold flex items-center justify-center flex-shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Guides */}
|
||||
{guides.length > 0 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<HelpBadge tone="accent" icon={ListOrdered} uppercase count={guides.length} className="self-start">{t('help.center.howTo')}</HelpBadge>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{guides.map(g => (
|
||||
<li key={g.id}>
|
||||
<GuideCard guide={g} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Docs */}
|
||||
{context.docs.length > 0 && (
|
||||
<section className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-2">
|
||||
<HelpBadge tone="neutral" icon={BookOpen} uppercase count={context.docs.length} className="self-start">{t('help.center.docsSection')}</HelpBadge>
|
||||
<ul className="flex flex-col">
|
||||
{context.docs.map(link => (
|
||||
<li key={`${link.slug}#${link.anchor ?? ''}`}>
|
||||
<HelpDocLink to={docsRoute(link)} title={docTitle(docIndex, link.slug)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One sub-screen: opens its overview in place; the one the reader is on says so. */
|
||||
function SubScreenCard({ screen }: { screen: HelpContext }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const browse = useHelpStore(s => s.browse)
|
||||
const contextId = useHelpStore(s => s.contextId)
|
||||
const current = screen.id === contextId
|
||||
const Icon = helpIcon(screen.icon)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => browse(current ? null : screen.id)}
|
||||
className="group w-full h-full flex items-center gap-3 rounded-xl border border-edge bg-surface-secondary px-3 py-2.5 text-left hover:border-content-faint hover:bg-surface-card transition-colors"
|
||||
>
|
||||
<span className="w-9 h-9 rounded-lg bg-surface-card text-content-secondary flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:text-accent-text transition-colors">
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||
<span className="text-body font-semibold text-content truncate">{t(ctxKey(screen.id, 'title'))}</span>
|
||||
<span className="text-caption text-content-muted">{t('help.center.guidesCount', { count: screen.guides.length })}</span>
|
||||
</span>
|
||||
{current ? <ThisScreenChip /> : <ArrowRight className="w-4 h-4 text-content-faint transition-transform group-hover:translate-x-0.5 group-hover:text-content" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The marker for the screen the reader is actually on. */
|
||||
export function ThisScreenChip(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-accent text-accent-text px-2 py-1 text-caption font-semibold leading-none whitespace-nowrap flex-shrink-0">
|
||||
{t('help.center.thisScreen')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionLabel({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <h3 className="text-caption font-semibold uppercase tracking-[0.1em] text-content-faint">{children}</h3>
|
||||
}
|
||||
|
||||
export function GuideCard({ guide }: { guide: HelpGuide }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const openGuide = useHelpStore(s => s.openGuide)
|
||||
const Icon = helpIcon(guide.icon)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openGuide(guide.id)}
|
||||
className="group w-full h-full flex flex-col gap-3 rounded-2xl border border-edge bg-surface-card p-4 text-left hover:border-content-faint hover:shadow-elevated transition-[border-color,box-shadow,transform] duration-200 ease-[var(--ease-out-quint)] hover:-translate-y-px"
|
||||
>
|
||||
<span className="flex items-center justify-between">
|
||||
<span className="w-10 h-10 rounded-xl bg-surface-tertiary text-content-secondary flex items-center justify-center group-hover:bg-accent group-hover:text-accent-text transition-colors">
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HelpSizeChip size={guide.size} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex flex-col gap-1 flex-1">
|
||||
<span className="text-body font-semibold text-content leading-snug">{t(guideKey(guide.id, 'title'))}</span>
|
||||
<span className="text-caption text-content-muted leading-relaxed">{t(guideKey(guide.id, 'goal'))}</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between pt-3 border-t border-edge-secondary">
|
||||
<HelpBadge tone="neutral" count={guide.steps}>{t('help.center.stepsLabel')}</HelpBadge>
|
||||
<ArrowRight className="w-4 h-4 text-content-faint transition-transform group-hover:translate-x-0.5 group-hover:text-content" />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
|
||||
/** A step picture enlarged over the help dialog; click anywhere or Escape to close. */
|
||||
export default function HelpLightbox(): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const lightbox = useHelpStore(s => s.lightbox)
|
||||
const close = useHelpStore(s => s.openLightbox)
|
||||
if (!lightbox) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="trek-help-lightbox fixed inset-0 z-[var(--z-overlay)] flex items-center justify-center p-4 sm:p-8 bg-[var(--overlay)] backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={lightbox.alt}
|
||||
onClick={() => close(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close(null)}
|
||||
aria-label={t('help.center.close')}
|
||||
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-inverse text-inverse-text flex items-center justify-center shadow-elevated hover:scale-105 transition-transform"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<figure className="trek-help-lightbox-card max-w-[min(1400px,94vw)] max-h-[90vh] flex flex-col gap-3" onClick={e => e.stopPropagation()}>
|
||||
<img src={lightbox.src} alt={lightbox.alt} className="max-h-[84vh] w-auto object-contain rounded-xl border border-edge shadow-modal bg-surface-card" />
|
||||
{lightbox.alt && <figcaption className="text-caption text-center text-inverse-text opacity-80">{lightbox.alt}</figcaption>}
|
||||
</figure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Search, LifeBuoy, ArrowUpRight, ChevronsUpDown, ChevronDown, ChevronRight, Check, MapPin, CornerDownRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore, docTitle } from '../../store/helpStore'
|
||||
import { ctxKey, guideKey, guidesFor, docsRoute, topLevelHelpContexts, childHelpContexts, helpContextTrail } from '../../help/registry'
|
||||
import type { HelpContext, HelpGuide } from '../../help/types'
|
||||
import { helpIcon } from './helpIcons'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import { ThisScreenChip } from './HelpHome'
|
||||
|
||||
const GITHUB_DISCUSSIONS = 'https://github.com/liketrek/TREK/discussions/new?category=ideas'
|
||||
const DISCORD = 'https://discord.gg/NhZBDSd4qW'
|
||||
|
||||
/**
|
||||
* Left pane of the help dialog: search, the current screen with its guides,
|
||||
* the docs that go with it, and the ways to reach a human.
|
||||
*/
|
||||
export default function HelpNav({ context, currentId, activeGuideId }: {
|
||||
/** The screen whose help is shown. */
|
||||
context: HelpContext | null
|
||||
/** The screen the reader is actually on, for the "this screen" marker. */
|
||||
currentId: string | null
|
||||
activeGuideId: string | null
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const query = useHelpStore(s => s.query)
|
||||
const setQuery = useHelpStore(s => s.setQuery)
|
||||
const closeGuide = useHelpStore(s => s.closeGuide)
|
||||
const docIndex = useHelpStore(s => s.docIndex)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus({ preventScroll: true })
|
||||
}, [])
|
||||
|
||||
const guides = context ? guidesFor(context) : []
|
||||
const overviewActive = !activeGuideId && !query.trim()
|
||||
// The tabs of the screen on show, or of its parent when the reader is on one of them.
|
||||
const family = context ? childHelpContexts(context.parent ?? context.id) : []
|
||||
// Folded by default: the guides matter more, the tabs are one click away.
|
||||
const [familyOpen, setFamilyOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col min-h-0 border-b md:border-b-0 md:border-r border-edge bg-surface-secondary" aria-label={t('help.center.title')}>
|
||||
<div className="px-5 pt-5 pb-3 flex items-center gap-2.5">
|
||||
<span className="w-8 h-8 rounded-lg flex items-center justify-center bg-accent text-accent-text">
|
||||
<LifeBuoy className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="text-subtitle font-bold text-content leading-none">{t('help.center.title')}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-4">
|
||||
<label className="relative block">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-content-faint pointer-events-none" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('help.center.searchPlaceholder')}
|
||||
aria-label={t('help.center.searchPlaceholder')}
|
||||
className="w-full rounded-xl bg-surface-card text-content text-body pl-9 pr-3 py-2.5 outline-none border border-edge focus:border-content-faint transition-colors placeholder:text-content-faint"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="trek-help-scroll flex-1 min-h-0 overflow-y-auto px-3 pb-3 flex flex-col gap-5">
|
||||
<section className="flex flex-col gap-1">
|
||||
<NavLabel>{t('help.center.screens')}</NavLabel>
|
||||
<ScreenSwitcher shown={context} currentId={currentId} />
|
||||
</section>
|
||||
{context ? (
|
||||
<>
|
||||
{family.length > 0 && (
|
||||
<section className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFamilyOpen(o => !o)}
|
||||
aria-expanded={familyOpen}
|
||||
className="flex items-center gap-1.5 px-2 pb-1 text-left text-caption font-semibold uppercase tracking-[0.1em] text-content-faint hover:text-content transition-colors"
|
||||
>
|
||||
<span className="flex-1">{t('help.center.subScreensLabel')}</span>
|
||||
<span className="text-caption font-bold tabular-nums normal-case tracking-normal">{family.length}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${familyOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{familyOpen && (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{family.map(screen => (
|
||||
<li key={screen.id}>
|
||||
<NavScreen screen={screen} shown={screen.id === context.id} current={screen.id === currentId} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-1">
|
||||
<NavLabel>{t('help.center.overview')}</NavLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { closeGuide(); setQuery('') }}
|
||||
aria-current={overviewActive ? 'page' : undefined}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors ${
|
||||
overviewActive ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<span className="text-body font-semibold truncate">{t(ctxKey(context.id, 'title'))}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{guides.length > 0 && (
|
||||
<section className="flex flex-col gap-1">
|
||||
<NavLabel>{t('help.center.howTo')}</NavLabel>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{guides.map(g => (
|
||||
<li key={g.id}>
|
||||
<NavGuide guide={g} active={g.id === activeGuideId} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{context.docs.length > 0 && (
|
||||
<section className="flex flex-col gap-1 px-2">
|
||||
<NavLabel>{t('help.center.docsSection')}</NavLabel>
|
||||
<ul className="flex flex-col">
|
||||
{context.docs.map(link => (
|
||||
<li key={`${link.slug}#${link.anchor ?? ''}`}>
|
||||
<HelpDocLink to={docsRoute(link)} title={docTitle(docIndex, link.slug)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="px-2 text-body text-content-muted">{t('help.center.noContext')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="px-4 py-4 border-t border-edge flex flex-col gap-2">
|
||||
<span className="px-1 text-caption font-semibold uppercase tracking-[0.1em] text-content-faint">{t('help.center.feedback')}</span>
|
||||
<a
|
||||
href={GITHUB_DISCUSSIONS}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-xl bg-accent text-accent-text px-3.5 py-2.5 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<GitHubMark className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="flex-1 text-body font-semibold">{t('help.center.feedbackLink')}</span>
|
||||
<ArrowUpRight className="w-4 h-4 opacity-70 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
{/* Discord blurple is a brand colour, deliberately not a theme token. */}
|
||||
<a
|
||||
href={DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-xl bg-[#5865F2] text-white px-3.5 py-2.5 hover:bg-[#4752C4] transition-colors" // theme-lint-disable
|
||||
>
|
||||
<DiscordMark className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="flex-1 text-body font-semibold">{t('help.center.discord')}</span>
|
||||
<ArrowUpRight className="w-4 h-4 opacity-70 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
</footer>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick which screen's help to read without leaving the one you are on. The
|
||||
* current screen is marked; choosing it again returns the dialog to following
|
||||
* the screen the reader is on.
|
||||
*/
|
||||
/**
|
||||
* Which screen the panel shows, and the list to pick another. Screens with
|
||||
* sub-screens (Journey and its journal, Settings and its tabs) fold: one group
|
||||
* is open at a time, and the group of the screen on show starts open.
|
||||
*/
|
||||
function ScreenSwitcher({ shown, currentId }: { shown: HelpContext | null; currentId: string | null }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const browse = useHelpStore(s => s.browse)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const screens = topLevelHelpContexts()
|
||||
const ShownIcon = shown ? helpIcon(shown.icon) : MapPin
|
||||
const trail = shown ? helpContextTrail(shown) : []
|
||||
|
||||
// Click outside or Escape closes the list; Escape must not bubble into the dialog's own close.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false) }
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) } }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const choose = (id: string) => {
|
||||
browse(id === currentId ? null : id)
|
||||
setOpen(false)
|
||||
}
|
||||
const toggleList = () => {
|
||||
setOpen(o => {
|
||||
if (!o) setOpenGroup(trail.length > 1 ? trail[0].id : null)
|
||||
return !o
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleList}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2.5 rounded-xl border border-edge bg-surface-card px-3 py-2.5 text-left hover:border-content-faint transition-colors"
|
||||
>
|
||||
<span className="w-7 h-7 rounded-md bg-accent text-accent-text flex items-center justify-center flex-shrink-0">
|
||||
<ShownIcon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-body font-semibold text-content truncate">
|
||||
{shown ? trail.map(c => t(ctxKey(c.id, 'title'))).join(' › ') : t('help.center.noContext')}
|
||||
</span>
|
||||
</span>
|
||||
{shown && shown.id === currentId && <ThisScreenChip />}
|
||||
<ChevronsUpDown className="w-4 h-4 text-content-faint flex-shrink-0" />
|
||||
</button>
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label={t('help.center.screens')}
|
||||
className="trek-help-menu absolute left-2 right-2 top-full mt-1.5 z-10 flex flex-col gap-0.5 rounded-xl border border-edge bg-surface-card p-1.5 shadow-elevated"
|
||||
>
|
||||
{screens.map(screen => {
|
||||
const children = childHelpContexts(screen.id)
|
||||
const expanded = openGroup === screen.id
|
||||
return (
|
||||
<li key={screen.id} className="flex flex-col gap-0.5">
|
||||
<ScreenRow
|
||||
screen={screen}
|
||||
selected={screen.id === shown?.id}
|
||||
current={screen.id === currentId}
|
||||
onPick={() => choose(screen.id)}
|
||||
group={children.length > 0 ? { expanded, count: children.length, toggle: () => setOpenGroup(expanded ? null : screen.id) } : undefined}
|
||||
/>
|
||||
{children.length > 0 && expanded && (
|
||||
<ul role="group" className="flex flex-col gap-0.5 pl-3">
|
||||
{children.map(child => (
|
||||
<li key={child.id}>
|
||||
<ScreenRow
|
||||
screen={child}
|
||||
selected={child.id === shown?.id}
|
||||
current={child.id === currentId}
|
||||
onPick={() => choose(child.id)}
|
||||
nested
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One screen in the switcher list; a group row carries the fold toggle on its right. */
|
||||
function ScreenRow({
|
||||
screen, selected, current, onPick, group, nested = false,
|
||||
}: {
|
||||
screen: HelpContext
|
||||
selected: boolean
|
||||
current: boolean
|
||||
onPick: () => void
|
||||
group?: { expanded: boolean; count: number; toggle: () => void }
|
||||
nested?: boolean
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const Icon = helpIcon(screen.icon)
|
||||
const Fold = group?.expanded ? ChevronDown : ChevronRight
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`flex items-center rounded-lg transition-colors ${
|
||||
selected ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<button type="button" onClick={onPick} className="min-w-0 flex-1 flex items-center gap-2.5 px-2 py-2 text-left">
|
||||
{nested ? (
|
||||
<CornerDownRight className="w-3.5 h-3.5 flex-shrink-0 text-content-faint" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4 flex-shrink-0 text-content-muted" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 text-body truncate">{t(ctxKey(screen.id, 'title'))}</span>
|
||||
{current && <ThisScreenChip />}
|
||||
{selected && <Check className="w-4 h-4 flex-shrink-0" />}
|
||||
</button>
|
||||
{group && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); group.toggle() }}
|
||||
aria-expanded={group.expanded}
|
||||
aria-label={t('help.center.subScreens', { count: group.count })}
|
||||
className="flex items-center gap-1 rounded-md mr-1.5 px-1.5 py-1 text-caption font-semibold text-content-faint hover:bg-surface-tertiary hover:text-content transition-colors"
|
||||
>
|
||||
{group.count}
|
||||
<Fold className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A sibling or child screen in the nav; picking it browses that screen's overview. */
|
||||
function NavScreen({ screen, shown, current }: { screen: HelpContext; shown: boolean; current: boolean }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const browse = useHelpStore(s => s.browse)
|
||||
const Icon = helpIcon(screen.icon)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => browse(current ? null : screen.id)}
|
||||
aria-current={shown ? 'page' : undefined}
|
||||
className={`group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors ${
|
||||
shown ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-6 h-6 rounded-md flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
shown ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted group-hover:bg-accent group-hover:text-accent-text'
|
||||
}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-body truncate">{t(ctxKey(screen.id, 'title'))}</span>
|
||||
{current && <MapPin className="w-3.5 h-3.5 text-content-faint flex-shrink-0" aria-label={t('help.center.thisScreen')} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NavLabel({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <h3 className="px-2 pb-1 text-caption font-semibold uppercase tracking-[0.1em] text-content-faint">{children}</h3>
|
||||
}
|
||||
|
||||
function NavGuide({ guide, active }: { guide: HelpGuide; active: boolean }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const openGuide = useHelpStore(s => s.openGuide)
|
||||
const Icon = helpIcon(guide.icon)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openGuide(guide.id)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors ${
|
||||
active ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-muted group-hover:text-content'
|
||||
}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-body truncate">{t(guideKey(guide.id, 'title'))}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** GitHub's mark, from their brand kit; lucide dropped its brand icons. */
|
||||
function GitHubMark({ className }: { className?: string }): React.ReactElement {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56v-2.17c-3.2.7-3.87-1.37-3.87-1.37-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.19 1.76 1.19 1.03 1.76 2.69 1.25 3.35.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.04 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.78 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.58.23 2.75.11 3.04.74.81 1.19 1.83 1.19 3.09 0 4.41-2.69 5.38-5.25 5.67.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Discord's mark, the same path the navbar's profile menu uses. */
|
||||
function DiscordMark({ className }: { className?: string }): React.ReactElement {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { render, screen, fireEvent, waitFor, within, act } from '../../../tests/helpers/render'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
import HelpPanel from './HelpPanel'
|
||||
import HelpButton from './HelpButton'
|
||||
import HelpAnchor from './HelpAnchor'
|
||||
|
||||
/**
|
||||
* The dialog end to end against the real registry and the English catalogue:
|
||||
* open it from the navbar button, read the overview, open a guide, search,
|
||||
* close it again. The wiki index and search are served by msw.
|
||||
*/
|
||||
|
||||
const initial = useHelpStore.getState()
|
||||
|
||||
beforeEach(() => {
|
||||
useHelpStore.setState(initial, true)
|
||||
server.use(
|
||||
http.get('/api/help/index', () =>
|
||||
HttpResponse.json({ sections: [{ title: 'Planning', pages: [{ slug: 'Creating-a-Trip', title: 'Creating a Trip' }, { slug: 'My-Trips-Dashboard', title: 'My Trips Dashboard' }] }] }),
|
||||
),
|
||||
http.get('/api/help/search', ({ request }) => {
|
||||
const q = new URL(request.url).searchParams.get('q') ?? ''
|
||||
if (q === 'zzqx') return HttpResponse.json({ hits: [] })
|
||||
return HttpResponse.json({ hits: [{ slug: 'Calendar-Feeds', title: 'Calendar Feeds', section: 'Planning', anchor: 'turning-a-feed-on', heading: 'Turning a feed on', snippet: 'Click Enable calendar subscription…', score: 9 }] })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function mount(): void {
|
||||
render(
|
||||
<>
|
||||
<HelpButton />
|
||||
<HelpAnchor id="dashboard" />
|
||||
<HelpPanel />
|
||||
</>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('HelpPanel', () => {
|
||||
it('stays out of the DOM until opened, then shows the screen overview', () => {
|
||||
mount()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Help' })
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeInTheDocument()
|
||||
// Guides appear in the navigation and as cards.
|
||||
expect(screen.getAllByRole('button', { name: /Create a trip/ }).length).toBeGreaterThanOrEqual(2)
|
||||
expect(screen.getByRole('button', { name: 'Help for this screen' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('opens a guide with its steps, result and tips, and goes back to the overview', async () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Create a trip/ })[0])
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/Click Create New Trip\./)).toBeInTheDocument()
|
||||
expect(screen.getByText(/takes over the boarding pass/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Dates can be changed later/)).toBeInTheDocument()
|
||||
// A picture for every step.
|
||||
expect(screen.getAllByRole('button', { name: /Step \d of/ })).toHaveLength(5)
|
||||
// Doc link resolves its title from the index once that has loaded.
|
||||
await waitFor(() => expect(screen.getAllByText('Creating a Trip').length).toBeGreaterThan(0))
|
||||
// Related guide opens in place (the last match is the one under "Related"; the nav lists it too).
|
||||
const related = screen.getAllByRole('button', { name: 'Set a cover photo' })
|
||||
fireEvent.click(related[related.length - 1])
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Set a cover photo' })).toBeInTheDocument()
|
||||
// The overview entry in the nav returns home.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }))
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enlarges a step picture in the lightbox and closes it on click', () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Edit a trip/ })[0])
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Step 1 of/ })[0])
|
||||
const box = screen.getByRole('dialog', { name: /Step 1 of/ })
|
||||
expect(box.querySelector('img')).toHaveAttribute('src', '/help-media/edit-trip/step-1.webp')
|
||||
fireEvent.click(box)
|
||||
expect(screen.queryByRole('dialog', { name: /Step 1 of/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('searches guides locally and the docs on the server', async () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
const box = screen.getByRole('searchbox')
|
||||
fireEvent.change(box, { target: { value: 'calendar' } })
|
||||
// Local guide match right away…
|
||||
expect(screen.getAllByRole('button', { name: /Subscribe to all trips in your calendar/ }).length).toBeGreaterThan(0)
|
||||
// …and the doc hit once the server answers.
|
||||
await waitFor(() => expect(screen.getByText('Calendar Feeds · Turning a feed on')).toBeInTheDocument())
|
||||
expect(screen.getByRole('link', { name: /Calendar Feeds · Turning a feed on/ })).toHaveAttribute('href', '/help/Calendar-Feeds#turning-a-feed-on')
|
||||
})
|
||||
|
||||
it('finds a guide by its words in any order, stop words and accents aside, title first', () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
const box = screen.getByRole('searchbox')
|
||||
fireEvent.change(box, { target: { value: 'how do I create trip' } })
|
||||
// The result cards sit in the main pane's "Guides" list, the nav lists guides too.
|
||||
const first = () => screen.getByRole('main').querySelector('ul li button')!
|
||||
expect(first()).toHaveAccessibleName(/Create a trip/)
|
||||
fireEvent.change(box, { target: { value: 'Trip Créate' } })
|
||||
expect(first()).toHaveAccessibleName(/Create a trip/)
|
||||
})
|
||||
|
||||
it('searches from inside a guide too, and returns to the guide once the box is cleared', () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Create a trip/ })[0])
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument()
|
||||
const box = screen.getByRole('searchbox')
|
||||
fireEvent.change(box, { target: { value: 'calendar' } })
|
||||
expect(screen.queryByRole('heading', { level: 1, name: 'Create a trip' })).toBeNull()
|
||||
expect(screen.getAllByRole('button', { name: /Subscribe to all trips in your calendar/ }).length).toBeGreaterThan(0)
|
||||
fireEvent.change(box, { target: { value: '' } })
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists the sub-screens of a screen folded on its overview and in the nav, and offers the way into the screen', () => {
|
||||
render(
|
||||
<>
|
||||
<HelpButton />
|
||||
<HelpAnchor id="settings" />
|
||||
<HelpPanel />
|
||||
</>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
// Folded: the names are listed in one line, the cards are not there yet.
|
||||
const nav = screen.getByRole('navigation', { name: 'Help' })
|
||||
const fold = screen.getAllByRole('button', { name: /Sub-screens/ }).find(b => !nav.contains(b))!
|
||||
expect(fold).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByRole('button', { name: /Appearance.*guides/ })).toBeNull()
|
||||
fireEvent.click(fold)
|
||||
expect(fold).toHaveAttribute('aria-expanded', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Appearance.*guides/ }))
|
||||
expect(useHelpStore.getState().browseId).toBe('settings-appearance')
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Appearance' })).toBeInTheDocument()
|
||||
// On a tab, the nav lists its siblings with the shown one marked, and the header leads into the tab.
|
||||
// The nav folds them too; open it, then the siblings are listed with the shown one marked.
|
||||
// (The overview entry is also named "Appearance"; the sibling rows are the ones under "Sub-screens".)
|
||||
fireEvent.click(within(nav).getByRole('button', { name: /Sub-screens/ }))
|
||||
const rows = within(nav).getAllByRole('button', { name: 'Appearance' })
|
||||
expect(rows.some(b => b.getAttribute('aria-current') === 'page')).toBe(true)
|
||||
expect(within(nav).getByRole('button', { name: 'Map' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Go to Appearance' })).toHaveAttribute('href', '/settings?tab=appearance')
|
||||
})
|
||||
|
||||
it('says so when nothing matches', async () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'zzqx' } })
|
||||
await waitFor(() => expect(screen.getByText('Nothing found for “zzqx”.')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('closes on the close button, on the backdrop and on Escape', async () => {
|
||||
mount()
|
||||
const open = () => fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close help' }))
|
||||
expect(useHelpStore.getState().open).toBe(false)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('dialog').parentElement!)
|
||||
expect(useHelpStore.getState().open).toBe(false)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
|
||||
open()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(useHelpStore.getState().open).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves Escape to a modal the reader opened while following a guide', () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
const modal = document.createElement('div')
|
||||
modal.className = 'trek-modal-backdrop'
|
||||
document.body.appendChild(modal)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(useHelpStore.getState().open).toBe(true)
|
||||
modal.remove()
|
||||
})
|
||||
|
||||
it('shows the empty state on a screen without help', () => {
|
||||
render(
|
||||
<>
|
||||
<HelpButton />
|
||||
<HelpPanel />
|
||||
</>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
expect(screen.getAllByText('No guide for this screen yet.').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('clears the screen when its anchor unmounts', () => {
|
||||
const { unmount } = render(<HelpAnchor id="dashboard" />)
|
||||
expect(useHelpStore.getState().contextId).toBe('dashboard')
|
||||
act(() => unmount())
|
||||
expect(useHelpStore.getState().contextId).toBeNull()
|
||||
})
|
||||
|
||||
it('folds sub-screens under their screen in the switcher, one group open at a time', () => {
|
||||
render(
|
||||
<>
|
||||
<HelpButton />
|
||||
<HelpAnchor id="journey-detail" />
|
||||
<HelpPanel />
|
||||
</>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
// The trigger names the trail, and the list opens with the journal's group unfolded.
|
||||
const trigger = screen.getByRole('button', { name: /Journey › Journal/ })
|
||||
fireEvent.click(trigger)
|
||||
const list = screen.getByRole('listbox', { name: 'Screens' })
|
||||
expect(list).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: /Journal/ })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('option', { name: /Studio/ })).toBeInTheDocument()
|
||||
// Folding the group hides the sub-screens; the top-level screens stay.
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 sub-screens' }))
|
||||
expect(screen.queryByRole('option', { name: /Studio/ })).toBeNull()
|
||||
expect(screen.getByRole('option', { name: /Atlas/ })).toBeInTheDocument()
|
||||
// Picking a sub-screen browses to it; the breadcrumb on the overview shows the trail.
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 sub-screens' }))
|
||||
fireEvent.click(screen.getByRole('option', { name: /Studio/ }).querySelector('button')!)
|
||||
expect(useHelpStore.getState().browseId).toBe('journey-studio')
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Studio' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not clear a screen another anchor has already taken over', () => {
|
||||
const { unmount } = render(<HelpAnchor id="dashboard" />)
|
||||
act(() => useHelpStore.getState().setContext('settings'))
|
||||
act(() => unmount())
|
||||
expect(useHelpStore.getState().contextId).toBe('settings')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
import { useHelpShortcut } from '../../hooks/useHelpShortcut'
|
||||
import { getHelpContext, getHelpGuide } from '../../help/registry'
|
||||
import HelpNav from './HelpNav'
|
||||
import HelpHome from './HelpHome'
|
||||
import HelpGuideView from './HelpGuideView'
|
||||
import HelpSearchResults from './HelpSearchResults'
|
||||
import HelpLightbox from './HelpLightbox'
|
||||
import './help.css'
|
||||
|
||||
/** How long the closing animation runs before the dialog leaves the DOM. */
|
||||
const CLOSE_MS = 160
|
||||
|
||||
/**
|
||||
* The help center: one large dialog over the app. Navigation and search on the
|
||||
* left, the screen overview or the open guide on the right. Escape is handled
|
||||
* by useHelpShortcut; the `?` in the navbar is the only way in.
|
||||
*/
|
||||
export default function HelpPanel(): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useHelpStore(s => s.open)
|
||||
const contextId = useHelpStore(s => s.contextId)
|
||||
const browseId = useHelpStore(s => s.browseId)
|
||||
const guideId = useHelpStore(s => s.guideId)
|
||||
const query = useHelpStore(s => s.query)
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
const loadDocIndex = useHelpStore(s => s.loadDocIndex)
|
||||
useHelpShortcut()
|
||||
|
||||
// Keep the dialog mounted for the length of the exit animation.
|
||||
const [state, setState] = useState<'closed' | 'open' | 'closing'>(open ? 'open' : 'closed')
|
||||
const wasOpen = useRef(open)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
wasOpen.current = true
|
||||
setState('open')
|
||||
return
|
||||
}
|
||||
if (!wasOpen.current) return
|
||||
wasOpen.current = false
|
||||
setState('closing')
|
||||
const id = window.setTimeout(() => setState('closed'), CLOSE_MS)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [open])
|
||||
|
||||
// Doc titles for the links; one fetch per session.
|
||||
useEffect(() => {
|
||||
if (open) void loadDocIndex()
|
||||
}, [open, loadDocIndex])
|
||||
|
||||
// The page behind must not scroll while the dialog is up.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Focus back to the `?` when the dialog closes.
|
||||
useEffect(() => {
|
||||
if (open) return
|
||||
const btn = document.querySelector<HTMLElement>('[data-help-button]')
|
||||
if (btn && document.activeElement === document.body) btn.focus({ preventScroll: true })
|
||||
}, [open])
|
||||
|
||||
// Every view change starts at the top of the content pane.
|
||||
const shownId = browseId ?? contextId
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo?.({ top: 0 })
|
||||
}, [guideId, query, shownId])
|
||||
|
||||
if (state === 'closed') return null
|
||||
|
||||
const context = getHelpContext(shownId)
|
||||
const guide = getHelpGuide(guideId)
|
||||
// Typing wins over whatever was open: a search from inside a guide shows
|
||||
// results, and clearing the box returns to that guide.
|
||||
const searching = query.trim().length > 0
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="trek-help-backdrop fixed inset-0 z-[var(--z-overlay)] flex items-center justify-center p-3 sm:p-5 bg-[var(--overlay)] backdrop-blur-sm"
|
||||
data-state={state === 'open' ? 'open' : 'closing'}
|
||||
role="presentation"
|
||||
onClick={closeHelp}
|
||||
>
|
||||
<div
|
||||
className="trek-help-dialog relative w-[min(1520px,100%)] h-[min(960px,100%)] grid grid-cols-1 md:grid-cols-[320px_minmax(0,1fr)] rounded-2xl border border-edge bg-surface-card shadow-modal overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('help.center.title')}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
|
||||
|
||||
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeHelp}
|
||||
aria-label={t('help.center.close')}
|
||||
className="absolute top-4 right-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
||||
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
|
||||
{searching ? (
|
||||
<HelpSearchResults key="search" />
|
||||
) : guide ? (
|
||||
<HelpGuideView key={guide.id} guide={guide} />
|
||||
) : (
|
||||
<HelpHome key={context?.id ?? 'none'} context={context} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HelpLightbox />
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { Loader2, SearchX } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
import { allHelpGuides, guideKey, guideStepKey, guideTipKey, ctxKey } from '../../help/registry'
|
||||
import type { HelpGuide } from '../../help/types'
|
||||
import { GuideCard, SectionLabel } from './HelpHome'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
|
||||
/** Guides shown for a query, at most. The docs list is capped by the server. */
|
||||
const MAX_GUIDES = 8
|
||||
|
||||
/** Words that carry no meaning on their own; "how do I create a trip" is "create trip". */
|
||||
const STOP = new Set(['a', 'an', 'the', 'to', 'of', 'in', 'on', 'for', 'and', 'or', 'how', 'do', 'i', 'my', 'is', 'it', 'with'])
|
||||
|
||||
/** Lower-case, accents stripped, so "Fjorde" finds "fjorde" and "cafe" finds "Café". */
|
||||
const fold = (text: string): string => text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
|
||||
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
/** The query as the words that matter, or all of them when only stop words were typed. */
|
||||
function queryWords(query: string): string[] {
|
||||
const words = fold(query).split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
||||
const meaningful = words.filter(w => !STOP.has(w))
|
||||
return meaningful.length > 0 ? meaningful : words
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a guide against the query using the words the reader sees, in their
|
||||
* language: title, goal, the screen it belongs to, steps, tips and result.
|
||||
* Every word of the query has to occur somewhere; a word in the title counts
|
||||
* most, and a title that holds all of them ranks first.
|
||||
*/
|
||||
function scoreGuide(guide: HelpGuide, words: string[], t: (key: string) => string): number {
|
||||
if (words.length === 0) return 0
|
||||
const fields: { text: string; weight: number }[] = [
|
||||
{ text: fold(t(guideKey(guide.id, 'title'))), weight: 6 },
|
||||
{ text: fold(t(guideKey(guide.id, 'goal'))), weight: 3 },
|
||||
{ text: fold(t(ctxKey(guide.context, 'title'))), weight: 2 },
|
||||
{ text: fold(t(guideKey(guide.id, 'result'))), weight: 1 },
|
||||
]
|
||||
for (let n = 1; n <= guide.steps; n++) fields.push({ text: fold(t(guideStepKey(guide.id, n))), weight: 1 })
|
||||
for (let n = 1; n <= guide.tips; n++) fields.push({ text: fold(t(guideTipKey(guide.id, n))), weight: 1 })
|
||||
let score = 0
|
||||
for (const word of words) {
|
||||
let best = 0
|
||||
for (const f of fields) {
|
||||
if (!f.text.includes(word)) continue
|
||||
// A whole word beats a fragment: "trip" in "trips" is fine, "rip" in "trip" is not much.
|
||||
const whole = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(word)}([^\\p{L}\\p{N}]|$)`, 'u').test(f.text)
|
||||
best = Math.max(best, whole ? f.weight * 2 : f.weight)
|
||||
}
|
||||
if (best === 0) return 0
|
||||
score += best
|
||||
}
|
||||
if (words.every(w => fields[0].text.includes(w))) score += 10
|
||||
return score
|
||||
}
|
||||
|
||||
/** Search across every registered guide and the docs; both lists live here. */
|
||||
export default function HelpSearchResults(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const query = useHelpStore(s => s.query)
|
||||
const status = useHelpStore(s => s.searchStatus)
|
||||
const docHits = useHelpStore(s => s.docHits)
|
||||
const q = query.trim()
|
||||
|
||||
const guides = useMemo(() => {
|
||||
const words = queryWords(q)
|
||||
return allHelpGuides()
|
||||
.map(g => ({ g, score: scoreGuide(g, words, t) }))
|
||||
.filter(x => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_GUIDES)
|
||||
.map(x => x.g)
|
||||
}, [q, t])
|
||||
|
||||
const nothing = guides.length === 0 && status === 'done' && docHits.length === 0
|
||||
|
||||
return (
|
||||
<div className="trek-help-view flex flex-col gap-8">
|
||||
<h1 className="text-title font-bold text-content leading-tight">{query.trim()}</h1>
|
||||
{guides.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionLabel>{t('help.center.searchGuides')}</SectionLabel>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{guides.map(g => (
|
||||
<li key={g.id}>
|
||||
<GuideCard guide={g} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionLabel>{t('help.center.searchDocs')}</SectionLabel>
|
||||
{status === 'loading' && <Loader2 className="w-3.5 h-3.5 animate-spin text-content-faint" />}
|
||||
</div>
|
||||
{status === 'error' && <p className="text-body text-content-muted">{t('help.center.searchError')}</p>}
|
||||
{docHits.length > 0 && (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{docHits.map(hit => (
|
||||
<li key={`${hit.slug}#${hit.anchor ?? ''}`}>
|
||||
<HelpDocLink
|
||||
to={`/help/${hit.slug}${hit.anchor ? `#${hit.anchor}` : ''}`}
|
||||
title={hit.heading ? `${hit.title} · ${hit.heading}` : hit.title}
|
||||
subtitle={hit.snippet}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{nothing && (
|
||||
<div className="flex flex-col items-center text-center gap-2 py-10">
|
||||
<SearchX className="w-6 h-6 text-content-faint" />
|
||||
<p className="text-body text-content-muted">{t('help.center.searchEmpty', { query: query.trim() })}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { HelpGuideSize } from '../../help/types'
|
||||
import HelpBadge from './HelpBadge'
|
||||
|
||||
/** "Quick", "Guide" or "Walkthrough": how much of a task the guide covers. */
|
||||
export default function HelpSizeChip({ size }: { size: HelpGuideSize }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const label = size === 'quick' ? t('help.center.quick') : size === 'tour' ? t('help.center.tour') : t('help.center.guide')
|
||||
return (
|
||||
<HelpBadge tone="accent" icon={Zap}>
|
||||
{label}
|
||||
</HelpBadge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Help center: the floating panel and its lightbox. Colors and sizes come from
|
||||
the appearance tokens; this file only holds motion and the few shapes
|
||||
Tailwind cannot express tidily. */
|
||||
|
||||
@keyframes trek-help-in {
|
||||
from { opacity: 0; transform: translateY(14px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes trek-help-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(10px) scale(0.985); }
|
||||
}
|
||||
@keyframes trek-help-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes trek-help-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes trek-help-rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes trek-help-pop {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.trek-help-backdrop[data-state='open'] {
|
||||
animation: trek-help-fade 180ms ease-out;
|
||||
}
|
||||
.trek-help-backdrop[data-state='closing'] {
|
||||
animation: trek-help-fade-out 160ms ease-in forwards;
|
||||
}
|
||||
.trek-help-backdrop[data-state='open'] > .trek-help-dialog {
|
||||
animation: trek-help-in 280ms var(--ease-out-quint);
|
||||
}
|
||||
.trek-help-backdrop[data-state='closing'] > .trek-help-dialog {
|
||||
animation: trek-help-out 160ms ease-in forwards;
|
||||
}
|
||||
|
||||
.trek-help-menu {
|
||||
animation: trek-help-pop 160ms var(--ease-out-quint);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
/* Views slide up softly when the reader moves between home, search and a guide. */
|
||||
.trek-help-view {
|
||||
animation: trek-help-rise 220ms var(--ease-out-quint);
|
||||
}
|
||||
|
||||
.trek-help-lightbox {
|
||||
animation: trek-help-fade 160ms ease-out;
|
||||
}
|
||||
.trek-help-lightbox > .trek-help-lightbox-card {
|
||||
animation: trek-help-pop 220ms var(--ease-out-quint);
|
||||
}
|
||||
|
||||
/* Step pictures: a hairline frame so light screenshots keep an edge on light
|
||||
surfaces, and a gentle lift on hover to say "click to enlarge". */
|
||||
.trek-help-shot {
|
||||
transition: transform 200ms var(--ease-out-quint), box-shadow 200ms var(--ease-out-quint);
|
||||
}
|
||||
.trek-help-shot:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
/* A thin scrollbar inside the panel, matching the app's own. */
|
||||
.trek-help-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
[data-reduce-motion] .trek-help-backdrop,
|
||||
[data-reduce-motion] .trek-help-backdrop > .trek-help-dialog,
|
||||
[data-reduce-motion] .trek-help-view,
|
||||
[data-reduce-motion] .trek-help-menu,
|
||||
[data-reduce-motion] .trek-help-lightbox,
|
||||
[data-reduce-motion] .trek-help-lightbox > .trek-help-lightbox-card {
|
||||
animation: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-help-backdrop,
|
||||
.trek-help-backdrop > .trek-help-dialog,
|
||||
.trek-help-view,
|
||||
.trek-help-menu,
|
||||
.trek-help-lightbox,
|
||||
.trek-help-lightbox > .trek-help-lightbox-card {
|
||||
animation: none;
|
||||
}
|
||||
.trek-help-shot { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Plus, Pencil, Image, Copy, Archive, Trash2, SlidersHorizontal, CalendarPlus, LayoutDashboard, Coins, Clock, Compass,
|
||||
CalendarCheck, CircleDot, Hourglass, Calculator, CalendarRange, Building2, Flag, GraduationCap, CalendarOff, CalendarDays,
|
||||
Repeat, UserPlus, Share2, Globe, MapPin, Search, Star, Eye, LandPlot, BarChart3, MinusCircle, Landmark,
|
||||
DownloadCloud, CheckCircle2, PanelRight, Tags, Upload, FileInput, Library, Bookmark, BookOpen, Sparkles, ThumbsUp, Images, Link,
|
||||
Book, Palette, Map, Bell, Plug, CloudOff, User, Lock, ShieldCheck, Fingerprint, KeyRound,
|
||||
Route, Users, UserRound, Crown, PanelLeftClose, Undo2, Ticket, Train, ListChecks, Wallet, FolderOpen, MessagesSquare,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { HelpIcon } from '../../help/types'
|
||||
|
||||
/** The registry names icons; the components draw them. Unknown names fall back to the compass. */
|
||||
const ICONS: Record<HelpIcon, LucideIcon> = {
|
||||
plus: Plus,
|
||||
pencil: Pencil,
|
||||
image: Image,
|
||||
copy: Copy,
|
||||
archive: Archive,
|
||||
trash: Trash2,
|
||||
filter: SlidersHorizontal,
|
||||
calendar: CalendarPlus,
|
||||
layout: LayoutDashboard,
|
||||
coins: Coins,
|
||||
clock: Clock,
|
||||
compass: Compass,
|
||||
calendarCheck: CalendarCheck,
|
||||
halfDay: CircleDot,
|
||||
hourglass: Hourglass,
|
||||
calculator: Calculator,
|
||||
calendarRange: CalendarRange,
|
||||
building: Building2,
|
||||
flag: Flag,
|
||||
graduationCap: GraduationCap,
|
||||
calendarOff: CalendarOff,
|
||||
calendarDays: CalendarDays,
|
||||
repeat: Repeat,
|
||||
userPlus: UserPlus,
|
||||
share: Share2,
|
||||
globe: Globe,
|
||||
mapPin: MapPin,
|
||||
search: Search,
|
||||
star: Star,
|
||||
eye: Eye,
|
||||
regions: LandPlot,
|
||||
chart: BarChart3,
|
||||
minusCircle: MinusCircle,
|
||||
landmark: Landmark,
|
||||
download: DownloadCloud,
|
||||
checkCircle: CheckCircle2,
|
||||
panelRight: PanelRight,
|
||||
tags: Tags,
|
||||
upload: Upload,
|
||||
fileInput: FileInput,
|
||||
library: Library,
|
||||
bookmark: Bookmark,
|
||||
bookOpen: BookOpen,
|
||||
sparkles: Sparkles,
|
||||
thumbsUp: ThumbsUp,
|
||||
gallery: Images,
|
||||
link: Link,
|
||||
book: Book,
|
||||
sliders: SlidersHorizontal,
|
||||
palette: Palette,
|
||||
map: Map,
|
||||
bell: Bell,
|
||||
plug: Plug,
|
||||
cloudOff: CloudOff,
|
||||
user: User,
|
||||
lock: Lock,
|
||||
shield: ShieldCheck,
|
||||
fingerprint: Fingerprint,
|
||||
key: KeyRound,
|
||||
route: Route,
|
||||
users: Users,
|
||||
userRound: UserRound,
|
||||
crown: Crown,
|
||||
panelLeft: PanelLeftClose,
|
||||
undo: Undo2,
|
||||
ticket: Ticket,
|
||||
train: Train,
|
||||
listChecks: ListChecks,
|
||||
wallet: Wallet,
|
||||
folder: FolderOpen,
|
||||
messages: MessagesSquare,
|
||||
}
|
||||
|
||||
export function helpIcon(name: HelpIcon): LucideIcon {
|
||||
return ICONS[name] ?? Compass
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
/**
|
||||
* A screen's route ready to follow. A trip screen's route carries `:id`, which
|
||||
* only the address of the trip the reader is on can fill in; browsed from
|
||||
* elsewhere, such a screen has no way in and the button stays away.
|
||||
*/
|
||||
export function useScreenRoute(route: string | undefined): string | null {
|
||||
const { pathname } = useLocation()
|
||||
if (!route) return null
|
||||
if (!route.includes(':id')) return route
|
||||
const match = /^\/trips\/(\d+)/.exec(pathname)
|
||||
return match ? route.replace(':id', match[1]) : null
|
||||
}
|
||||
Reference in New Issue
Block a user