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:
@@ -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). */}
|
||||||
|
|||||||
@@ -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) {
|
}
|
||||||
return (
|
|
||||||
<td
|
export const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||||
ref={ref}
|
function TableCell({ className, hideBelow, ...props }, ref) {
|
||||||
className={cn('px-3 py-2 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
return (
|
||||||
{...props}
|
<td
|
||||||
/>
|
ref={ref}
|
||||||
)
|
className={cn(
|
||||||
})
|
'px-3 py-2 align-middle',
|
||||||
|
hideBelow && HIDE_BELOW[hideBelow],
|
||||||
|
'[&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
export const TableCaption = React.forwardRef<
|
export const TableCaption = React.forwardRef<
|
||||||
HTMLTableCaptionElement,
|
HTMLTableCaptionElement,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user