improvement: treatments UI/UX updated again to minimize clicking and scrolling.

This commit is contained in:
2026-08-19 23:59:37 +03:30
parent 29c639f5a1
commit d2f07c0ed3
33 changed files with 1651 additions and 744 deletions

View File

@@ -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>
);
}