improvement: treatments UI/UX updated again to minimize clicking and scrolling.
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useId, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useId, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
import {
|
||||
ResponsiveDialogOverlay,
|
||||
ResponsiveDialogPanel,
|
||||
} from '@/components/ui/shared/ResponsiveDialog';
|
||||
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
|
||||
import {
|
||||
getToothColumnWidthRem,
|
||||
@@ -17,8 +24,6 @@ const TOOTH_NUMBER_GAP = 'mt-1';
|
||||
const REALISTIC_NUMBER_GAP = '2mm';
|
||||
/** Tight interproximal gap between tooth columns. */
|
||||
const TOOTH_GAP = 'gap-x-px';
|
||||
/** Fixed row cell height — must fit tallest scaled canine/root. */
|
||||
const TOOTH_CELL_H = 'h-[7.25rem]';
|
||||
|
||||
function quadrantMirrored(fdi: FdiToothId): boolean {
|
||||
const q = fdi[0];
|
||||
@@ -47,6 +52,65 @@ function toothSize(fdi: FdiToothId): {
|
||||
};
|
||||
}
|
||||
|
||||
function ToothNumber({
|
||||
fdi,
|
||||
selected,
|
||||
widthRem,
|
||||
colorClass,
|
||||
style,
|
||||
interactive,
|
||||
disabled,
|
||||
onActivate,
|
||||
}: {
|
||||
fdi: FdiToothId;
|
||||
selected: boolean;
|
||||
widthRem: number;
|
||||
colorClass: string;
|
||||
style?: CSSProperties;
|
||||
interactive: boolean;
|
||||
disabled: boolean;
|
||||
onActivate: (shiftKey: boolean) => void;
|
||||
}) {
|
||||
const className = `text-[10px] tabular-nums text-center leading-none ${colorClass}`;
|
||||
const boxStyle: CSSProperties = { width: `${widthRem}rem`, ...style };
|
||||
|
||||
if (!interactive) {
|
||||
return (
|
||||
<span className={className} style={boxStyle}>
|
||||
{fdi}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-fdi={fdi}
|
||||
onMouseDown={(e) => {
|
||||
if (e.shiftKey) e.preventDefault();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
onActivate(e.shiftKey);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
onActivate(e.shiftKey);
|
||||
}}
|
||||
aria-pressed={selected}
|
||||
className={`${className} bg-transparent select-none touch-manipulation focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
}`}
|
||||
style={boxStyle}
|
||||
>
|
||||
{fdi}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface FdiToothChartProps {
|
||||
selected: ReadonlySet<FdiToothId>;
|
||||
/**
|
||||
@@ -71,6 +135,8 @@ interface FdiToothChartProps {
|
||||
headerControl?: ReactNode;
|
||||
/** Compact card for embedded case detail panels. */
|
||||
compact?: boolean;
|
||||
/** Nested in the treatment editor — no extra card chrome. */
|
||||
embedded?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -126,10 +192,13 @@ export function FdiToothChart({
|
||||
toothColors,
|
||||
headerControl,
|
||||
compact = false,
|
||||
embedded = false,
|
||||
className = '',
|
||||
}: FdiToothChartProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tCommon = useTranslations('common');
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const [hintOpen, setHintOpen] = useState(false);
|
||||
const archPeak = 8;
|
||||
const interactive = !readOnly && Boolean(onToggle);
|
||||
const linkInteractive = interactive && Boolean(onToggleLink);
|
||||
@@ -293,6 +362,13 @@ export function FdiToothChart({
|
||||
/>
|
||||
);
|
||||
|
||||
/** Cover crown/root shift toward the numbers without overlapping neighbors. */
|
||||
const hitOverflowPx = Math.abs(offsetY);
|
||||
const activateTooth = (shiftKey: boolean) => {
|
||||
if (!interactive || isDisabled) return;
|
||||
onToggle?.(fdi, { shiftKey });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fdi}
|
||||
@@ -302,42 +378,66 @@ export function FdiToothChart({
|
||||
{!upper && realistic ? (
|
||||
<div style={{ height: REALISTIC_NUMBER_GAP }} aria-hidden />
|
||||
) : null}
|
||||
<div className={`${TOOTH_CELL_H} flex ${alignItems} justify-center`}>
|
||||
<div
|
||||
className={`group relative box-content flex ${alignItems} justify-center`}
|
||||
style={{
|
||||
height: '7.25rem',
|
||||
paddingBottom: upper ? hitOverflowPx : 0,
|
||||
paddingTop: upper ? 0 : hitOverflowPx,
|
||||
}}
|
||||
>
|
||||
{interactive ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
data-fdi={fdi}
|
||||
onMouseDown={(e) => {
|
||||
if (e.shiftKey) e.preventDefault();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
onToggle?.(fdi, { shiftKey: e.shiftKey });
|
||||
e.stopPropagation();
|
||||
activateTooth(e.shiftKey);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
activateTooth(e.shiftKey);
|
||||
}}
|
||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||
className={`
|
||||
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
|
||||
absolute inset-y-[6%] z-10 rounded-[var(--radius-sm)] bg-transparent select-none touch-manipulation
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
|
||||
${isDisabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
style={{ left: '22%', right: '22%' }}
|
||||
aria-pressed={isSel}
|
||||
aria-label={
|
||||
isSel
|
||||
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
|
||||
: t('toothAria', { fdi })
|
||||
}
|
||||
>
|
||||
{glyph}
|
||||
</button>
|
||||
) : (
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||
className={`pointer-events-none ${isDisabled ? 'opacity-50' : ''}`}
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||
className="rounded-[var(--radius-sm)] p-0.5"
|
||||
aria-hidden={!isSel}
|
||||
className={`
|
||||
rounded-[var(--radius-sm)] p-0.5 transition-transform duration-150
|
||||
${isSel ? 'scale-105' : ''}
|
||||
${
|
||||
interactive && !isDisabled
|
||||
? '[@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-105'
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{upper && realistic ? (
|
||||
<div style={{ height: REALISTIC_NUMBER_GAP }} aria-hidden />
|
||||
@@ -351,9 +451,11 @@ export function FdiToothChart({
|
||||
</div>
|
||||
);
|
||||
|
||||
const cardClass = compact
|
||||
? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2'
|
||||
: 'surface-card p-3 space-y-3';
|
||||
const cardClass = embedded
|
||||
? 'space-y-2'
|
||||
: compact
|
||||
? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2'
|
||||
: 'surface-card p-3 space-y-3';
|
||||
|
||||
const chartBody = (
|
||||
<>
|
||||
@@ -368,44 +470,38 @@ export function FdiToothChart({
|
||||
<div className="relative z-10 space-y-0">
|
||||
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
|
||||
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP} ${TOOTH_NUMBER_GAP}`}>
|
||||
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
|
||||
const size = toothSizeClass(fdi);
|
||||
const isSel = selected.has(fdi);
|
||||
return (
|
||||
<span
|
||||
key={`u-${fdi}`}
|
||||
className={`text-[10px] tabular-nums text-center leading-none ${numberColorClass(fdi, isSel)}`}
|
||||
style={{
|
||||
width: `${size.columnWidthRem}rem`,
|
||||
...(numberStyle(fdi, isSel) ?? {}),
|
||||
}}
|
||||
>
|
||||
{fdi}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => (
|
||||
<ToothNumber
|
||||
key={`u-${fdi}`}
|
||||
fdi={fdi}
|
||||
selected={selected.has(fdi)}
|
||||
widthRem={toothSizeClass(fdi).columnWidthRem}
|
||||
colorClass={numberColorClass(fdi, selected.has(fdi))}
|
||||
style={numberStyle(fdi, selected.has(fdi))}
|
||||
interactive={interactive}
|
||||
disabled={isDisabled}
|
||||
onActivate={(shiftKey) => onToggle?.(fdi, { shiftKey })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="my-2 h-px w-full bg-border/70" role="separator" aria-hidden />
|
||||
|
||||
<div className="pt-0.5">
|
||||
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP}`}>
|
||||
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
|
||||
const size = toothSizeClass(fdi);
|
||||
const isSel = selected.has(fdi);
|
||||
return (
|
||||
<span
|
||||
key={`l-${fdi}`}
|
||||
className={`text-[10px] tabular-nums text-center leading-none ${numberColorClass(fdi, isSel)}`}
|
||||
style={{
|
||||
width: `${size.columnWidthRem}rem`,
|
||||
...(numberStyle(fdi, isSel) ?? {}),
|
||||
}}
|
||||
>
|
||||
{fdi}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => (
|
||||
<ToothNumber
|
||||
key={`l-${fdi}`}
|
||||
fdi={fdi}
|
||||
selected={selected.has(fdi)}
|
||||
widthRem={toothSizeClass(fdi).columnWidthRem}
|
||||
colorClass={numberColorClass(fdi, selected.has(fdi))}
|
||||
style={numberStyle(fdi, selected.has(fdi))}
|
||||
interactive={interactive}
|
||||
disabled={isDisabled}
|
||||
onActivate={(shiftKey) => onToggle?.(fdi, { shiftKey })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={TOOTH_NUMBER_GAP}>
|
||||
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
|
||||
@@ -421,31 +517,63 @@ export function FdiToothChart({
|
||||
|
||||
return (
|
||||
<div className={`${cardClass} ${className}`}>
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<h3 className="text-sm font-semibold text-text-primary">
|
||||
{compact ? t('toothChartTitleCompact') : t('toothChartTitle')}
|
||||
{compact || embedded ? t('toothChartTitleCompact') : t('toothChartTitle')}
|
||||
</h3>
|
||||
{!compact && (
|
||||
<p className="text-[11px] text-text-muted mt-0.5">{t('toothChartHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
|
||||
{headerControl}
|
||||
<p className="text-[11px] text-text-secondary tabular-nums sm:text-end">
|
||||
{t('selectedLabel')}{' '}
|
||||
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
|
||||
</p>
|
||||
{!compact && !embedded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHintOpen(true)}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-text-muted hover:bg-background-card hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('toothChartHelpAria')}
|
||||
>
|
||||
<Info className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex justify-center">{headerControl}</div>
|
||||
<p className="min-w-0 text-[11px] text-text-secondary tabular-nums text-end">
|
||||
{t('selectedLabel')}{' '}
|
||||
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scale !== 1 ? (
|
||||
<div style={{ zoom: scale }} className="origin-top-left w-fit">
|
||||
<div style={{ zoom: scale }} className="mx-auto w-fit origin-top">
|
||||
{chartBody}
|
||||
</div>
|
||||
) : (
|
||||
chartBody
|
||||
)}
|
||||
|
||||
{hintOpen ? (
|
||||
<ResponsiveDialogOverlay onBackdropClick={() => setHintOpen(false)}>
|
||||
<ResponsiveDialogPanel
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${uid}-chart-hint-title`}
|
||||
maxWidthClass="sm:max-w-md"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<h4
|
||||
id={`${uid}-chart-hint-title`}
|
||||
className="text-sm font-semibold text-text-primary pe-2"
|
||||
>
|
||||
{t('toothChartHelpAria')}
|
||||
</h4>
|
||||
<DialogCloseButton onClick={() => setHintOpen(false)} />
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{t('toothChartHint')}</p>
|
||||
<div className="mt-4">
|
||||
<Button type="button" variant="outline" fullWidth onClick={() => setHintOpen(false)}>
|
||||
{tCommon('close')}
|
||||
</Button>
|
||||
</div>
|
||||
</ResponsiveDialogPanel>
|
||||
</ResponsiveDialogOverlay>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user