mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
143 lines
4.7 KiB
TypeScript
143 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { Info } from '@/components/icons';
|
|
import { TOUCH_HIT_AREA } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* Technical terms with a short explanation in `glossary.terms.<id>` (CO-14).
|
|
* Keep this list in step with the glossary in locales/en/common.json; a test
|
|
* checks both directions.
|
|
*/
|
|
export const GLOSSARY_IDS = [
|
|
'jmap', 'imap', 'smtp', 'caldav', 'carddav', 'webdav', 'sieve', 'smime', 'pgp',
|
|
'public_key', 'two_factor', 'totp', 'app_password', 'access_token', 'oauth',
|
|
'identity', 'delegation', 'push', 'sync', 'cache', 'sub_addressing',
|
|
'encryption_at_rest', 'vcard', 'icalendar', 'cors',
|
|
] as const;
|
|
|
|
export type GlossaryId = (typeof GLOSSARY_IDS)[number];
|
|
|
|
// Space kept between the explanation and the window edge.
|
|
const EDGE = 8;
|
|
|
|
interface TermInfoProps {
|
|
id: GlossaryId;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* A small info button that shows what a term means. It works without a
|
|
* pointer: the button is in the tab order, toggles with Enter or Space, and
|
|
* Escape closes it again. Screen readers get the explanation as the button's
|
|
* description even while it is closed, so they never have to open it.
|
|
*
|
|
* Put it next to a label rather than inside one: a control named by that
|
|
* label would otherwise read the button's name as part of its own.
|
|
*/
|
|
export function TermInfo({ id, className }: TermInfoProps) {
|
|
const t = useTranslations('glossary');
|
|
const [open, setOpen] = useState(false);
|
|
const [shift, setShift] = useState(0);
|
|
const panelId = useId();
|
|
const explanationId = useId();
|
|
const wrapperRef = useRef<HTMLSpanElement>(null);
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const panelRef = useRef<HTMLSpanElement>(null);
|
|
const term = t(`terms.${id}.term`);
|
|
|
|
const close = useCallback((refocus: boolean) => {
|
|
setOpen(false);
|
|
if (refocus) buttonRef.current?.focus();
|
|
}, []);
|
|
|
|
// Keep the explanation inside the window on narrow screens.
|
|
useLayoutEffect(() => {
|
|
if (!open) {
|
|
setShift(0);
|
|
return;
|
|
}
|
|
const panel = panelRef.current;
|
|
if (!panel) return;
|
|
const rect = panel.getBoundingClientRect();
|
|
const width = document.documentElement.clientWidth || window.innerWidth;
|
|
if (rect.right > width - EDGE) setShift(width - EDGE - rect.right);
|
|
else if (rect.left < EDGE) setShift(EDGE - rect.left);
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
if (!wrapperRef.current?.contains(event.target as Node)) close(false);
|
|
};
|
|
document.addEventListener('pointerdown', onPointerDown);
|
|
return () => document.removeEventListener('pointerdown', onPointerDown);
|
|
}, [open, close]);
|
|
|
|
return (
|
|
<span
|
|
ref={wrapperRef}
|
|
className={cn('relative inline-flex align-middle', className)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape' && open) {
|
|
event.stopPropagation();
|
|
close(true);
|
|
}
|
|
}}
|
|
>
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
aria-label={t('what_is', { term })}
|
|
aria-expanded={open}
|
|
aria-controls={panelId}
|
|
aria-describedby={explanationId}
|
|
data-glossary-term={id}
|
|
onClick={(event) => {
|
|
// Rows and labels around the term must not react to it.
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setOpen((value) => !value);
|
|
}}
|
|
className={cn(
|
|
'inline-flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground',
|
|
TOUCH_HIT_AREA,
|
|
)}
|
|
>
|
|
<Info size={14} aria-hidden="true" />
|
|
</button>
|
|
<span
|
|
ref={panelRef}
|
|
id={panelId}
|
|
hidden={!open}
|
|
style={shift ? { transform: `translateX(${shift}px)` } : undefined}
|
|
className={cn(
|
|
'absolute start-0 top-full z-50 mt-1 block w-72 max-w-[calc(100vw-1rem)]',
|
|
'rounded-md border border-border bg-popover p-3 text-start text-xs font-normal normal-case leading-relaxed tracking-normal text-popover-foreground shadow-md whitespace-normal',
|
|
)}
|
|
>
|
|
<span className="block font-medium text-foreground">{term}</span>
|
|
<span id={explanationId} className="mt-1 block">{t(`terms.${id}.text`)}</span>
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
interface TermProps {
|
|
id: GlossaryId;
|
|
/** The term as it appears in the sentence. */
|
|
children: ReactNode;
|
|
}
|
|
|
|
/** A technical term in running text, followed by its info button. */
|
|
export function Term({ id, children }: TermProps) {
|
|
return (
|
|
<span className="whitespace-nowrap">
|
|
{children}
|
|
<TermInfo id={id} className="ms-0.5" />
|
|
</span>
|
|
);
|
|
}
|