@softize/opus 12.7.1 → 12.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/bin/lib/gen-dicts.mjs +11 -1
- package/bin/lib/gen-runner.mjs +8 -2
- package/bin/lib/materialize.mjs +5 -2
- package/docs/adr/0003-dictionary-presentation-is-declared.md +160 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +31 -10
- package/registry/skills/build-opus-ui/references/evaluations.md +25 -0
- package/registry/skills/build-opus-ui/references/ui-patterns.md +85 -0
- package/registry/skills/implement-opus-change/SKILL.md +4 -3
- package/registry/skills/model-opus-dictionary/SKILL.md +76 -0
- package/registry/skills/model-opus-dictionary/agents/openai.yaml +4 -0
- package/registry/skills/model-opus-dictionary/references/evaluations.md +18 -0
- package/src/core/dictionary.ts +152 -0
- package/src/core/index.ts +18 -0
- package/src/core/logical-type.ts +26 -2
- package/src/core/types.ts +9 -1
- package/src/schema/drivers/zod.ts +46 -13
- package/src/ui/components/patterns/list.tsx +136 -51
- package/src/ui/components/primitives/badge.tsx +3 -0
- package/src/ui/components/primitives/detail.tsx +12 -1
- package/src/ui/components/primitives/dictionary-value.tsx +141 -0
- package/src/ui/components/primitives/empty-value.tsx +50 -0
- package/src/ui/components/primitives/pagination.tsx +86 -56
- package/src/ui/docs/content/action-list-dialog.md +1 -1
- package/src/ui/docs/content/action-list.md +34 -9
- package/src/ui/docs/content/badge.md +6 -3
- package/src/ui/docs/content/detail.md +19 -2
- package/src/ui/docs/content/dictionary-value.md +120 -0
- package/src/ui/docs/content/empty-value.md +48 -0
- package/src/ui/docs/content/pagination.md +54 -17
- package/src/ui/docs/doc-client.tsx +21 -4
- package/src/ui/docs/registry.tsx +6 -0
- package/src/ui/meta.ts +14 -2
- package/src/ui/react.tsx +6 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
|
|
3
3
|
import { cn } from '../../lib/cn.ts'
|
|
4
|
+
import { EmptyValue, isAbsentValue } from './empty-value.tsx'
|
|
4
5
|
|
|
5
6
|
export type DetailGroupVariant = 'plain' | 'framed'
|
|
6
7
|
export type DetailGroupOrientation = 'vertical' | 'horizontal'
|
|
@@ -60,9 +61,13 @@ export interface DetailFieldProps extends Omit<
|
|
|
60
61
|
'children'
|
|
61
62
|
> {
|
|
62
63
|
label: React.ReactNode
|
|
64
|
+
/** `null`, `undefined`, string vazia ou só com espaços renderizam a ausência
|
|
65
|
+
* (“Não informado” ou `empty`); `0` e `false` seguem como valores. */
|
|
63
66
|
value: React.ReactNode
|
|
64
67
|
/** Ícone decorativo que identifica o campo antes do par chave/valor. */
|
|
65
68
|
icon?: React.ReactNode
|
|
69
|
+
/** O que a ausência significa neste campo: um rótulo (“Nunca enviado”) ou um nó próprio. */
|
|
70
|
+
empty?: React.ReactNode
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
function DetailField({
|
|
@@ -70,8 +75,14 @@ function DetailField({
|
|
|
70
75
|
label,
|
|
71
76
|
value,
|
|
72
77
|
icon,
|
|
78
|
+
empty,
|
|
73
79
|
...props
|
|
74
80
|
}: DetailFieldProps): React.ReactElement {
|
|
81
|
+
const content = !isAbsentValue(value)
|
|
82
|
+
? value
|
|
83
|
+
: empty === undefined || typeof empty === 'string'
|
|
84
|
+
? <EmptyValue {...(empty !== undefined ? { label: empty } : {})} />
|
|
85
|
+
: empty
|
|
75
86
|
return (
|
|
76
87
|
<div
|
|
77
88
|
data-slot="detail-field"
|
|
@@ -104,7 +115,7 @@ function DetailField({
|
|
|
104
115
|
data-slot="detail-field-value"
|
|
105
116
|
className="min-w-0 text-sm leading-snug font-normal"
|
|
106
117
|
>
|
|
107
|
-
{
|
|
118
|
+
{content}
|
|
108
119
|
</dd>
|
|
109
120
|
</div>
|
|
110
121
|
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import type { LucideIcon } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
dictionaryDescriptor,
|
|
6
|
+
presentDictionaryValue,
|
|
7
|
+
type DictionaryDescriptor,
|
|
8
|
+
type DictPresentation,
|
|
9
|
+
type DictTone,
|
|
10
|
+
} from '../../../core/index.ts'
|
|
11
|
+
import { cn } from '../../lib/cn.ts'
|
|
12
|
+
import { Badge, type BadgeProps } from './badge.tsx'
|
|
13
|
+
import { iconPickerIcons } from './icon-picker.tsx'
|
|
14
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip.tsx'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fonte aceita pelo renderer: o `DictType` de `t.dict` (tem `meta`), a própria
|
|
18
|
+
* `LogicalTypeMeta` lida do schema, ou um descritor já normalizado.
|
|
19
|
+
*/
|
|
20
|
+
export type DictionaryValueSource =
|
|
21
|
+
| { meta: unknown }
|
|
22
|
+
| { logicalType: string; params?: Record<string, unknown> }
|
|
23
|
+
| DictionaryDescriptor
|
|
24
|
+
|
|
25
|
+
export interface DictionaryValueProps extends Omit<React.ComponentProps<'span'>, 'children'> {
|
|
26
|
+
/** O dicionário (`t.dict`) ou a meta lida do schema do contrato. */
|
|
27
|
+
dict: DictionaryValueSource
|
|
28
|
+
/** O código a apresentar. Vazio renderiza `fallback`. */
|
|
29
|
+
value: string | null | undefined
|
|
30
|
+
/** Sobrepõe o papel declarado no dicionário SÓ nesta ocorrência. */
|
|
31
|
+
presentation?: DictPresentation
|
|
32
|
+
/** Sobrepõe a variante do badge (override explícito). Valor fora do dicionário segue texto. */
|
|
33
|
+
variant?: BadgeProps['variant']
|
|
34
|
+
/** `false` esconde o ícone declarado; um nó substitui o ícone do catálogo. */
|
|
35
|
+
icon?: boolean | React.ReactNode
|
|
36
|
+
/** Catálogo de ícones nome → componente. Default: `iconPickerIcons`. */
|
|
37
|
+
icons?: Record<string, LucideIcon>
|
|
38
|
+
/** `false` suprime o tooltip mesmo com `description` na entrada. */
|
|
39
|
+
tooltip?: boolean
|
|
40
|
+
/** Conteúdo quando `value` é vazio. Default: nada. */
|
|
41
|
+
fallback?: React.ReactNode
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const toneVariant: Record<DictTone, NonNullable<BadgeProps['variant']>> = {
|
|
45
|
+
neutral: 'secondary',
|
|
46
|
+
info: 'info',
|
|
47
|
+
success: 'success',
|
|
48
|
+
warning: 'warning',
|
|
49
|
+
danger: 'danger',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isDescriptor(source: DictionaryValueSource): source is DictionaryDescriptor {
|
|
53
|
+
return 'entries' in source && 'keys' in source
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Renderiza um valor de dicionário com a apresentação que o DICIONÁRIO declarou (ADR 0003):
|
|
58
|
+
* classificação → Badge `outline`; status/estágio → Badge tonal pelo `tone` da entrada
|
|
59
|
+
* (`neutral` sem tom); `plain` ou sem papel → texto. Ícone só quando a entrada declara `icon`
|
|
60
|
+
* do catálogo; tooltip só quando `description` acrescenta ao rótulo. O rótulo é sempre texto —
|
|
61
|
+
* cor e ícone nunca comunicam sozinhos. Valor fora do dicionário mostra o código como texto.
|
|
62
|
+
*/
|
|
63
|
+
export function DictionaryValue({
|
|
64
|
+
dict,
|
|
65
|
+
value,
|
|
66
|
+
presentation,
|
|
67
|
+
variant,
|
|
68
|
+
icon = true,
|
|
69
|
+
icons = iconPickerIcons,
|
|
70
|
+
tooltip = true,
|
|
71
|
+
fallback = null,
|
|
72
|
+
className,
|
|
73
|
+
...props
|
|
74
|
+
}: DictionaryValueProps): React.ReactElement | null {
|
|
75
|
+
if (value === null || value === undefined || value === '') return <>{fallback}</>
|
|
76
|
+
const descriptor = isDescriptor(dict) ? dict : dictionaryDescriptor(dict)
|
|
77
|
+
const resolved = presentDictionaryValue(
|
|
78
|
+
descriptor ?? { keys: [], entries: {} },
|
|
79
|
+
value,
|
|
80
|
+
presentation !== undefined ? { presentation } : {},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const IconComponent = resolved.icon !== null ? icons[resolved.icon] : undefined
|
|
84
|
+
const iconNode =
|
|
85
|
+
icon === false
|
|
86
|
+
? null
|
|
87
|
+
: icon === true
|
|
88
|
+
? IconComponent !== undefined
|
|
89
|
+
? <IconComponent aria-hidden="true" />
|
|
90
|
+
: null
|
|
91
|
+
: icon
|
|
92
|
+
const description = tooltip ? resolved.description : null
|
|
93
|
+
|
|
94
|
+
const dataProps = {
|
|
95
|
+
'data-dictionary-presentation': resolved.presentation,
|
|
96
|
+
'data-dictionary-value': resolved.value,
|
|
97
|
+
...(resolved.known ? {} : { 'data-dictionary-unknown': true }),
|
|
98
|
+
...(description !== null ? { tabIndex: 0 } : {}),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Forma: texto quando o papel não pede badge e ninguém sobrepôs a variante. Valor fora do
|
|
102
|
+
// dicionário é sempre texto — override não promove código desconhecido a badge.
|
|
103
|
+
const badgeVariant: BadgeProps['variant'] = !resolved.known
|
|
104
|
+
? undefined
|
|
105
|
+
: variant ??
|
|
106
|
+
(resolved.badge === null ? undefined : resolved.badge === 'outline' ? 'outline' : toneVariant[resolved.badge])
|
|
107
|
+
|
|
108
|
+
const content =
|
|
109
|
+
badgeVariant === undefined ? (
|
|
110
|
+
<span
|
|
111
|
+
data-slot="dictionary-value"
|
|
112
|
+
className={cn('inline-flex items-center gap-1 [&>svg]:size-3.5 [&>svg]:shrink-0', className)}
|
|
113
|
+
{...dataProps}
|
|
114
|
+
{...props}
|
|
115
|
+
>
|
|
116
|
+
{iconNode}
|
|
117
|
+
{resolved.label}
|
|
118
|
+
</span>
|
|
119
|
+
) : (
|
|
120
|
+
<Badge
|
|
121
|
+
data-slot="dictionary-value"
|
|
122
|
+
variant={badgeVariant}
|
|
123
|
+
className={className}
|
|
124
|
+
{...dataProps}
|
|
125
|
+
{...props}
|
|
126
|
+
>
|
|
127
|
+
{iconNode}
|
|
128
|
+
{resolved.label}
|
|
129
|
+
</Badge>
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if (description === null) return content
|
|
133
|
+
return (
|
|
134
|
+
<TooltipProvider delayDuration={300}>
|
|
135
|
+
<Tooltip>
|
|
136
|
+
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
|
137
|
+
<TooltipContent className="max-w-sm">{description}</TooltipContent>
|
|
138
|
+
</Tooltip>
|
|
139
|
+
</TooltipProvider>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ausência de valor: `null`, `undefined`, string vazia ou só com espaços. `0`, `false` e
|
|
7
|
+
* coleções vazias são valores legítimos e nunca contam como ausência — quem apresenta
|
|
8
|
+
* booleanos e listas decide a própria forma.
|
|
9
|
+
*/
|
|
10
|
+
export function isAbsentValue(value: unknown): boolean {
|
|
11
|
+
return value === null || value === undefined || (typeof value === 'string' && value.trim().length === 0)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EmptyValueProps extends Omit<React.ComponentProps<'span'>, 'children'> {
|
|
15
|
+
/** O que a ausência significa neste domínio. Default: “Não informado”. */
|
|
16
|
+
label?: string
|
|
17
|
+
/** Célula compacta: mostra o travessão e reserva o `label` à leitura assistiva. */
|
|
18
|
+
compact?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Representação padrão de valor ausente. Em texto corrido (DetailField) o rótulo aparece;
|
|
23
|
+
* em célula compacta (`compact`, o default das colunas de ActionList) aparece o travessão
|
|
24
|
+
* e o rótulo vai para o leitor de tela. Um domínio troca o rótulo por `label`
|
|
25
|
+
* (“Nunca enviado”, “Sem vencimento”, “Não se aplica”) em vez de repetir a condicional.
|
|
26
|
+
*/
|
|
27
|
+
export function EmptyValue({
|
|
28
|
+
label = 'Não informado',
|
|
29
|
+
compact = false,
|
|
30
|
+
className,
|
|
31
|
+
...props
|
|
32
|
+
}: EmptyValueProps): React.ReactElement {
|
|
33
|
+
return (
|
|
34
|
+
<span
|
|
35
|
+
data-slot="empty-value"
|
|
36
|
+
data-compact={compact || undefined}
|
|
37
|
+
className={cn('text-muted-foreground', className)}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
{compact ? (
|
|
41
|
+
<>
|
|
42
|
+
<span aria-hidden="true">—</span>
|
|
43
|
+
<span className="sr-only">{label}</span>
|
|
44
|
+
</>
|
|
45
|
+
) : (
|
|
46
|
+
label
|
|
47
|
+
)}
|
|
48
|
+
</span>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -1,117 +1,147 @@
|
|
|
1
|
-
import * as React from
|
|
2
|
-
import {
|
|
3
|
-
ChevronLeftIcon,
|
|
4
|
-
ChevronRightIcon,
|
|
5
|
-
MoreHorizontalIcon,
|
|
6
|
-
} from "lucide-react"
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react'
|
|
7
3
|
|
|
8
4
|
import { cn } from '../../lib/cn.ts'
|
|
9
|
-
import { buttonVariants, type
|
|
5
|
+
import { buttonVariants, type ButtonProps } from './button.tsx'
|
|
10
6
|
|
|
11
|
-
function Pagination({ className, ...props }: React.ComponentProps<
|
|
7
|
+
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
|
12
8
|
return (
|
|
13
9
|
<nav
|
|
14
10
|
role="navigation"
|
|
15
|
-
aria-label="
|
|
11
|
+
aria-label="Paginação"
|
|
16
12
|
data-slot="pagination"
|
|
17
|
-
className={cn(
|
|
13
|
+
className={cn('mx-auto flex w-full justify-center', className)}
|
|
18
14
|
{...props}
|
|
19
15
|
/>
|
|
20
16
|
)
|
|
21
17
|
}
|
|
22
18
|
|
|
23
|
-
function PaginationContent({
|
|
24
|
-
className,
|
|
25
|
-
...props
|
|
26
|
-
}: React.ComponentProps<"ul">) {
|
|
19
|
+
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
|
|
27
20
|
return (
|
|
28
21
|
<ul
|
|
29
22
|
data-slot="pagination-content"
|
|
30
|
-
className={cn(
|
|
23
|
+
className={cn('flex flex-row items-center gap-1', className)}
|
|
31
24
|
{...props}
|
|
32
25
|
/>
|
|
33
26
|
)
|
|
34
27
|
}
|
|
35
28
|
|
|
36
|
-
function PaginationItem({ ...props }: React.ComponentProps<
|
|
29
|
+
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
|
|
37
30
|
return <li data-slot="pagination-item" {...props} />
|
|
38
31
|
}
|
|
39
32
|
|
|
40
|
-
|
|
33
|
+
export interface PaginationLinkProps extends Omit<React.ComponentProps<'a'>, 'children'> {
|
|
34
|
+
/** Página atual: vira `outline` e ganha `aria-current="page"`. */
|
|
41
35
|
isActive?: boolean
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
/** Escala do Button. `default` (números: altura fixa, largura mínima quadrada que cresce
|
|
37
|
+
* com os dígitos) ou uma escala `icon*` (setas quadradas, sem padding horizontal). */
|
|
38
|
+
size?: ButtonProps['size']
|
|
39
|
+
/** Número da página: dá o nome acessível (“Página N”) e o conteúdo, quando `children` falta. */
|
|
40
|
+
page?: number
|
|
41
|
+
/** Sem `href` o link é um `<button>`; `disabled` vale só nesse modo. */
|
|
42
|
+
disabled?: boolean
|
|
43
|
+
children?: React.ReactNode
|
|
44
|
+
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Um link de página. Altura fixa, largura mínima quadrada e largura que cresce com o
|
|
48
|
+
* número (`min-w-9 w-auto px-2`, dígitos tabulares): 5726 e 5727 nunca se sobrepõem. Com
|
|
49
|
+
* `href` é `<a>`; sem `href` é `<button type="button">`, que preserva foco e teclado no
|
|
50
|
+
* modo controlado. Ajuste a escala por `className` (ex.: `h-7 min-w-7 text-xs`).
|
|
51
|
+
*/
|
|
52
|
+
function PaginationLink({ className, isActive, page, disabled, href, size = 'default', children, ...props }: PaginationLinkProps) {
|
|
53
|
+
const square = typeof size === 'string' && size.startsWith('icon')
|
|
54
|
+
const shared = {
|
|
55
|
+
'aria-current': isActive ? ('page' as const) : undefined,
|
|
56
|
+
'aria-label': props['aria-label'] ?? (page !== undefined ? `Página ${page}` : undefined),
|
|
57
|
+
'data-slot': 'pagination-link',
|
|
58
|
+
'data-active': isActive,
|
|
59
|
+
className: cn(
|
|
60
|
+
buttonVariants({ variant: isActive ? 'outline' : 'ghost', size }),
|
|
61
|
+
// Número: a largura mínima é quadrada e o conteúdo manda no resto. Escala `icon*`
|
|
62
|
+
// fica quadrada de verdade (sem `has-[>svg]:px-*` do `default`).
|
|
63
|
+
!square && 'h-9 w-auto min-w-9 px-2 tabular-nums',
|
|
64
|
+
className,
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
const content = children ?? page
|
|
68
|
+
if (href !== undefined) {
|
|
69
|
+
return (
|
|
70
|
+
<a href={href} {...props} {...shared}>
|
|
71
|
+
{content}
|
|
72
|
+
</a>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
const { onClick, ...rest } = props as React.ComponentProps<'button'>
|
|
51
76
|
return (
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
data-active={isActive}
|
|
56
|
-
className={cn(
|
|
57
|
-
buttonVariants({
|
|
58
|
-
variant: isActive ? "outline" : "ghost",
|
|
59
|
-
size,
|
|
60
|
-
}),
|
|
61
|
-
className
|
|
62
|
-
)}
|
|
63
|
-
{...props}
|
|
64
|
-
/>
|
|
77
|
+
<button type="button" disabled={disabled} onClick={onClick} {...rest} {...shared}>
|
|
78
|
+
{content}
|
|
79
|
+
</button>
|
|
65
80
|
)
|
|
66
81
|
}
|
|
67
82
|
|
|
83
|
+
export interface PaginationControlProps extends Omit<PaginationLinkProps, 'page' | 'isActive'> {
|
|
84
|
+
/** Nome acessível e texto visível. Default: “Página anterior” / “Próxima página”. */
|
|
85
|
+
label?: string
|
|
86
|
+
/** Só a seta, quadrada (escala `icon`, `size-9`); o `label` continua como nome acessível. */
|
|
87
|
+
iconOnly?: boolean
|
|
88
|
+
/** Classe do ícone (ex.: `size-3.5` na escala densa). Vai no próprio svg, porque o
|
|
89
|
+
* fallback `[&_svg:not([class*='size-'])]:size-4` do Button vence qualquer seletor de fora. */
|
|
90
|
+
iconClassName?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
68
93
|
function PaginationPrevious({
|
|
69
94
|
className,
|
|
95
|
+
label = 'Página anterior',
|
|
96
|
+
iconOnly = false,
|
|
97
|
+
iconClassName,
|
|
98
|
+
size,
|
|
70
99
|
...props
|
|
71
|
-
}:
|
|
100
|
+
}: PaginationControlProps) {
|
|
72
101
|
return (
|
|
73
102
|
<PaginationLink
|
|
74
|
-
aria-label=
|
|
75
|
-
size=
|
|
76
|
-
className={cn(
|
|
103
|
+
aria-label={label}
|
|
104
|
+
size={size ?? (iconOnly ? 'icon' : 'default')}
|
|
105
|
+
className={cn(!iconOnly && 'gap-1 px-2.5 sm:pl-2.5', className)}
|
|
77
106
|
{...props}
|
|
78
107
|
>
|
|
79
|
-
<ChevronLeftIcon />
|
|
80
|
-
<span className="hidden sm:block">
|
|
108
|
+
<ChevronLeftIcon className={iconClassName} />
|
|
109
|
+
{!iconOnly && <span className="hidden sm:block">{label}</span>}
|
|
81
110
|
</PaginationLink>
|
|
82
111
|
)
|
|
83
112
|
}
|
|
84
113
|
|
|
85
114
|
function PaginationNext({
|
|
86
115
|
className,
|
|
116
|
+
label = 'Próxima página',
|
|
117
|
+
iconOnly = false,
|
|
118
|
+
iconClassName,
|
|
119
|
+
size,
|
|
87
120
|
...props
|
|
88
|
-
}:
|
|
121
|
+
}: PaginationControlProps) {
|
|
89
122
|
return (
|
|
90
123
|
<PaginationLink
|
|
91
|
-
aria-label=
|
|
92
|
-
size=
|
|
93
|
-
className={cn(
|
|
124
|
+
aria-label={label}
|
|
125
|
+
size={size ?? (iconOnly ? 'icon' : 'default')}
|
|
126
|
+
className={cn(!iconOnly && 'gap-1 px-2.5 sm:pr-2.5', className)}
|
|
94
127
|
{...props}
|
|
95
128
|
>
|
|
96
|
-
<span className="hidden sm:block">
|
|
97
|
-
<ChevronRightIcon />
|
|
129
|
+
{!iconOnly && <span className="hidden sm:block">{label}</span>}
|
|
130
|
+
<ChevronRightIcon className={iconClassName} />
|
|
98
131
|
</PaginationLink>
|
|
99
132
|
)
|
|
100
133
|
}
|
|
101
134
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
...props
|
|
105
|
-
}: React.ComponentProps<"span">) {
|
|
135
|
+
/** Decorativa: `aria-hidden`; os números ao redor já dizem que há páginas no vão. */
|
|
136
|
+
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
|
106
137
|
return (
|
|
107
138
|
<span
|
|
108
139
|
aria-hidden
|
|
109
140
|
data-slot="pagination-ellipsis"
|
|
110
|
-
className={cn(
|
|
141
|
+
className={cn('flex size-9 items-center justify-center', className)}
|
|
111
142
|
{...props}
|
|
112
143
|
>
|
|
113
144
|
<MoreHorizontalIcon className="size-4" />
|
|
114
|
-
<span className="sr-only">More pages</span>
|
|
115
145
|
</span>
|
|
116
146
|
)
|
|
117
147
|
}
|
|
@@ -27,7 +27,7 @@ render(
|
|
|
27
27
|
{workspaces.map((w) => (
|
|
28
28
|
<div key={w.id} className="flex items-center gap-2 rounded-md border border-border p-3">
|
|
29
29
|
<span className="text-sm font-semibold">{w.name}</span>
|
|
30
|
-
<
|
|
30
|
+
<DictionaryValue dict={docWorkspaceStatus} value={w.status} />
|
|
31
31
|
<span className="ml-auto text-xs text-muted-foreground">{w.agents} agentes</span>
|
|
32
32
|
</div>
|
|
33
33
|
))}
|
|
@@ -28,16 +28,37 @@ superfícies que não são listagens, como relatórios. O estado é controlado p
|
|
|
28
28
|
/>
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Selects inline têm largura fixa e previsível (`w-40`; lookup e múltiplo `w-52`): nem a label nem
|
|
32
|
+
o valor selecionado alargam o controle — o rótulo trunca e a opção inteira continua na lista. No
|
|
33
|
+
modal de filtros avançados, o select ocupa a largura toda. Busca e filtros são renderizados
|
|
34
|
+
somente quando declarados. Períodos incluem os presets do
|
|
32
35
|
contrato e o intervalo personalizado no mesmo calendário usado pela `ActionList`.
|
|
33
36
|
|
|
37
|
+
## Colunas de dicionário
|
|
38
|
+
|
|
39
|
+
Coluna cujo campo no schema de saída é um `t.dict().zod()` renderiza `DictionaryValue` com a
|
|
40
|
+
apresentação que o dicionário declarou (`presentation`: classificação em badge `outline`, status
|
|
41
|
+
e estágio em badge tonal, `plain` ou ausente como texto). Nada a configurar na tela. Quando o
|
|
42
|
+
campo de saída é uma string comum, a coluna nomeia o dicionário registrado no provider:
|
|
43
|
+
`{ key: 'source', label: 'Fonte', dictionary: 'customerSource' }`. Dimensões independentes, como
|
|
44
|
+
tipo e estágio, ficam em colunas distintas — a leitura de comparação depende disso. `type:
|
|
45
|
+
'badge'` continua sendo o chip `outline` legado; com dicionário, ele mostra o rótulo em vez do
|
|
46
|
+
código.
|
|
47
|
+
|
|
48
|
+
## Valor ausente
|
|
49
|
+
|
|
50
|
+
Célula escalar com `null`, `undefined`, string vazia ou só espaços mostra o travessão e “Não
|
|
51
|
+
informado” à leitura assistiva, sem condicional na tela. `0` e `false` são valores; array e objeto
|
|
52
|
+
sem `cells` continuam sem representação padrão, porque a forma de uma coleção é de quem a
|
|
53
|
+
apresenta. A coluna do contrato
|
|
54
|
+
troca o significado com `empty`: `{ key: 'sentAt', label: 'Último envio', empty: 'Nunca enviado' }`.
|
|
55
|
+
Renderer customizado reutiliza `EmptyValue`.
|
|
56
|
+
|
|
34
57
|
## Células custom (cells)
|
|
35
58
|
|
|
36
|
-
As colunas do contrato descrevem DADOS; apresentação especial entra por cima com `cells` (chave = key da coluna). É onde vivem
|
|
59
|
+
As colunas do contrato descrevem DADOS; apresentação especial entra por cima com `cells` (chave = key da coluna). É onde vivem links, composições e a coluna de ações; valor de dicionário já resolve sozinho pela coluna, sem `cells`.
|
|
37
60
|
|
|
38
61
|
```tsx preview col
|
|
39
|
-
const STATUS_VARIANT = { active: 'success', onboarding: 'warning', paused: 'outline' }
|
|
40
|
-
|
|
41
62
|
render(
|
|
42
63
|
<DocBrowserActionProvider>
|
|
43
64
|
<div className="w-full">
|
|
@@ -45,7 +66,11 @@ render(
|
|
|
45
66
|
action={docWorkspaceList}
|
|
46
67
|
input={{}}
|
|
47
68
|
cells={{
|
|
48
|
-
|
|
69
|
+
name: (w) => (
|
|
70
|
+
<a href="#" onClick={(e) => e.preventDefault()} className="font-medium underline-offset-4 hover:underline">
|
|
71
|
+
{w.name}
|
|
72
|
+
</a>
|
|
73
|
+
),
|
|
49
74
|
}}
|
|
50
75
|
/>
|
|
51
76
|
</div>
|
|
@@ -68,7 +93,7 @@ periods: [
|
|
|
68
93
|
|
|
69
94
|
## Paginação e loading
|
|
70
95
|
|
|
71
|
-
O pattern manda `limit` (= `pageSize`, default 50) e `page` no input; o handler implementa o OFFSET e devolve `total` no Paginated. O rodapé (Página X de Y · páginas numeradas com reticências · "N itens no total") aparece sempre que há total; mudar filtro/busca/sort/período volta pra página 1 (a página vive na URL: `?page=2`). No primeiro carregamento, `DataState` centraliza o spinner; no refetch com a lista já na tela, o corpo esmaece (`aria-busy`) até os dados chegarem.
|
|
96
|
+
O pattern manda `limit` (= `pageSize`, default 50) e `page` no input; o handler implementa o OFFSET e devolve `total` no Paginated. O rodapé (Página X de Y · páginas numeradas com reticências · "N itens no total") compõe a primitiva `Pagination` na escala densa — números com largura mínima que cresce com os dígitos, setas quadradas, nome acessível por página — e aparece sempre que há total; mudar filtro/busca/sort/período volta pra página 1 (a página vive na URL: `?page=2`). No primeiro carregamento, `DataState` centraliza o spinner; no refetch com a lista já na tela, o corpo esmaece (`aria-busy`) até os dados chegarem.
|
|
72
97
|
|
|
73
98
|
## Multi-seleção com can (batch)
|
|
74
99
|
|
|
@@ -106,7 +131,7 @@ render(
|
|
|
106
131
|
<div key={w.id} className="rounded-lg border border-border p-3">
|
|
107
132
|
<div className="flex items-center gap-2">
|
|
108
133
|
<span className="text-sm font-semibold">{w.name}</span>
|
|
109
|
-
<
|
|
134
|
+
<DictionaryValue dict={docWorkspaceStatus} value={w.status} />
|
|
110
135
|
</div>
|
|
111
136
|
<p className="mt-1 text-xs text-muted-foreground">{w.agents} agentes.</p>
|
|
112
137
|
</div>
|
|
@@ -171,7 +196,7 @@ render(
|
|
|
171
196
|
<div key={w.id} className="rounded-lg border border-border p-3">
|
|
172
197
|
<div className="flex items-center gap-2">
|
|
173
198
|
<span className="text-sm font-semibold">{w.name}</span>
|
|
174
|
-
<
|
|
199
|
+
<DictionaryValue dict={docWorkspaceStatus} value={w.status} />
|
|
175
200
|
</div>
|
|
176
201
|
<p className="mt-1 text-xs text-muted-foreground">{w.agents} agentes.</p>
|
|
177
202
|
</div>
|
|
@@ -188,7 +213,7 @@ render(
|
|
|
188
213
|
|
|
189
214
|
| Chave | O que declara |
|
|
190
215
|
|---|---|
|
|
191
|
-
| `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat? }` — a tabela. `hidden` fica fora (base do futuro column picker). |
|
|
216
|
+
| `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat?, dictionary?, empty? }` — a tabela. `hidden` fica fora (base do futuro column picker); `dictionary` nomeia o dicionário do provider; `empty` dá o significado da ausência. |
|
|
192
217
|
| `filters` | `{ [nome]: { label, type, options?, multiple?, advanced?, depends?, … } }` — a toolbar. `advanced` vai pro modal; `depends` desabilita/cascateia; options `kind: 'lookup'` busca numa action. Valor aplicado entra no input com o MESMO nome. |
|
|
193
218
|
| `text` | `{ fields }` — liga a busca; convenção: param `q` no input. Com período/filtros no contrato ela fica à direita; sendo a ÚNICA forma de recorte, abre a linha. |
|
|
194
219
|
| `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
|
|
@@ -12,13 +12,16 @@ Button.
|
|
|
12
12
|
|
|
13
13
|
## Tons de status da casa
|
|
14
14
|
|
|
15
|
-
success/warning/info (fill tingido + borda translúcida) — convenção da casa pra estado
|
|
16
|
-
igual ao KindBadge do Maestro. Não-interativos.
|
|
15
|
+
success/warning/info/danger (fill tingido + borda translúcida) — convenção da casa pra estado
|
|
16
|
+
semântico, igual ao KindBadge do Maestro. Não-interativos. `danger` é o tom tonal da família;
|
|
17
|
+
`destructive` continua sólido, pra alerta e ação. Valor de dicionário com papel declarado usa
|
|
18
|
+
`DictionaryValue`, que escolhe o tom pela metadata em vez de repetir esta escolha em cada tela.
|
|
17
19
|
|
|
18
20
|
```tsx preview
|
|
19
21
|
<Badge variant="success">Ativa</Badge>
|
|
20
22
|
<Badge variant="warning">Aguardando revisor</Badge>
|
|
21
23
|
<Badge variant="info">Em sessão</Badge>
|
|
24
|
+
<Badge variant="danger">Bloqueada</Badge>
|
|
22
25
|
```
|
|
23
26
|
|
|
24
27
|
## Com ícone
|
|
@@ -44,5 +47,5 @@ variantes.
|
|
|
44
47
|
|
|
45
48
|
| Prop | Tipo | Default | Descrição |
|
|
46
49
|
|---|---|---|---|
|
|
47
|
-
| `variant` | `'default' \| 'secondary' \| 'destructive' \| 'outline' \| 'success' \| 'warning' \| 'info'` | `'default'` | Intenção (default/secondary/destructive/outline) ou tom de status da casa (success/warning/info). |
|
|
50
|
+
| `variant` | `'default' \| 'secondary' \| 'destructive' \| 'outline' \| 'success' \| 'warning' \| 'info' \| 'danger'` | `'default'` | Intenção (default/secondary/destructive/outline) ou tom de status da casa (success/warning/info/danger). |
|
|
48
51
|
| `asChild` | `boolean` | `false` | Renderiza como o filho (Radix Slot) em vez de `<span>` — ex.: um `<a>` com cara de badge. |
|
|
@@ -6,8 +6,13 @@ para entrada e validação; use DetailField quando a pessoa apenas consulta um v
|
|
|
6
6
|
<DetailGroup columns={2}>
|
|
7
7
|
<DetailField label="Cliente" value="João Silva" />
|
|
8
8
|
<DetailField
|
|
9
|
-
label="
|
|
10
|
-
value={
|
|
9
|
+
label="Estágio"
|
|
10
|
+
value={
|
|
11
|
+
<DictionaryValue
|
|
12
|
+
dict={{ keys: ['prospect', 'customer'], entries: { prospect: { label: 'Prospect' }, customer: { label: 'Cliente', tone: 'success' } }, presentation: 'stage' }}
|
|
13
|
+
value="prospect"
|
|
14
|
+
/>
|
|
15
|
+
}
|
|
11
16
|
/>
|
|
12
17
|
<DetailField
|
|
13
18
|
className="sm:col-span-2"
|
|
@@ -18,6 +23,18 @@ para entrada e validação; use DetailField quando a pessoa apenas consulta um v
|
|
|
18
23
|
</DetailGroup>
|
|
19
24
|
```
|
|
20
25
|
|
|
26
|
+
## Valor ausente
|
|
27
|
+
|
|
28
|
+
`null`, `undefined`, string vazia ou só com espaços mostram “Não informado”; `empty` troca o
|
|
29
|
+
significado no domínio. `0` e `false` seguem como valores. Ver `EmptyValue`.
|
|
30
|
+
|
|
31
|
+
```tsx preview col
|
|
32
|
+
<DetailGroup columns={2}>
|
|
33
|
+
<DetailField label="Telefone" value={null} />
|
|
34
|
+
<DetailField label="Último envio" value="" empty="Nunca enviado" />
|
|
35
|
+
</DetailGroup>
|
|
36
|
+
```
|
|
37
|
+
|
|
21
38
|
## Moldura e divisórias internas
|
|
22
39
|
|
|
23
40
|
`variant="framed"` adiciona a superfície e a borda externa. `dividers` desenha apenas as
|