improvement: FDI chart tool selection modes updated. shift+ and crtl+ both are assigned with a selection mode.
This commit is contained in:
@@ -23,7 +23,7 @@ interface FdiToothChartProps {
|
||||
selected: ReadonlySet<FdiToothId>;
|
||||
/** Teeth that belong to a connected selection span (dots above/below). */
|
||||
connectedTeeth?: ReadonlySet<FdiToothId>;
|
||||
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void;
|
||||
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean; ctrlKey: boolean }) => void;
|
||||
disabled?: boolean;
|
||||
/** Non-interactive display (Cases / connection history). */
|
||||
readOnly?: boolean;
|
||||
@@ -144,91 +144,125 @@ export function FdiToothChart({
|
||||
return accent ? { color: accent } : undefined;
|
||||
};
|
||||
|
||||
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
|
||||
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
|
||||
{teeth.map((fdi, i) => {
|
||||
const kind = getToothShapeKind(fdi);
|
||||
const gid = `${uid}-g-${fdi}-${i}`;
|
||||
const isSel = selected.has(fdi);
|
||||
const isConnected = Boolean(connectedTeeth?.has(fdi));
|
||||
const size = toothSizeClass(fdi);
|
||||
const tweak = TOOTH_TWEAKS[fdi];
|
||||
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
|
||||
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
|
||||
const alignItems = upper ? 'items-end' : 'items-start';
|
||||
const accent = toothAccent(fdi);
|
||||
/** Dots + thin links — only for Shift-connected spans (not plain / Ctrl singles). */
|
||||
const ConnectedRail = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => {
|
||||
const hasConnected = teeth.some((fdi) => connectedTeeth?.has(fdi));
|
||||
if (!hasConnected) return null;
|
||||
|
||||
const glyph = (
|
||||
<ToothGlyph
|
||||
fdi={fdi}
|
||||
kind={kind}
|
||||
gradientId={gid}
|
||||
selected={isSel}
|
||||
upper={upper}
|
||||
mirrored={quadrantMirrored(fdi)}
|
||||
upsideDown={upper}
|
||||
className={tweak?.glyph ?? size.glyph}
|
||||
accentColor={isSel ? accent : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectedDot = isConnected ? (
|
||||
<span
|
||||
className={`block h-1.5 w-1.5 rounded-full bg-primary ${upper ? 'mb-0.5' : 'mt-0.5'}`}
|
||||
aria-hidden
|
||||
title={t('toothConnectedHint')}
|
||||
/>
|
||||
) : (
|
||||
<span className={`block h-1.5 w-1.5 ${upper ? 'mb-0.5' : 'mt-0.5'}`} aria-hidden />
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
|
||||
{upper ? connectedDot : null}
|
||||
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
|
||||
{interactive ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onMouseDown={(e) => {
|
||||
// Shift+click otherwise triggers browser text-selection / sticky focus boxes.
|
||||
if (e.shiftKey) e.preventDefault();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggle?.(fdi, { shiftKey: e.shiftKey });
|
||||
}}
|
||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||
className={`
|
||||
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
|
||||
`}
|
||||
aria-pressed={isSel}
|
||||
aria-label={
|
||||
isSel
|
||||
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}${
|
||||
isConnected ? t('toothConnectedSuffix') : ''
|
||||
}`
|
||||
: t('toothAria', { fdi })
|
||||
}
|
||||
>
|
||||
{glyph}
|
||||
</button>
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit ${upper ? 'mb-1' : 'mt-1'}`}
|
||||
aria-hidden
|
||||
>
|
||||
{teeth.map((fdi, i) => {
|
||||
const size = toothSizeClass(fdi);
|
||||
const isConnected = Boolean(connectedTeeth?.has(fdi));
|
||||
const next = teeth[i + 1];
|
||||
const linkToNext = Boolean(isConnected && next && connectedTeeth?.has(next));
|
||||
return (
|
||||
<div
|
||||
key={`c-${fdi}`}
|
||||
className={`relative flex h-2.5 items-center justify-center ${size.wrapper}`}
|
||||
title={isConnected ? t('toothConnectedHint') : undefined}
|
||||
>
|
||||
{linkToNext ? (
|
||||
<span
|
||||
className="pointer-events-none absolute top-1/2 left-1/2 z-0 h-[2px] -translate-y-1/2 bg-primary/75"
|
||||
style={{ width: 'calc(100% + 0.25rem)' }}
|
||||
/>
|
||||
) : null}
|
||||
{isConnected ? (
|
||||
<span className="relative z-10 block h-2 w-2 rounded-full bg-primary shadow-sm" />
|
||||
) : (
|
||||
<div
|
||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||
className="rounded-[var(--radius-sm)] p-0.5"
|
||||
aria-hidden={!isSel}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
<span className="block h-2 w-2" />
|
||||
)}
|
||||
</div>
|
||||
{!upper ? connectedDot : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
|
||||
<div className="flex flex-col items-center min-w-max mx-auto w-fit">
|
||||
{upper ? <ConnectedRail teeth={teeth} upper /> : null}
|
||||
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
|
||||
{teeth.map((fdi, i) => {
|
||||
const kind = getToothShapeKind(fdi);
|
||||
const gid = `${uid}-g-${fdi}-${i}`;
|
||||
const isSel = selected.has(fdi);
|
||||
const isConnected = Boolean(connectedTeeth?.has(fdi));
|
||||
const size = toothSizeClass(fdi);
|
||||
const tweak = TOOTH_TWEAKS[fdi];
|
||||
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
|
||||
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
|
||||
const alignItems = upper ? 'items-end' : 'items-start';
|
||||
const accent = toothAccent(fdi);
|
||||
|
||||
const glyph = (
|
||||
<ToothGlyph
|
||||
fdi={fdi}
|
||||
kind={kind}
|
||||
gradientId={gid}
|
||||
selected={isSel}
|
||||
upper={upper}
|
||||
mirrored={quadrantMirrored(fdi)}
|
||||
upsideDown={upper}
|
||||
className={tweak?.glyph ?? size.glyph}
|
||||
accentColor={isSel ? accent : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
|
||||
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
|
||||
{interactive ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onMouseDown={(e) => {
|
||||
// Modifier+click otherwise triggers browser text-selection / sticky focus boxes.
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) e.preventDefault();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggle?.(fdi, {
|
||||
shiftKey: e.shiftKey,
|
||||
ctrlKey: e.ctrlKey || e.metaKey,
|
||||
});
|
||||
}}
|
||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||
className={`
|
||||
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
|
||||
`}
|
||||
aria-pressed={isSel}
|
||||
aria-label={
|
||||
isSel
|
||||
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}${
|
||||
isConnected ? t('toothConnectedSuffix') : ''
|
||||
}`
|
||||
: t('toothAria', { fdi })
|
||||
}
|
||||
>
|
||||
{glyph}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||
className="rounded-[var(--radius-sm)] p-0.5"
|
||||
aria-hidden={!isSel}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!upper ? <ConnectedRail teeth={teeth} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user