549 lines
22 KiB
JavaScript
549 lines
22 KiB
JavaScript
import { useEffect, useImperativeHandle, useRef, useState } from 'react'
|
|
import { searchRabbits, getRabbitAncestors } from '../api'
|
|
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
|
|
import { useMediaQuery } from '../hooks/useMediaQuery'
|
|
import { useWeightUnit } from '../hooks/useWeightUnit'
|
|
import { formatWeight } from '../lib/format'
|
|
import { CoatAvatar } from './CoatArt'
|
|
import Icon from './Icon'
|
|
import {
|
|
ALL_SLOTS,
|
|
GEN1,
|
|
createInitialPedigreeState,
|
|
resolveEffectiveNode,
|
|
buildPedigreePayload,
|
|
buildPedigreeSummary,
|
|
serializeManualAncestor,
|
|
parseManualAncestor,
|
|
formatManualAncestorDisplay,
|
|
formatManualAncestorDetails,
|
|
MANUAL_ANCESTOR_DEFAULTS,
|
|
} from '../lib/pedigree'
|
|
|
|
const EMPTY_MANUAL_FORM = {
|
|
name: '',
|
|
farm: '',
|
|
ear_number: '',
|
|
weight_lbs: String(MANUAL_ANCESTOR_DEFAULTS.weight_lbs),
|
|
dob: '',
|
|
dobUnknown: true,
|
|
}
|
|
|
|
// Upper bound on the popup's height (its tallest state is the manual-entry
|
|
// form). Used to decide drop direction synchronously on open, before the popup
|
|
// mounts, so the very first click is positioned correctly.
|
|
const POPUP_MAX_HEIGHT = 360
|
|
|
|
// Viewport space at the bottom covered by fixed/sticky chrome (the Add Rabbit
|
|
// action bar, plus the bottom nav on phones), so a popup that would open
|
|
// underneath it flips upward instead.
|
|
function bottomChromeHeight() {
|
|
return window.matchMedia('(max-width: 767px)').matches ? 136 : 72
|
|
}
|
|
|
|
// The 3-column tree needs ~900px of content width; below that (phones,
|
|
// tablets, small laptops with the sidebar) the same slots render as a stacked,
|
|
// indented list instead of a sideways-scrolling tree.
|
|
const WIDE_TREE_QUERY = '(min-width: 1280px)'
|
|
|
|
const SLOT_BY_PATH = new Map(ALL_SLOTS.map((entry) => [entry.path.join('.'), entry]))
|
|
|
|
// Sire-side slots are blue, dam-side pink (the buck/doe hues used app-wide).
|
|
function sideTone(path) {
|
|
return path[path.length - 1] === 'sire' ? 'bg-c6-soft text-c6-ink' : 'bg-c2-soft text-c2-ink'
|
|
}
|
|
|
|
// Node widths per generation in the wide bracket layout (px).
|
|
const WIDTHS = [0, 210, 196, 184]
|
|
|
|
function LockIcon() {
|
|
return (
|
|
<svg viewBox="0 0 20 20" className="h-4 w-4 shrink-0 opacity-70" fill="currentColor" aria-label="Locked">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 2a4 4 0 0 0-4 4v2H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-1V6a4 4 0 0 0-4-4Zm2 6V6a2 2 0 1 0-4 0v2h4Z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
// `dob` on the manual-ancestor form is a real date input value; `dobUnknown`
|
|
// is the separate "Unknown" checkbox. They collapse into one stored string
|
|
// ("Unknown" or an actual date) only at save time, via serializeManualAncestor.
|
|
function manualFormFromParsed(parsed) {
|
|
const isKnownDate = parsed?.dob && parsed.dob !== 'Unknown'
|
|
return {
|
|
...EMPTY_MANUAL_FORM,
|
|
...parsed,
|
|
weight_lbs: String(parsed?.weight_lbs ?? MANUAL_ANCESTOR_DEFAULTS.weight_lbs),
|
|
dob: isKnownDate ? parsed.dob : '',
|
|
dobUnknown: !isKnownDate,
|
|
}
|
|
}
|
|
|
|
function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fetchCascade, flagged = false }) {
|
|
const node = resolveEffectiveNode(path, state, cascadeCache)
|
|
const weightUnit = useWeightUnit()
|
|
const [open, setOpen] = useState(false)
|
|
const [mode, setMode] = useState('search') // 'search' | 'manual'
|
|
const [query, setQuery] = useState('')
|
|
const [results, setResults] = useState([])
|
|
const [manualForm, setManualForm] = useState(EMPTY_MANUAL_FORM)
|
|
const containerRef = useRef(null)
|
|
const inputRef = useRef(null)
|
|
const triggerRef = useRef(null)
|
|
// Open the popup above the trigger when there isn't room below it (e.g. nodes
|
|
// near the bottom of the window) so the tall manual form doesn't get cut off.
|
|
const [dropUp, setDropUp] = useState(false)
|
|
|
|
// Decide drop direction from the trigger's position in the viewport. Called
|
|
// synchronously on open (the trigger already exists in the DOM) so the first
|
|
// click lands in the right place rather than flipping on the next open.
|
|
function updateDropUp() {
|
|
if (!triggerRef.current) return
|
|
const rect = triggerRef.current.getBoundingClientRect()
|
|
const spaceBelow = window.innerHeight - bottomChromeHeight() - rect.bottom
|
|
const spaceAbove = rect.top
|
|
setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow)
|
|
}
|
|
|
|
function close() {
|
|
setOpen(false)
|
|
triggerRef.current?.focus()
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
function onClickOutside(e) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', onClickOutside)
|
|
return () => document.removeEventListener('mousedown', onClickOutside)
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (!open || mode !== 'search') return
|
|
const handle = setTimeout(() => {
|
|
searchRabbits({ q: query }).then(setResults).catch(() => setResults([]))
|
|
}, 200)
|
|
return () => clearTimeout(handle)
|
|
}, [query, open, mode])
|
|
|
|
if (node.locked) {
|
|
const text = node.loading
|
|
? 'Loading…'
|
|
: node.mode === 'unknown'
|
|
? 'Unknown'
|
|
: node.mode === 'manual'
|
|
? formatManualAncestorDisplay(node.name)
|
|
: node.name
|
|
return (
|
|
<div
|
|
data-testid={`pedigree-node-${slot}`}
|
|
data-locked="true"
|
|
title="Filled in from the linked parent's own pedigree"
|
|
className={`flex min-h-[58px] w-full items-center justify-between gap-2 rounded-2xl px-3 py-2 ${
|
|
node.mode === 'unknown' ? 'border-2 border-dashed border-border text-text-faint' : `${sideTone(path)} opacity-75`
|
|
}`}
|
|
>
|
|
<div className="min-w-0 flex-1 overflow-hidden">
|
|
<p className="truncate font-body text-[11px] font-bold opacity-80">{label}</p>
|
|
<p className="truncate font-body text-sm font-semibold">{text}</p>
|
|
</div>
|
|
<LockIcon />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function selectRabbit(rabbit) {
|
|
setSlotState(slot, { mode: 'linked', id: rabbit.id, name: rabbit.name })
|
|
if (slot === 'sire' || slot === 'dam') fetchCascade(rabbit.id)
|
|
setOpen(false)
|
|
setQuery('')
|
|
}
|
|
|
|
const canSaveManual =
|
|
manualForm.name.trim() && manualForm.farm.trim() && manualForm.ear_number.trim()
|
|
|
|
function setManualField(field, fieldValue) {
|
|
setManualForm((prev) => ({ ...prev, [field]: fieldValue }))
|
|
}
|
|
|
|
function saveManual() {
|
|
if (!canSaveManual) return
|
|
const dob = manualForm.dobUnknown ? 'Unknown' : manualForm.dob
|
|
setSlotState(slot, { mode: 'manual', id: null, name: serializeManualAncestor({ ...manualForm, dob }) })
|
|
setOpen(false)
|
|
}
|
|
|
|
function markUnknown() {
|
|
setSlotState(slot, { mode: 'unknown', id: null, name: null })
|
|
setOpen(false)
|
|
}
|
|
|
|
const displayText =
|
|
node.mode === 'unknown' ? 'Unknown' : node.mode === 'manual' ? formatManualAncestorDisplay(node.name) : node.name
|
|
const detailText =
|
|
node.mode === 'manual' ? formatManualAncestorDetails(node.name, (w) => formatWeight(w, weightUnit)) : null
|
|
|
|
return (
|
|
<div ref={containerRef} data-testid={`pedigree-node-${slot}`} className="relative w-full min-w-[140px]">
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
onClick={() => {
|
|
if (!open) updateDropUp()
|
|
setOpen((v) => !v)
|
|
if (node.mode === 'manual') {
|
|
setMode('manual')
|
|
setManualForm(manualFormFromParsed(parseManualAncestor(node.name)))
|
|
} else {
|
|
setMode('search')
|
|
setManualForm(EMPTY_MANUAL_FORM)
|
|
}
|
|
}}
|
|
aria-expanded={open}
|
|
className={`flex min-h-[58px] w-full min-w-0 flex-col items-start justify-center gap-px overflow-hidden rounded-2xl px-3 py-2 text-left transition-[transform,box-shadow] hover:-translate-y-0.5 focus:outline-none ${
|
|
node.mode === 'unknown'
|
|
? 'border-2 border-dashed border-border-strong bg-surface-raised text-text-secondary hover:border-accent hover:text-text-primary'
|
|
: sideTone(path)
|
|
} ${flagged ? 'ring-2 ring-c5 ring-offset-2 ring-offset-surface-raised' : ''} ${open ? 'shadow-pop' : ''}`}
|
|
>
|
|
<span className="flex w-full items-center gap-1.5 font-body text-[11px] font-bold opacity-80">
|
|
<span className="truncate">{label}</span>
|
|
{flagged && (
|
|
<span className="shrink-0 rounded-full bg-c5 px-1.5 font-extrabold text-[#1c1530]">Check</span>
|
|
)}
|
|
</span>
|
|
<span className="flex w-full items-center gap-1 truncate font-body text-sm font-extrabold">
|
|
{node.mode === 'unknown' && <Icon name="add" className="h-3.5 w-3.5 shrink-0" strokeWidth={2.6} />}
|
|
<span className="truncate">{node.mode === 'unknown' ? 'Unknown — choose' : displayText}</span>
|
|
</span>
|
|
{detailText && <span className="w-full truncate font-body text-xs opacity-85">{detailText}</span>}
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
className={`absolute left-0 z-30 w-full min-w-[17rem] max-w-sm rounded-3xl border border-border bg-surface-raised p-2.5 text-text-primary shadow-pop ${
|
|
dropUp ? 'bottom-full mb-2' : 'top-full mt-2'
|
|
}`}
|
|
>
|
|
<div className="mb-2 flex gap-1 rounded-full bg-surface-sunken p-1">
|
|
{[
|
|
{ value: 'search', label: 'From your herd' },
|
|
{ value: 'manual', label: 'Type it in' },
|
|
].map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => setMode(opt.value)}
|
|
aria-pressed={mode === opt.value}
|
|
className={`min-h-[36px] flex-1 rounded-full px-2 font-body text-xs font-bold transition-colors ${
|
|
mode === opt.value ? 'bg-surface-raised text-text-primary shadow-card' : 'text-text-secondary hover:text-text-primary'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{mode === 'search' && (
|
|
<div>
|
|
<input
|
|
ref={inputRef}
|
|
autoFocus
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
|
|
placeholder="Search by name or ear #…"
|
|
className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
|
/>
|
|
<ul className="mt-1.5 max-h-52 overflow-auto">
|
|
{results.map((rabbit, index) => (
|
|
<li key={rabbit.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => selectRabbit(rabbit)}
|
|
onKeyDown={(e) =>
|
|
handleResultKeyDown(e, {
|
|
containerEl: containerRef.current,
|
|
index,
|
|
inputEl: inputRef.current,
|
|
close,
|
|
})
|
|
}
|
|
className="flex w-full items-center gap-2.5 rounded-2xl px-2 py-1.5 text-left hover:bg-surface-sunken focus:bg-surface-sunken"
|
|
>
|
|
<CoatAvatar rabbit={rabbit} className="h-8 w-8" />
|
|
<span className="min-w-0">
|
|
<span className="block truncate font-body text-sm font-bold">{rabbit.name}</span>
|
|
{(rabbit.farm || rabbit.ear_number) && (
|
|
<span className="block truncate font-body text-xs text-text-secondary">
|
|
{[rabbit.farm, rabbit.ear_number && `#${rabbit.ear_number}`].filter(Boolean).join(' · ')}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
{query && results.length === 0 && (
|
|
<li className="px-2 py-2 font-body text-sm text-text-secondary">No matches — try “Type it in”.</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{mode === 'manual' && (
|
|
<div className="space-y-1.5">
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={manualForm.farm}
|
|
onChange={(e) => setManualField('farm', e.target.value)}
|
|
placeholder="Farm Name *"
|
|
className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={manualForm.name}
|
|
onChange={(e) => setManualField('name', e.target.value)}
|
|
placeholder="Rabbit Name *"
|
|
className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={manualForm.ear_number}
|
|
onChange={(e) => setManualField('ear_number', e.target.value.toUpperCase())}
|
|
placeholder="Ear Number *"
|
|
className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-mono text-sm uppercase focus:border-accent focus:outline-none"
|
|
/>
|
|
<div>
|
|
<label className="block px-0.5 font-body text-xs text-text-secondary">
|
|
Weight (lbs) — {MANUAL_ANCESTOR_DEFAULTS.weight_lbs} if not known
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={manualForm.weight_lbs}
|
|
onChange={(e) => setManualField('weight_lbs', e.target.value)}
|
|
className="mt-0.5 w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block px-0.5 font-body text-xs text-text-secondary">Date of Birth</label>
|
|
<input
|
|
type="date"
|
|
value={manualForm.dob}
|
|
disabled={manualForm.dobUnknown}
|
|
onChange={(e) => setManualField('dob', e.target.value)}
|
|
className="mt-0.5 w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none disabled:cursor-not-allowed disabled:!bg-surface-sunken disabled:text-text-secondary"
|
|
/>
|
|
</div>
|
|
<label className="flex items-center gap-1.5 px-0.5 font-body text-xs text-text-secondary">
|
|
<input
|
|
type="checkbox"
|
|
checked={manualForm.dobUnknown}
|
|
onChange={(e) =>
|
|
setManualForm((prev) => ({
|
|
...prev,
|
|
dobUnknown: e.target.checked,
|
|
dob: e.target.checked ? '' : prev.dob,
|
|
}))
|
|
}
|
|
className="accent-accent"
|
|
/>
|
|
Unknown
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={saveManual}
|
|
disabled={!canSaveManual}
|
|
className="min-h-[40px] w-full rounded-full bg-accent px-3 font-body text-sm font-bold text-on-accent disabled:opacity-50"
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={markUnknown}
|
|
className="mt-1.5 min-h-[38px] w-full rounded-full px-3 text-center font-body text-xs font-bold text-text-secondary hover:bg-surface-sunken"
|
|
>
|
|
Mark as Unknown
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// One slot plus (in the wide layout) its own sire/dam, joined by the bracket
|
|
// lines drawn in index.css (.ped-*).
|
|
function Branch({ path, slotProps }) {
|
|
const entry = SLOT_BY_PATH.get(path.join('.'))
|
|
const childPaths = [
|
|
[...path, 'sire'],
|
|
[...path, 'dam'],
|
|
].filter((p) => SLOT_BY_PATH.has(p.join('.')))
|
|
|
|
return (
|
|
<div className="ped-br">
|
|
<div className={`ped-nw ${childPaths.length ? 'has' : ''}`} style={{ width: WIDTHS[path.length] }}>
|
|
<AncestorSlot
|
|
path={entry.path}
|
|
slot={entry.slot}
|
|
label={entry.label}
|
|
state={slotProps.state}
|
|
setSlotState={slotProps.setSlotState}
|
|
cascadeCache={slotProps.cascadeCache}
|
|
fetchCascade={slotProps.fetchCascade}
|
|
flagged={slotProps.flaggedSlots.has(entry.slot)}
|
|
/>
|
|
</div>
|
|
{childPaths.length > 0 && (
|
|
<div className="ped-pair">
|
|
{childPaths.map((childPath) => (
|
|
<Branch key={childPath.join('.')} path={childPath} slotProps={slotProps} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// One ancestor plus, indented beneath it, its own sire/dam branches — the
|
|
// stacked (narrow-screen) rendering of the same slots the wide tree shows.
|
|
function StackedBranch({ path, slotProps }) {
|
|
const entry = SLOT_BY_PATH.get(path.join('.'))
|
|
const childPaths = [
|
|
[...path, 'sire'],
|
|
[...path, 'dam'],
|
|
].filter((p) => SLOT_BY_PATH.has(p.join('.')))
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<AncestorSlot
|
|
path={entry.path}
|
|
slot={entry.slot}
|
|
label={entry.label}
|
|
state={slotProps.state}
|
|
setSlotState={slotProps.setSlotState}
|
|
cascadeCache={slotProps.cascadeCache}
|
|
fetchCascade={slotProps.fetchCascade}
|
|
flagged={slotProps.flaggedSlots.has(entry.slot)}
|
|
/>
|
|
{childPaths.length > 0 && (
|
|
<div className="ml-2 flex flex-col gap-2 border-l-[2.5px] border-border-strong pl-3">
|
|
{childPaths.map((childPath) => (
|
|
<StackedBranch key={childPath.join('.')} path={childPath} slotProps={slotProps} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PedigreeTree({
|
|
// Exposes setParent(slot, rabbit) so the page can fill the sire/dam from
|
|
// outside the tree (Add Rabbit's suggested parents).
|
|
ref,
|
|
subjectName = 'New Rabbit',
|
|
initialState,
|
|
initialCascade,
|
|
onChange,
|
|
// Slots a pedigree scan was unsure about. Each is highlighted until the user
|
|
// edits it (tracked locally in `reviewedSlots`).
|
|
uncertainSlots,
|
|
}) {
|
|
const [state, setState] = useState(() => initialState ?? createInitialPedigreeState())
|
|
const [cascadeCache, setCascadeCache] = useState(() => initialCascade ?? new Map())
|
|
// Ids with a fetch already started, kept outside state so fetchCascade never
|
|
// sets state synchronously (it's called from a mount effect below). An id is
|
|
// never removed: on failure the cache stores null, and we don't retry.
|
|
const cascadeFetchStarted = useRef(new Set())
|
|
const wide = useMediaQuery(WIDE_TREE_QUERY)
|
|
|
|
const [reviewedSlots, setReviewedSlots] = useState(() => new Set())
|
|
const flaggedSlots = new Set([...(uncertainSlots ?? [])].filter((slot) => !reviewedSlots.has(slot)))
|
|
|
|
function setSlotState(slot, value) {
|
|
setState((prev) => ({ ...prev, [slot]: value }))
|
|
setReviewedSlots((prev) => new Set(prev).add(slot))
|
|
}
|
|
|
|
function fetchCascade(id) {
|
|
if (cascadeCache.has(id) || cascadeFetchStarted.current.has(id)) return
|
|
cascadeFetchStarted.current.add(id)
|
|
getRabbitAncestors(id)
|
|
.then((tree) => setCascadeCache((prev) => new Map(prev).set(id, tree)))
|
|
.catch(() => setCascadeCache((prev) => new Map(prev).set(id, null)))
|
|
}
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
setParent(slot, rabbit) {
|
|
setSlotState(slot, { mode: 'linked', id: rabbit.id, name: rabbit.name })
|
|
fetchCascade(rabbit.id)
|
|
},
|
|
}))
|
|
|
|
useEffect(() => {
|
|
onChange?.(buildPedigreePayload(state, cascadeCache), buildPedigreeSummary(state, cascadeCache))
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [state, cascadeCache])
|
|
|
|
// If the initial state (e.g. editing an existing rabbit) already has a
|
|
// linked sire/dam, make sure its cascade subtree gets fetched — initialCascade
|
|
// only covers what was already available synchronously at mount.
|
|
useEffect(() => {
|
|
for (const { slot } of GEN1) {
|
|
const node = state[slot]
|
|
if (node.mode === 'linked' && node.id != null) fetchCascade(node.id)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
const slotProps = { state, setSlotState, cascadeCache, fetchCascade, flaggedSlots }
|
|
|
|
if (!wide) {
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{GEN1.map(({ path, slot }) => (
|
|
<section key={slot} className="rounded-3xl border border-border bg-surface-raised p-3.5 shadow-card">
|
|
<h3 className="mb-2.5 flex items-center gap-2 px-1 font-display text-base font-bold text-text-primary">
|
|
<span className={`h-2.5 w-2.5 rounded-full ${slot === 'sire' ? 'bg-c6' : 'bg-c2'}`} aria-hidden="true" />
|
|
{slot === 'sire' ? "Sire's side" : "Dam's side"}
|
|
</h3>
|
|
<StackedBranch path={path} slotProps={slotProps} />
|
|
</section>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// No overflow clipping here: a slot's picker floats out over the card (each
|
|
// picker flips upward on its own when there isn't room below).
|
|
return (
|
|
<div className="w-full rounded-3xl border border-border bg-surface-raised p-5 shadow-card">
|
|
<div className="ped-br">
|
|
<div className="ped-nw has" style={{ width: 150 }}>
|
|
<div className="flex min-h-[72px] items-center justify-center rounded-2xl bg-text-primary px-3 text-center">
|
|
<span className="truncate font-display text-base font-bold text-surface">{subjectName}</span>
|
|
</div>
|
|
</div>
|
|
<div className="ped-pair">
|
|
{GEN1.map(({ path }) => (
|
|
<Branch key={path.join('.')} path={path} slotProps={slotProps} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default PedigreeTree
|