0acba4c073
Closing handoff gaps выявленных при полном audit'е README.md vs current
implementation. См. screen-by-screen mapping ниже.
== Screen 2 — Editor: WorkflowBanner (NEW) ==
Required by handoff (line 238-242) но не существовал. Inline status row выше
tab bar — live/review/info variants. Backend не имеет explicit dict-level
workflow state (draft → review → live transition), используем proxy:
- approvalRequired=false → 'Live' (green-bg + green left edge)
- approvalRequired=true + 0 → 'Approval enabled' (accent-bg + accent edge)
- approvalRequired=true + N → 'На ревью N' (warn-bg + warn edge), link к /reviews
Backend extension future: добавить explicit workflowState к DictionaryDetail
+ buttons для transitions (request-review / approve / publish). Сейчас banner
дает минимум — visual workflow signal без UI mutation buttons.
== Table accessibility per handoff line 476 ==
TableHeaderCell:
- scope='col' (a11y screen reader requirement)
- aria-sort='ascending|descending|none' когда sortable prop true
- New sortable + sortDirection props (API ready, не используется пока)
== 560px responsive per handoff line 466 ==
TableHeaderCell + TableCell получили optional hideBelow='sm|md|lg' prop:
hideBelow='sm' → hidden <640px (table-cell ≥sm)
hideBelow='md' → hidden <768px
hideBelow='lg' → hidden <1024px
Callsites могут пометить secondary columns (created_by, updated_at, etc.)
hideBelow='md' для clean mobile experience. Текущие routes ещё не используют
prop — API ready для incremental adoption.
== Toast styling per handoff line 302-305 ==
Sonner toast — раньше surface bg + ink text. Handoff:
- Navy bg + on-accent text (warm cream-orange feeling)
- 4s auto-dismiss
- Variant accent через left border 4px:
success → green / error → pink / info → accent / warning → warn
- cap-style title (но используем text-body для нативности, не overkill)
- 13px font-sans
richColors disabled — наши tokens вместо sonner defaults.
Files:
- NEW src/components/editor/WorkflowBanner.tsx
- src/routes/dictionaries.$name.tsx (wire banner above tab bar)
- src/ui/components/table.tsx (a11y + hideBelow API)
- src/ui/components/toaster.tsx (navy variant + variant borders)
- src/ui/index.ts (TableCellProps export)
Tests: 116 pass. TS strict clean.
197 lines
5.6 KiB
TypeScript
197 lines
5.6 KiB
TypeScript
import * as React from 'react'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
/**
|
||
* Table primitives — shadcn-style atomic wrappers. Замена @nstart/ui Table*.
|
||
*
|
||
* <p>Per handoff: table cell text = text-cell (12.5/500). Применяется на
|
||
* <td/th> через CSS rule в `<tbody>` — но проще baked-in via TableCell
|
||
* className. Caller может override через className prop.
|
||
*
|
||
* <p>TableEmpty — single full-width row для empty state.
|
||
*/
|
||
|
||
export const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||
function Table({ className, ...props }, ref) {
|
||
return (
|
||
<div className="w-full overflow-x-auto">
|
||
<table
|
||
ref={ref}
|
||
className={cn('w-full caption-bottom text-cell', className)}
|
||
{...props}
|
||
/>
|
||
</div>
|
||
)
|
||
},
|
||
)
|
||
|
||
export const TableHeader = React.forwardRef<
|
||
HTMLTableSectionElement,
|
||
React.HTMLAttributes<HTMLTableSectionElement>
|
||
>(function TableHeader({ className, ...props }, ref) {
|
||
return (
|
||
<thead
|
||
ref={ref}
|
||
className={cn('[&_tr]:border-b [&_tr]:border-line', className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
})
|
||
// nstart-compat alias: nstart использовал <TableHead> как THEAD wrapper.
|
||
// shadcn по convention использует TableHead как TH cell — это конфликтовало
|
||
// (callsites писали `<TableHead><TableRow>...` ожидая THEAD-семантику).
|
||
// Резолвим в пользу nstart-compat (callsites больше). Для TH cell — TableHeaderCell.
|
||
export const TableHead = TableHeader
|
||
|
||
export const TableBody = React.forwardRef<
|
||
HTMLTableSectionElement,
|
||
React.HTMLAttributes<HTMLTableSectionElement>
|
||
>(function TableBody({ className, ...props }, ref) {
|
||
return (
|
||
<tbody
|
||
ref={ref}
|
||
className={cn('[&_tr:last-child]:border-0', className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
})
|
||
|
||
export const TableFooter = React.forwardRef<
|
||
HTMLTableSectionElement,
|
||
React.HTMLAttributes<HTMLTableSectionElement>
|
||
>(function TableFooter({ className, ...props }, ref) {
|
||
return (
|
||
<tfoot
|
||
ref={ref}
|
||
className={cn(
|
||
'border-t border-line bg-surface-2 font-medium [&>tr]:last:border-b-0',
|
||
className,
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
})
|
||
|
||
export const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||
function TableRow({ className, ...props }, ref) {
|
||
return (
|
||
<tr
|
||
ref={ref}
|
||
className={cn(
|
||
'border-b border-line transition-colors hover:bg-surface-2/40 data-[state=selected]:bg-accent-bg',
|
||
className,
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
},
|
||
)
|
||
|
||
export type TableHeaderCellProps = React.ThHTMLAttributes<HTMLTableCellElement> & {
|
||
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>(
|
||
function TableHeaderCell(
|
||
{ className, align = 'left', hideBelow, sortable, sortDirection, ...props },
|
||
ref,
|
||
) {
|
||
const ariaSort =
|
||
sortDirection === 'asc'
|
||
? 'ascending'
|
||
: sortDirection === 'desc'
|
||
? 'descending'
|
||
: sortable
|
||
? 'none'
|
||
: undefined
|
||
return (
|
||
<th
|
||
ref={ref}
|
||
scope="col"
|
||
aria-sort={ariaSort}
|
||
className={cn(
|
||
'h-9 px-3 align-middle text-cap text-mute',
|
||
align === 'right' && 'text-right',
|
||
align === 'center' && 'text-center',
|
||
align === 'left' && 'text-left',
|
||
hideBelow && HIDE_BELOW[hideBelow],
|
||
'[&:has([role=checkbox])]:pr-0',
|
||
className,
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
},
|
||
)
|
||
|
||
export type TableCellProps = React.TdHTMLAttributes<HTMLTableCellElement> & {
|
||
/** Pair с TableHeaderCell.hideBelow — column скрывается на narrow viewport. */
|
||
hideBelow?: 'sm' | 'md' | 'lg'
|
||
}
|
||
|
||
export const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(
|
||
function TableCell({ className, hideBelow, ...props }, ref) {
|
||
return (
|
||
<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<
|
||
HTMLTableCaptionElement,
|
||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||
>(function TableCaption({ className, ...props }, ref) {
|
||
return (
|
||
<caption
|
||
ref={ref}
|
||
className={cn('mt-3 text-cell text-mute', className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
})
|
||
|
||
/**
|
||
* TableEmpty — full-width row с заглушкой "ничего не найдено".
|
||
* Замена @nstart/ui TableEmptyRow API: <TableEmpty colSpan={N}>{text}</TableEmpty>.
|
||
*/
|
||
export type TableEmptyProps = React.TdHTMLAttributes<HTMLTableCellElement> & {
|
||
colSpan: number
|
||
}
|
||
|
||
export function TableEmpty({ colSpan, className, children, ...props }: TableEmptyProps) {
|
||
return (
|
||
<tr>
|
||
<td
|
||
colSpan={colSpan}
|
||
className={cn('py-10 text-center text-cell text-mute', className)}
|
||
{...props}
|
||
>
|
||
{children}
|
||
</td>
|
||
</tr>
|
||
)
|
||
}
|