Skip to content
Clone
eeff292
399 lines 13.6 KB
/**
* Small shared render pieces for the 问题集 pages: badges, avatars, filter
* menus. They own no data — every value comes from `@/entities/issue` metadata
* so the kanban, the table and the detail panel render the same state the same
* way.
*/
import type { IssueLabel, IssuePriority, IssueState, UserDto } from '@app/domains/issues/model'
import { describeDue, normalizeColor, priorityOf, readableText, STATES } from '@app/domains/issues/model'
import { useT } from '@app/i18n'
import { IconCheck as Check, IconChevronDown as ChevronDown } from '@tabler/icons-react'
import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
export function PriorityBadge({ priority, compact = false }: { priority: IssuePriority; compact?: boolean }) {
const { t } = useT()
const meta = priorityOf(priority)
return (
<span
data-priority={meta.id}
className={`issue-priority-badge inline-flex shrink-0 items-center rounded-full font-semibold ${compact ? 'px-1.5 py-px text-[10.5px]' : 'px-2 py-0.5 text-[11px]'}`}
title={t('issue.filter.chip.priority', { value: meta.label })}
>
{meta.label}
</span>
)
}
export function StateBadge({ state }: { state: IssueState }) {
const meta = STATES[state] ?? STATES.open
return (
<span
data-state={state}
className="issue-state-badge inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold"
>
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />
{meta.label}
</span>
)
}
/**
* Linear-style priority mark: three bars filled up to the priority's rank, an
* outline-only mark for 无优先级. Urgent keeps a red accent; everything else
* inherits the row's ink so a page of issues does not turn into a colour chart.
*/
export function PriorityGlyph({ priority }: { priority: IssuePriority }) {
const meta = priorityOf(priority)
const filled = meta.rank <= 0 ? 0 : Math.min(meta.rank, 3)
return (
<span className="issue-priority-glyph" data-priority={meta.id} title={meta.label}>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden focusable="false">
{[0, 1, 2].map((index) => (
<rect
key={index}
className={index < filled ? 'is-on' : undefined}
x={2.5 + index * 4.25}
y={9.5 - index * 3.25}
width="3"
height={4.5 + index * 3.25}
rx="1"
/>
))}
</svg>
<span className="sr-only">{meta.label}</span>
</span>
)
}
export function LabelChip({ label, onRemove }: { label: IssueLabel; onRemove?: () => void }) {
const { t } = useT()
const color = normalizeColor(label.color)
return (
<span
className="inline-flex max-w-[150px] shrink-0 items-center gap-1 rounded-full px-1.5 py-px text-[10.5px] font-semibold"
style={{ backgroundColor: color, color: readableText(color) }}
title={label.description ? `${label.name} · ${label.description}` : label.name}
>
<span className="truncate">{label.name}</span>
{onRemove ? (
<button
type="button"
aria-label={t('issue.board.removeLabel', { name: label.name })}
onClick={(event) => {
event.stopPropagation()
onRemove()
}}
className="shrink-0 opacity-70 hover:opacity-100"
>
×
</button>
) : null}
</span>
)
}
export function DueChip({ due, state }: { due: string | null | undefined; state: IssueState }) {
const meta = describeDue(due, state)
if (!meta) return null
const tone = meta.tone === 'over' ? 'text-[#e5484d]' : meta.tone === 'due' ? 'text-[#b26a00]' : 'text-shell-ink-muted'
return <span className={`shrink-0 text-[11px] ${tone}`}>{meta.text}</span>
}
/** Up to `max` assignee avatars, then a "+n" chip. */
export function AvatarStack({ users, max = 3, size = 18 }: { users: UserDto[]; max?: number; size?: number }) {
if (users.length === 0) return null
const shown = users.slice(0, max)
const rest = users.length - shown.length
return (
<span className="flex shrink-0 items-center -space-x-1">
{shown.map((user) => (
<UserAvatar key={user.user_id} user={user} size={size} />
))}
{rest > 0 ? (
<span
className="grid place-content-center rounded-full bg-shell-tile text-[10px] font-semibold text-shell-ink ring-2 ring-shell-surface"
style={{ width: size, height: size }}
title={users.map((u) => u.display_name || u.username).join(', ')}
>
+{rest}
</span>
) : null}
</span>
)
}
export function UserAvatar({ user, size = 18 }: { user: UserDto; size?: number }) {
const initial = (user.display_name || user.username || '?').slice(0, 1).toUpperCase()
if (user.avatar_url) {
return (
<img
src={user.avatar_url}
alt={user.display_name || user.username}
title={user.display_name || user.username}
className="shrink-0 rounded-full object-cover ring-2 ring-shell-surface"
style={{ width: size, height: size }}
loading="lazy"
/>
)
}
return (
<span
className="grid shrink-0 place-content-center rounded-full bg-shell-tile text-[10px] font-semibold text-shell-ink ring-2 ring-shell-surface"
style={{ width: size, height: size }}
title={user.display_name || user.username}
>
{initial}
</span>
)
}
export interface MenuOption {
value: string
label: string
/** Swatch shown on the left (label colour, priority colour, …). */
color?: string
hint?: string
}
/**
* Compact popover used by the toolbar filters and the detail sidebar pickers.
* Closes on outside click and Escape; the trigger shows the active choice so a
* filtered board is readable at a glance.
*/
export function FilterMenu({
label,
value,
options,
onChange,
active,
placeholder,
align = 'left',
bare = false,
iconOnly = false,
leading,
extra,
}: {
label: string
value: string
options: MenuOption[]
onChange: (value: string) => void
/** Text shown on the trigger; defaults to the matching option's label. */
active?: string
placeholder?: string
align?: 'left' | 'right'
/** Bordered chip for toolbars vs. a full-width hover row for the detail rail. */
bare?: boolean
/** Ghost icon-only trigger (list rows): no value text, colour dot or chevron. */
iconOnly?: boolean
/** Icon + label pinned to the start of the trigger (rail rows). */
leading?: ReactNode
/** Free-form block under the option list (an inline "new label" row, …). */
extra?: ReactNode
}) {
const { t } = useT()
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement | null>(null)
const trigger = useRef<HTMLButtonElement | null>(null)
const menuId = useId()
const current = options.find((option) => option.value === value)
useEffect(() => {
if (!open) return
const onDown = (event: MouseEvent) => {
if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
}
const frame = requestAnimationFrame(() => {
const option =
box.current?.querySelector<HTMLButtonElement>('[role=option][aria-selected=true]') ??
box.current?.querySelector<HTMLButtonElement>('[role=option]')
option?.focus()
})
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false)
trigger.current?.focus()
}
}
window.addEventListener('mousedown', onDown)
window.addEventListener('keydown', onKey)
return () => {
cancelAnimationFrame(frame)
window.removeEventListener('mousedown', onDown)
window.removeEventListener('keydown', onKey)
}
}, [open])
return (
<div
ref={box}
className="board-filter-menu relative shrink-0"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) setOpen(false)
}}
>
<button
type="button"
ref={trigger}
aria-label={t('issue.filter.triggerAria', {
label,
value: active ?? current?.label ?? placeholder ?? t('issue.board.unset'),
})}
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-haspopup="listbox"
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
setOpen(true)
}
}}
onClick={() => setOpen((v) => !v)}
className={`board-filter-trigger flex h-7 items-center gap-1 rounded-md px-2 text-[12.5px] transition-colors ${
iconOnly ? 'board-filter-trigger-icon' : bare ? 'board-filter-trigger-bare' : ''
} ${
iconOnly
? ''
: value
? 'bg-shell-active text-shell-ink'
: 'bg-shell-field text-shell-ink-muted hover:text-shell-ink'
}`}
>
{leading ? <span className="board-filter-leading flex shrink-0 items-center gap-2">{leading}</span> : null}
{!iconOnly && current?.color ? (
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: current.color }} aria-hidden />
) : null}
{iconOnly ? null : (
<span className="board-filter-trigger-value max-w-[120px] truncate">
{active ?? current?.label ?? placeholder ?? label}
</span>
)}
{iconOnly ? null : <ChevronDown className="board-filter-chevron h-3 w-3 shrink-0 opacity-70" aria-hidden />}
</button>
{open ? (
<div
id={menuId}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
setOpen(false)
trigger.current?.focus()
return
}
const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End']
if (!keys.includes(event.key)) return
event.preventDefault()
const options = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role=option]'))
const currentIndex = options.indexOf(document.activeElement as HTMLButtonElement)
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? options.length - 1
: (currentIndex + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length
options[next]?.focus()
}}
className={`board-filter-popover absolute z-30 mt-1 max-h-[320px] w-[220px] overflow-y-auto rounded-lg border-2 border-shell-hairline bg-shell-surface p-1 shadow-[0_6px_20px_rgb(28_30_34/14%)] ${
align === 'right' ? 'right-0' : 'left-0'
}`}
role="listbox"
aria-label={label}
>
{placeholder ? (
<MenuRow
option={{ value: '', label: placeholder }}
selected={!value}
onPick={(next) => {
onChange(next)
setOpen(false)
trigger.current?.focus()
}}
/>
) : null}
{options.map((option) => (
<MenuRow
key={option.value}
option={option}
selected={option.value === value}
onPick={(next) => {
onChange(next)
setOpen(false)
trigger.current?.focus()
}}
/>
))}
{options.length === 0 ? (
<p className="px-2 py-3 text-center text-[12px] text-shell-ink-muted">{t('issue.board.noOptions')}</p>
) : null}
{extra ? <div className="mt-1 border-t-2 border-shell-hairline p-2">{extra}</div> : null}
</div>
) : null}
</div>
)
}
function MenuRow({
option,
selected,
onPick,
}: {
option: MenuOption
selected: boolean
onPick: (value: string) => void
}) {
return (
<button
type="button"
role="option"
aria-selected={selected}
onClick={() => onPick(option.value)}
className={`board-filter-option flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12.5px] transition-colors hover:bg-shell-field ${
selected ? 'font-semibold text-shell-ink' : 'text-shell-ink-muted'
}`}
>
{option.color ? (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: option.color }} aria-hidden />
) : (
<span className="w-2.5 shrink-0" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.hint ? <span className="shrink-0 text-[11px] text-shell-ink-muted">{option.hint}</span> : null}
{selected ? <Check className="h-3.5 w-3.5 shrink-0 text-shell-ink" aria-hidden /> : null}
</button>
)
}
/** Inline confirm step used by destructive rows (delete label / milestone / issue). */
export function DangerButton({
label,
busy,
onConfirm,
className = '',
}: {
label: string
busy?: boolean
onConfirm: () => void
className?: string
}) {
const { t } = useT()
const [armed, setArmed] = useState(false)
useEffect(() => {
if (!armed) return
const timer = setTimeout(() => setArmed(false), 4000)
return () => clearTimeout(timer)
}, [armed])
return (
<button
type="button"
disabled={busy}
data-armed={armed ? 'true' : undefined}
onClick={() => (armed ? onConfirm() : setArmed(true))}
className={`shrink-0 rounded px-2 py-1 text-[12px] transition-colors disabled:opacity-50 ${
armed ? 'bg-[#e5484d] font-semibold text-white' : 'text-[#b42318] hover:bg-[#e5484d]/10'
} ${className}`}
>
{busy ? t('common.action.working') : armed ? t('issue.board.confirmDiscard') : label}
</button>
)
}