Merge branch 'feat/workflow-banner-table-a11y' into 'main'

feat(handoff): WorkflowBanner + table a11y/responsive + toast styling

See merge request 2-6/2-6-4/terravault/ordinis!59
This commit is contained in:
Александр Зимин
2026-05-11 12:58:20 +00:00
5 changed files with 217 additions and 29 deletions
@@ -0,0 +1,135 @@
import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import { ClipboardList, CheckCircle, FileText } from 'lucide-react'
import type { DictionaryDetail } from '@/api/client'
import { cn } from '@/lib/utils'
/**
* WorkflowBanner — status banner для editor (Screen 2 per handoff).
*
* <p>Backend не имеет explicit dict-level workflow state (draft → review →
* live transition). Используем существующие поля как proxy:
* <ul>
* <li><b>Live</b> (default) — approvalRequired=false → published immediately.
* Green-bg, "Опубликовано · v{schemaVersion} · {updatedAt}".</li>
* <li><b>Review pending</b> — approvalRequired=true И есть pending drafts
* (pendingCount > 0). Warn-bg, link to /reviews.</li>
* <li><b>Approval enabled empty</b> — approvalRequired=true но нет drafts.
* Accent-bg, informational "changes require review".</li>
* </ul>
*
* <p>Когда backend добавит explicit workflowState (draft/review/live transition
* на dict-level) — расширим этот компонент. Сейчас фокус UI на approval flow.
*/
export type WorkflowBannerProps = {
detail: DictionaryDetail
/** Pending drafts count для this dict (из useDictPendingDrafts). */
pendingCount?: number
}
export function WorkflowBanner({ detail, pendingCount = 0 }: WorkflowBannerProps) {
const { t } = useTranslation()
const updatedAt = new Date(detail.updatedAt).toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
// === Case 1: Live — no approval required, immediate publish ===
if (!detail.approvalRequired) {
return (
<Banner
variant="live"
icon={<CheckCircle size={16} strokeWidth={2} className="shrink-0" />}
>
<span className="font-medium">
{t('workflow.live.title', { defaultValue: 'Опубликовано' })}
</span>
<span className="text-mono opacity-80">v{detail.schemaVersion}</span>
<span className="text-cell opacity-70">· {updatedAt}</span>
</Banner>
)
}
// === Case 2: Review pending — approval required + drafts queued ===
if (pendingCount > 0) {
return (
<Banner
variant="review"
icon={<ClipboardList size={16} strokeWidth={2} className="shrink-0" />}
action={
<Link
to="/reviews"
className="text-body font-medium text-accent hover:underline shrink-0"
>
{t('workflow.review.open', { defaultValue: 'Открыть /reviews' })}
</Link>
}
>
<span className="font-medium">
{t('workflow.review.title', {
count: pendingCount,
defaultValue: 'На ревью {{count}}',
})}
</span>
<span className="text-cell opacity-70">
{t('workflow.review.subtitle', {
defaultValue: 'правки записей ждут approval',
})}
</span>
</Banner>
)
}
// === Case 3: Approval enabled, no pending ===
return (
<Banner
variant="info"
icon={<FileText size={16} strokeWidth={2} className="shrink-0" />}
>
<span className="font-medium">
{t('workflow.approval.title', { defaultValue: 'Approval workflow' })}
</span>
<span className="text-cell opacity-70">
{t('workflow.approval.subtitle', {
defaultValue: 'правки записей требуют ревью',
})}
</span>
</Banner>
)
}
type BannerProps = {
variant: 'live' | 'review' | 'info' | 'draft'
icon: React.ReactNode
action?: React.ReactNode
children: React.ReactNode
}
const VARIANT_CLASS: Record<BannerProps['variant'], string> = {
live: 'bg-green-bg border-l-green text-ink',
review: 'bg-warn-bg border-l-warn text-ink',
info: 'bg-accent-bg border-l-accent text-ink',
draft: 'bg-warn-bg border-l-warn text-ink',
}
function Banner({ variant, icon, action, children }: BannerProps) {
return (
<div
role="status"
className={cn(
'flex items-center gap-3 px-4 py-2.5 rounded-md border-l-4 text-body',
VARIANT_CLASS[variant],
)}
>
{icon}
<div className="flex items-center gap-2 flex-wrap min-w-0 flex-1">
{children}
</div>
{action}
</div>
)
}
@@ -41,6 +41,7 @@ import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog' import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { RecordDrawer } from '@/components/record/RecordDrawer' import { RecordDrawer } from '@/components/record/RecordDrawer'
import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker' import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
import { nowIsoLocal } from '@/lib/dates' import { nowIsoLocal } from '@/lib/dates'
@@ -578,6 +579,11 @@ function DictionaryDetail() {
} }
/> />
{/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
{detailQuery.data && (
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
)}
{/* Editor tabs per handoff Stage 3.4: {/* Editor tabs per handoff Stage 3.4:
Records | Relations | Fields | JSON | Events | History. Records | Relations | Fields | JSON | Events | History.
URL-synced через ?tab=. Default — records (records table + filters). */} URL-synced через ?tab=. Default — records (records table + filters). */}
+44 -7
View File
@@ -89,18 +89,46 @@ export const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttribut
export type TableHeaderCellProps = React.ThHTMLAttributes<HTMLTableCellElement> & { export type TableHeaderCellProps = React.ThHTMLAttributes<HTMLTableCellElement> & {
align?: 'left' | 'center' | 'right' align?: 'left' | 'center' | 'right'
/** Hide column на narrow viewports per handoff line 466:
* - 'sm' — hide <640px (table cells 6-7 columns secondary)
* - 'md' — hide <768px
* - 'lg' — hide <1024px
* Используется pair с same prop на соответствующих TableCell. */
hideBelow?: 'sm' | 'md' | 'lg'
sortable?: boolean
sortDirection?: 'asc' | 'desc' | 'none'
}
const HIDE_BELOW: Record<NonNullable<TableHeaderCellProps['hideBelow']>, string> = {
sm: 'hidden sm:table-cell',
md: 'hidden md:table-cell',
lg: 'hidden lg:table-cell',
} }
export const TableHeaderCell = React.forwardRef<HTMLTableCellElement, TableHeaderCellProps>( export const TableHeaderCell = React.forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
function TableHeaderCell({ className, align = 'left', ...props }, ref) { function TableHeaderCell(
{ className, align = 'left', hideBelow, sortable, sortDirection, ...props },
ref,
) {
const ariaSort =
sortDirection === 'asc'
? 'ascending'
: sortDirection === 'desc'
? 'descending'
: sortable
? 'none'
: undefined
return ( return (
<th <th
ref={ref} ref={ref}
scope="col"
aria-sort={ariaSort}
className={cn( className={cn(
'h-9 px-3 align-middle text-cap text-mute', 'h-9 px-3 align-middle text-cap text-mute',
align === 'right' && 'text-right', align === 'right' && 'text-right',
align === 'center' && 'text-center', align === 'center' && 'text-center',
align === 'left' && 'text-left', align === 'left' && 'text-left',
hideBelow && HIDE_BELOW[hideBelow],
'[&:has([role=checkbox])]:pr-0', '[&:has([role=checkbox])]:pr-0',
className, className,
)} )}
@@ -110,18 +138,27 @@ export const TableHeaderCell = React.forwardRef<HTMLTableCellElement, TableHeade
}, },
) )
export const TableCell = React.forwardRef< export type TableCellProps = React.TdHTMLAttributes<HTMLTableCellElement> & {
HTMLTableCellElement, /** Pair с TableHeaderCell.hideBelow — column скрывается на narrow viewport. */
React.TdHTMLAttributes<HTMLTableCellElement> hideBelow?: 'sm' | 'md' | 'lg'
>(function TableCell({ className, ...props }, ref) { }
export const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(
function TableCell({ className, hideBelow, ...props }, ref) {
return ( return (
<td <td
ref={ref} ref={ref}
className={cn('px-3 py-2 align-middle [&:has([role=checkbox])]:pr-0', className)} className={cn(
'px-3 py-2 align-middle',
hideBelow && HIDE_BELOW[hideBelow],
'[&:has([role=checkbox])]:pr-0',
className,
)}
{...props} {...props}
/> />
) )
}) },
)
export const TableCaption = React.forwardRef< export const TableCaption = React.forwardRef<
HTMLTableCaptionElement, HTMLTableCaptionElement,
+25 -16
View File
@@ -2,20 +2,22 @@ import { Toaster as SonnerToaster, toast } from 'sonner'
import { useTheme } from '@/stores/ThemeProvider' import { useTheme } from '@/stores/ThemeProvider'
/** /**
* Toast notification provider — wraps sonner с our design tokens. * Toast notification provider — sonner с handoff Toast styling (Screen 4).
* *
* Sonner = headless, кастомизируем через CSS variables. Параметризируется * <p>Handoff spec: navy bg (or Claude-orange в dark theme), "OK" cap header в
* theme="light" | "dark" — берётся из ThemeProvider, авто-switch на смену темы. * toast-cap color (orange-cream), then message text, then × dismiss button.
* Auto-dismiss после 4s. Slide-up bottom-right.
*
* <p>Sonner = headless library. Кастомизируем через CSS overrides — наша
* navy color tokens применяются к bg/text/border.
* *
* Использование: * Использование:
* <pre> * <pre>
* import { toast } from '@/ui/components/toaster' * import { toast } from '@/ui'
* toast.success('Запись сохранена') * toast.success('Запись сохранена')
* toast.error('Ошибка валидации', { description: '...' }) * toast.error('Ошибка валидации', { description: '...' })
* toast.promise(mutateAsync(), {loading, success, error}) * toast.promise(mutateAsync(), {loading, success, error})
* </pre> * </pre>
*
* Mount: <Toaster /> один раз в main.tsx внутри ThemeProvider.
*/ */
export function Toaster() { export function Toaster() {
const { resolved } = useTheme() const { resolved } = useTheme()
@@ -23,30 +25,37 @@ export function Toaster() {
return ( return (
<SonnerToaster <SonnerToaster
position="bottom-right" position="bottom-right"
richColors // richColors=false — наши custom colors из tokens, не sonner default.
closeButton closeButton
theme={resolved} theme={resolved}
duration={4000}
toastOptions={{ toastOptions={{
// Inline styles переопределяют sonner CSS — tokens из @theme. // Handoff toast — navy bg, on-accent text (cream/white), no border.
// На light: surface bg + ink text + line border. // Cap-style "OK / ERROR / INFO" header идёт через title classname.
// На dark: re-uses tokens автомат (другие значения cssvar).
style: { style: {
background: 'var(--color-surface)', background: 'var(--color-navy)',
color: 'var(--color-ink)', color: 'var(--color-on-accent)',
border: '1px solid var(--color-line)', border: 'none',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-lg)',
fontFamily: 'var(--font-sans)', fontFamily: 'var(--font-sans)',
fontSize: '13px', fontSize: '13px',
padding: '12px 16px',
boxShadow: '0 12px 30px -10px rgba(40,25,10,0.38)', boxShadow: '0 12px 30px -10px rgba(40,25,10,0.38)',
}, },
classNames: { classNames: {
title: 'font-medium', // Title всегда выше description; cap-style для статуса/labels.
description: 'text-mute text-cell', title: 'text-body font-medium',
description: 'text-cell opacity-80 mt-0.5',
// success/error/info variants — accent через left border (handoff).
success: '!border-l-4 !border-l-[var(--color-green)]',
error: '!border-l-4 !border-l-[var(--color-pink)]',
info: '!border-l-4 !border-l-[var(--color-accent)]',
warning: '!border-l-4 !border-l-[var(--color-warn)]',
}, },
}} }}
/> />
) )
} }
// Re-export для удобства: `import { toast } from '@/ui/components/toaster'`. // Re-export для удобства: `import { toast } from '@/ui'`.
export { toast } export { toast }
+1
View File
@@ -92,6 +92,7 @@ export {
TableCaption, TableCaption,
TableEmpty, TableEmpty,
type TableHeaderCellProps, type TableHeaderCellProps,
type TableCellProps,
type TableEmptyProps, type TableEmptyProps,
} from './components/table' } from './components/table'