@softize/opus 13.1.0 → 15.0.0
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 +97 -0
- package/bin/cli.mjs +2 -0
- package/bin/lib/check.mjs +33 -5
- package/bin/lib/cli-shared.mjs +30 -1
- package/bin/lib/copy.mjs +279 -6
- package/bin/lib/db.mjs +2 -0
- package/docs/adr/0004-page-content-state-is-composed.md +39 -5
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +9 -3
- package/docs/adr/0009-page-title-does-not-carry-a-counter.md +57 -0
- package/docs/adr/0010-page-header-owns-page-chrome.md +73 -0
- package/docs/code-style.md +4 -1
- package/docs/data-layer.md +9 -0
- package/package.json +1 -1
- package/registry/instructions/opus.md +3 -3
- package/registry/skills/build-opus-ui/SKILL.md +3 -2
- package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
- package/registry/templates/app/src/App.tsx +11 -6
- package/src/core/types.ts +3 -4
- package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
- package/src/ui/components/patterns/confirm.tsx +2 -31
- package/src/ui/components/patterns/content-header.tsx +42 -141
- package/src/ui/components/patterns/data-state.tsx +42 -68
- package/src/ui/components/patterns/form.tsx +15 -15
- package/src/ui/components/patterns/list.tsx +55 -34
- package/src/ui/components/patterns/page-state.tsx +81 -51
- package/src/ui/components/patterns/page.tsx +228 -97
- package/src/ui/components/patterns/state-surface.tsx +262 -0
- package/src/ui/components/patterns/surface-header.tsx +204 -0
- package/src/ui/components/patterns/trigger.tsx +9 -10
- package/src/ui/components/patterns/view.tsx +14 -16
- package/src/ui/components/primitives/alert.tsx +1 -33
- package/src/ui/components/primitives/avatar.tsx +15 -5
- package/src/ui/components/primitives/badge.tsx +2 -43
- package/src/ui/components/primitives/button-group.tsx +34 -8
- package/src/ui/components/primitives/button.tsx +31 -35
- package/src/ui/components/primitives/control.ts +69 -0
- package/src/ui/components/primitives/dot.tsx +1 -30
- package/src/ui/components/primitives/input-group.tsx +11 -8
- package/src/ui/components/primitives/item.tsx +3 -1
- package/src/ui/components/primitives/menu.tsx +1 -7
- package/src/ui/components/primitives/pagination.tsx +16 -8
- package/src/ui/components/primitives/select.tsx +2 -2
- package/src/ui/components/primitives/spinner.tsx +13 -16
- package/src/ui/components/primitives/switch.tsx +4 -1
- package/src/ui/components/primitives/tabs.tsx +5 -3
- package/src/ui/components/primitives/toggle.tsx +9 -4
- package/src/ui/docs/content/action-form.md +26 -0
- package/src/ui/docs/content/action-list-dialog.md +2 -2
- package/src/ui/docs/content/action-list.md +35 -2
- package/src/ui/docs/content/action-trigger.md +5 -4
- package/src/ui/docs/content/action-view.md +3 -2
- package/src/ui/docs/content/alert.md +16 -4
- package/src/ui/docs/content/avatar.md +7 -3
- package/src/ui/docs/content/button.md +48 -17
- package/src/ui/docs/content/communication.md +36 -0
- package/src/ui/docs/content/content.md +5 -4
- package/src/ui/docs/content/data-state.md +17 -13
- package/src/ui/docs/content/dialog.md +1 -4
- package/src/ui/docs/content/input.md +1 -1
- package/src/ui/docs/content/item.md +1 -1
- package/src/ui/docs/content/page.md +160 -37
- package/src/ui/docs/content/pagination.md +11 -9
- package/src/ui/docs/content/semantic-context.md +3 -2
- package/src/ui/docs/content/sidebar.md +2 -42
- package/src/ui/docs/content/spinner.md +9 -6
- package/src/ui/docs/content/switch.md +1 -1
- package/src/ui/docs/content/tabs.md +1 -1
- package/src/ui/docs/content/toggle.md +1 -1
- package/src/ui/drivers/react.tsx +1 -6
- package/src/ui/meta.ts +8 -8
- package/src/ui/react.tsx +16 -18
- package/src/ui/components/patterns/shell-nav.tsx +0 -154
|
@@ -3,38 +3,44 @@ import { Slot } from '@radix-ui/react-slot'
|
|
|
3
3
|
|
|
4
4
|
import type { UiContext } from '../../../core/ui-context.ts'
|
|
5
5
|
import { cn } from '../../lib/cn.ts'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
controlBase,
|
|
8
|
+
controlGlyphClass,
|
|
9
|
+
controlHeight,
|
|
10
|
+
controlSquare,
|
|
11
|
+
focusRing,
|
|
12
|
+
type ControlScale,
|
|
13
|
+
type ControlShape,
|
|
14
|
+
} from './control.ts'
|
|
7
15
|
import { Spinner } from './spinner.tsx'
|
|
8
16
|
|
|
9
17
|
export type ButtonContext = Extract<UiContext, 'neutral' | 'primary' | 'danger'>
|
|
10
18
|
export type ButtonVariant = 'solid' | 'subtle' | 'outline' | 'ghost' | 'link'
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
* `context="neutral"`, `destructive` → `context="danger"` (todos com `variant="solid"`).
|
|
14
|
-
* Declare `context` + `variant`.
|
|
15
|
-
*/
|
|
16
|
-
export type LegacyButtonVariant = 'default' | 'secondary' | 'destructive'
|
|
17
|
-
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-xs'
|
|
19
|
+
/** A escala completa de control.ts: alturas de linha e quadrados só-ícone. */
|
|
20
|
+
export type ButtonSize = ControlScale
|
|
18
21
|
|
|
19
22
|
export interface ButtonStyleOptions {
|
|
20
23
|
/** Sem `context`, `solid`/`subtle` assumem `primary`; `outline`/`ghost`/`link` assumem `neutral`. */
|
|
21
24
|
context?: ButtonContext | null
|
|
22
|
-
/** Tratamento visual.
|
|
23
|
-
variant?: ButtonVariant |
|
|
25
|
+
/** Tratamento visual. */
|
|
26
|
+
variant?: ButtonVariant | null
|
|
24
27
|
size?: ButtonSize | null
|
|
25
28
|
shape?: ControlShape | null
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
const baseClasses =
|
|
29
|
-
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all disabled:pointer-events-none disabled:opacity-50 active:brightness-90 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 [&_svg]:pointer-events-none [&_svg]:shrink-0
|
|
32
|
+
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all disabled:pointer-events-none disabled:opacity-50 active:brightness-90 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 [&_svg]:pointer-events-none [&_svg]:shrink-0'
|
|
30
33
|
|
|
34
|
+
/** Padding e ritmo por tamanho; altura e quadrado vêm da escala compartilhada. */
|
|
31
35
|
const sizeClasses: Record<ButtonSize, string> = {
|
|
32
|
-
|
|
33
|
-
sm:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'icon-
|
|
37
|
-
'icon-
|
|
36
|
+
xs: `${controlHeight.xs} gap-1 px-2 text-xs has-[>svg]:px-1.5`,
|
|
37
|
+
sm: `${controlHeight.sm} gap-1.5 px-3 has-[>svg]:px-2.5`,
|
|
38
|
+
default: `${controlHeight.default} px-4 py-2 has-[>svg]:px-3`,
|
|
39
|
+
lg: `${controlHeight.lg} px-6 has-[>svg]:px-4`,
|
|
40
|
+
'icon-xs': controlSquare['icon-xs'],
|
|
41
|
+
'icon-sm': controlSquare['icon-sm'],
|
|
42
|
+
icon: controlSquare.icon,
|
|
43
|
+
'icon-lg': controlSquare['icon-lg'],
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
const solidClasses: Record<ButtonContext, string> = {
|
|
@@ -71,13 +77,6 @@ export function resolveButtonStyle({ context, variant }: ButtonStyleOptions = {}
|
|
|
71
77
|
context: ButtonContext
|
|
72
78
|
variant: ButtonVariant
|
|
73
79
|
} {
|
|
74
|
-
const legacy = variant === 'default' || variant === 'secondary' || variant === 'destructive'
|
|
75
|
-
if (context != null && legacy) {
|
|
76
|
-
throw new Error('Button não permite combinar context com uma variante semântica legada.')
|
|
77
|
-
}
|
|
78
|
-
if (variant === 'default') return { context: 'primary', variant: 'solid' }
|
|
79
|
-
if (variant === 'secondary') return { context: 'neutral', variant: 'solid' }
|
|
80
|
-
if (variant === 'destructive') return { context: 'danger', variant: 'solid' }
|
|
81
80
|
const resolvedVariant: ButtonVariant = variant ?? 'solid'
|
|
82
81
|
// Tratamentos de superfície (solid/subtle) pedem destaque; os leves (outline/ghost/link)
|
|
83
82
|
// são o chrome discreto da casa e só ganham cor quando o contexto é declarado.
|
|
@@ -87,8 +86,8 @@ export function resolveButtonStyle({ context, variant }: ButtonStyleOptions = {}
|
|
|
87
86
|
}
|
|
88
87
|
|
|
89
88
|
/**
|
|
90
|
-
* Classes do Button. `context` seleciona a família semântica
|
|
91
|
-
*
|
|
89
|
+
* Classes do Button. `context` seleciona a família semântica, `variant` o tratamento visual e
|
|
90
|
+
* `size` a medida na escala compartilhada de control.ts.
|
|
92
91
|
*/
|
|
93
92
|
export function buttonVariants(options: ButtonStyleOptions = {}): string {
|
|
94
93
|
const resolved = resolveButtonStyle(options)
|
|
@@ -107,6 +106,7 @@ export function buttonVariants(options: ButtonStyleOptions = {}): string {
|
|
|
107
106
|
focusRing,
|
|
108
107
|
variantClasses,
|
|
109
108
|
sizeClasses[options.size ?? 'default'],
|
|
109
|
+
controlGlyphClass(options.size),
|
|
110
110
|
options.shape === 'pill' && 'rounded-full',
|
|
111
111
|
)
|
|
112
112
|
}
|
|
@@ -116,8 +116,9 @@ export interface ButtonProps extends React.ComponentProps<'button'>, ButtonStyle
|
|
|
116
116
|
asChild?: boolean
|
|
117
117
|
/** Estado de ação em andamento; mostra Spinner e desabilita o controle. */
|
|
118
118
|
busy?: boolean
|
|
119
|
-
/** Ícone à esquerda
|
|
120
|
-
|
|
119
|
+
/** Ícone à esquerda, como nó (`icon={<Plus />}`); o tamanho do glifo vem do `size`. No
|
|
120
|
+
* `busy`, o Spinner ocupa o mesmo espaço. */
|
|
121
|
+
icon?: React.ReactNode
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
export function Button({
|
|
@@ -128,14 +129,13 @@ export function Button({
|
|
|
128
129
|
shape,
|
|
129
130
|
asChild = false,
|
|
130
131
|
busy = false,
|
|
131
|
-
icon
|
|
132
|
+
icon,
|
|
132
133
|
disabled,
|
|
133
134
|
children,
|
|
134
135
|
...props
|
|
135
136
|
}: ButtonProps): React.ReactElement {
|
|
136
137
|
const Comp = asChild ? Slot : 'button'
|
|
137
138
|
const resolved = resolveButtonStyle({ context, variant })
|
|
138
|
-
const iconSize = size === 'sm' ? 'size-3' : 'size-4'
|
|
139
139
|
return (
|
|
140
140
|
<Comp
|
|
141
141
|
data-slot="button"
|
|
@@ -149,11 +149,7 @@ export function Button({
|
|
|
149
149
|
children
|
|
150
150
|
) : (
|
|
151
151
|
<>
|
|
152
|
-
{busy ? (
|
|
153
|
-
<Spinner size={size === 'sm' ? 'sm' : 'default'} />
|
|
154
|
-
) : Icon ? (
|
|
155
|
-
<Icon className={iconSize} />
|
|
156
|
-
) : null}
|
|
152
|
+
{busy ? <Spinner size={controlBase(size)} /> : icon}
|
|
157
153
|
{children}
|
|
158
154
|
</>
|
|
159
155
|
)}
|
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
/** Geometria compartilhada pelos controles inline da casa. */
|
|
2
2
|
export type ControlShape = 'default' | 'pill'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A escala única de `size` dos controles (14.0.0). Um nome, uma medida — em qualquer
|
|
6
|
+
* componente que aceite `size`, `sm` é a mesma altura de linha e `icon-sm` é o mesmo quadrado.
|
|
7
|
+
* Cada componente aceita o subconjunto coerente com seu papel, nunca um nome com outra medida.
|
|
8
|
+
*
|
|
9
|
+
* - Altura de linha (`xs` · `sm` · `default` · `lg`): 1.5 · 2 · 2.25 · 2.5rem.
|
|
10
|
+
* - Quadrado só-ícone (`icon-xs` · `icon-sm` · `icon` · `icon-lg`): 1.5 · 1.75 · 2.25 · 2.5rem.
|
|
11
|
+
* `icon-xs` é o degrau da ação que mora dentro de uma linha densa. `icon-sm` atende
|
|
12
|
+
* composições compactas que ainda precisam de mais presença.
|
|
13
|
+
* - Glifo (o svg dentro do controle daquele tamanho): 0.875 · 0.875 · 1 · 1.25rem. O Spinner
|
|
14
|
+
* usa esta medida, por isso `busy` substitui o ícone sem mexer na largura do botão.
|
|
15
|
+
*/
|
|
16
|
+
export type ControlSize = 'xs' | 'sm' | 'default' | 'lg'
|
|
17
|
+
export type ControlIconSize = 'icon-xs' | 'icon-sm' | 'icon' | 'icon-lg'
|
|
18
|
+
export type ControlScale = ControlSize | ControlIconSize
|
|
19
|
+
|
|
20
|
+
export const controlHeight: Record<ControlSize, string> = {
|
|
21
|
+
xs: 'h-6',
|
|
22
|
+
sm: 'h-8',
|
|
23
|
+
default: 'h-9',
|
|
24
|
+
lg: 'h-10',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const controlSquare: Record<ControlIconSize, string> = {
|
|
28
|
+
'icon-xs': 'size-6',
|
|
29
|
+
'icon-sm': 'size-7',
|
|
30
|
+
icon: 'size-9',
|
|
31
|
+
'icon-lg': 'size-10',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const controlGlyph: Record<ControlSize, string> = {
|
|
35
|
+
xs: 'size-3.5',
|
|
36
|
+
sm: 'size-3.5',
|
|
37
|
+
default: 'size-4',
|
|
38
|
+
lg: 'size-5',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// As classes completas precisam permanecer literais para o scanner do Tailwind gerar os seletores.
|
|
42
|
+
// Montar `]:${controlGlyph[...]}` em runtime devolve o nome certo no DOM, mas deixa o CSS ausente.
|
|
43
|
+
const controlGlyphSelector: Record<ControlSize, string> = {
|
|
44
|
+
xs: '[&_svg:not([class*=size-])]:size-3.5',
|
|
45
|
+
sm: '[&_svg:not([class*=size-])]:size-3.5',
|
|
46
|
+
default: '[&_svg:not([class*=size-])]:size-4',
|
|
47
|
+
lg: '[&_svg:not([class*=size-])]:size-5',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A altura de linha por trás de um tamanho da escala (`icon-sm` → `sm`). */
|
|
51
|
+
export function controlBase(size: ControlScale | null | undefined): ControlSize {
|
|
52
|
+
switch (size) {
|
|
53
|
+
case 'icon-xs':
|
|
54
|
+
return 'xs'
|
|
55
|
+
case 'icon-sm':
|
|
56
|
+
return 'sm'
|
|
57
|
+
case 'icon-lg':
|
|
58
|
+
return 'lg'
|
|
59
|
+
case 'icon':
|
|
60
|
+
case null:
|
|
61
|
+
case undefined:
|
|
62
|
+
return 'default'
|
|
63
|
+
default:
|
|
64
|
+
return size
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Fallback de tamanho do svg descendente, respeitando um `size-*` explícito no próprio ícone. */
|
|
69
|
+
export function controlGlyphClass(size: ControlScale | null | undefined): string {
|
|
70
|
+
return controlGlyphSelector[controlBase(size)]
|
|
71
|
+
}
|
|
72
|
+
|
|
4
73
|
/**
|
|
5
74
|
* Anel de foco da casa: fino (0.1875rem), translúcido e encostado na borda — sem offset.
|
|
6
75
|
* Todo controle focável usa esta constante; espessuras próprias (`ring-2`, `ring-4`) ou
|
|
@@ -5,22 +5,9 @@ import { cn } from '../../lib/cn.ts'
|
|
|
5
5
|
|
|
6
6
|
export type DotContext = UiContext
|
|
7
7
|
export type DotVariant = 'solid' | 'outline'
|
|
8
|
-
/**
|
|
9
|
-
* @deprecated Aliases de migração: `default` → `context="primary"`, `secondary` →
|
|
10
|
-
* `context="neutral"`, `destructive` → `context="danger"`; `success`, `warning` e `info` → o
|
|
11
|
-
* `context` homônimo. Declare `context` + `variant`.
|
|
12
|
-
*/
|
|
13
|
-
export type LegacyDotVariant =
|
|
14
|
-
| 'default'
|
|
15
|
-
| 'secondary'
|
|
16
|
-
| 'destructive'
|
|
17
|
-
| 'success'
|
|
18
|
-
| 'warning'
|
|
19
|
-
| 'info'
|
|
20
|
-
|
|
21
8
|
interface DotStyleOptions {
|
|
22
9
|
context?: DotContext | null
|
|
23
|
-
variant?: DotVariant |
|
|
10
|
+
variant?: DotVariant | null
|
|
24
11
|
}
|
|
25
12
|
|
|
26
13
|
const solidClasses: Record<DotContext, string> = {
|
|
@@ -45,22 +32,6 @@ export function resolveDotStyle({ context, variant }: DotStyleOptions = {}): {
|
|
|
45
32
|
context: DotContext
|
|
46
33
|
variant: DotVariant
|
|
47
34
|
} {
|
|
48
|
-
const legacy =
|
|
49
|
-
variant === 'default' ||
|
|
50
|
-
variant === 'secondary' ||
|
|
51
|
-
variant === 'destructive' ||
|
|
52
|
-
variant === 'success' ||
|
|
53
|
-
variant === 'warning' ||
|
|
54
|
-
variant === 'info'
|
|
55
|
-
if (context != null && legacy) {
|
|
56
|
-
throw new Error('Dot não permite combinar context com uma variante semântica legada.')
|
|
57
|
-
}
|
|
58
|
-
if (variant === 'default') return { context: 'primary', variant: 'solid' }
|
|
59
|
-
if (variant === 'secondary') return { context: 'neutral', variant: 'solid' }
|
|
60
|
-
if (variant === 'destructive') return { context: 'danger', variant: 'solid' }
|
|
61
|
-
if (variant === 'success' || variant === 'warning' || variant === 'info') {
|
|
62
|
-
return { context: variant, variant: 'solid' }
|
|
63
|
-
}
|
|
64
35
|
return { context: context ?? 'neutral', variant: variant ?? 'solid' }
|
|
65
36
|
}
|
|
66
37
|
|
|
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|
|
3
3
|
|
|
4
4
|
import { cn } from '../../lib/cn.ts'
|
|
5
5
|
import type { ControlShape } from './control.ts'
|
|
6
|
-
import { Button } from './button.tsx'
|
|
6
|
+
import { Button, type ButtonProps } from './button.tsx'
|
|
7
7
|
import { Input } from './input.tsx'
|
|
8
8
|
import { Textarea } from './textarea.tsx'
|
|
9
9
|
|
|
@@ -79,16 +79,16 @@ function InputGroupAddon({
|
|
|
79
79
|
)
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** Só o acabamento por tamanho: altura, quadrado e glifo vêm da escala de control.ts via Button. */
|
|
82
83
|
const inputGroupButtonVariants = cva(
|
|
83
84
|
"flex items-center gap-2 text-sm shadow-none",
|
|
84
85
|
{
|
|
85
86
|
variants: {
|
|
86
87
|
size: {
|
|
87
|
-
xs: "
|
|
88
|
-
sm: "
|
|
89
|
-
"icon-xs":
|
|
90
|
-
|
|
91
|
-
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
|
88
|
+
xs: "rounded-[calc(var(--radius)-0.3125rem)] has-[>svg]:px-2",
|
|
89
|
+
sm: "rounded-md",
|
|
90
|
+
"icon-xs": "rounded-[calc(var(--radius)-0.3125rem)] p-0 has-[>svg]:p-0",
|
|
91
|
+
"icon-sm": "p-0 has-[>svg]:p-0",
|
|
92
92
|
},
|
|
93
93
|
},
|
|
94
94
|
defaultVariants: {
|
|
@@ -97,19 +97,22 @@ const inputGroupButtonVariants = cva(
|
|
|
97
97
|
}
|
|
98
98
|
)
|
|
99
99
|
|
|
100
|
+
/** Subconjunto da escala que cabe dentro de um campo: `xs`, `sm`, `icon-xs` e `icon-sm`. */
|
|
101
|
+
type InputGroupButtonSize = Extract<NonNullable<ButtonProps['size']>, 'xs' | 'sm' | 'icon-xs' | 'icon-sm'>
|
|
102
|
+
|
|
100
103
|
function InputGroupButton({
|
|
101
104
|
className,
|
|
102
105
|
type = "button",
|
|
103
106
|
variant = "ghost",
|
|
104
107
|
size = "xs",
|
|
105
108
|
...props
|
|
106
|
-
}: Omit<
|
|
107
|
-
VariantProps<typeof inputGroupButtonVariants>) {
|
|
109
|
+
}: Omit<ButtonProps, 'size'> & { size?: InputGroupButtonSize }) {
|
|
108
110
|
return (
|
|
109
111
|
<Button
|
|
110
112
|
type={type}
|
|
111
113
|
data-size={size}
|
|
112
114
|
variant={variant}
|
|
115
|
+
size={size}
|
|
113
116
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
|
114
117
|
{...props}
|
|
115
118
|
/>
|
|
@@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
3
3
|
import { Slot } from "radix-ui";
|
|
4
4
|
|
|
5
5
|
import { cn } from "../../lib/cn.ts";
|
|
6
|
+
import type { ControlSize } from "./control.ts";
|
|
6
7
|
import { Separator } from "./separator.tsx";
|
|
7
8
|
|
|
8
9
|
const itemGroupVariants = cva(
|
|
@@ -72,10 +73,11 @@ const itemVariants = cva(
|
|
|
72
73
|
outline: "border-border",
|
|
73
74
|
muted: "bg-muted/50",
|
|
74
75
|
},
|
|
76
|
+
// Nomes da escala de control.ts; a linha não tem altura fixa, `sm` aperta o respiro.
|
|
75
77
|
size: {
|
|
76
78
|
default: "gap-4 p-4",
|
|
77
79
|
sm: "gap-2.5 px-4 py-3",
|
|
78
|
-
},
|
|
80
|
+
} satisfies Partial<Record<ControlSize, string>>,
|
|
79
81
|
},
|
|
80
82
|
defaultVariants: { variant: "default", size: "default" },
|
|
81
83
|
},
|
|
@@ -67,18 +67,12 @@ function MenuItem({
|
|
|
67
67
|
className,
|
|
68
68
|
inset,
|
|
69
69
|
context,
|
|
70
|
-
variant,
|
|
71
70
|
...props
|
|
72
71
|
}: React.ComponentProps<typeof MenuPrimitive.Item> & {
|
|
73
72
|
inset?: boolean
|
|
74
73
|
context?: Extract<UiContext, 'neutral' | 'danger'>
|
|
75
|
-
/** @deprecated Use `context="danger"`. */
|
|
76
|
-
variant?: "default" | "destructive"
|
|
77
74
|
}) {
|
|
78
|
-
|
|
79
|
-
throw new Error('MenuItem não permite combinar context com uma variante semântica legada.')
|
|
80
|
-
}
|
|
81
|
-
const resolvedContext = context ?? (variant === 'destructive' ? 'danger' : 'neutral')
|
|
75
|
+
const resolvedContext = context ?? 'neutral'
|
|
82
76
|
return (
|
|
83
77
|
<MenuPrimitive.Item
|
|
84
78
|
data-slot="menu-item"
|
|
@@ -3,6 +3,7 @@ import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-re
|
|
|
3
3
|
|
|
4
4
|
import { cn } from '../../lib/cn.ts'
|
|
5
5
|
import { buttonVariants, type ButtonProps } from './button.tsx'
|
|
6
|
+
import { controlSquare, type ControlIconSize } from './control.ts'
|
|
6
7
|
|
|
7
8
|
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
|
8
9
|
return (
|
|
@@ -33,8 +34,8 @@ function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
|
|
|
33
34
|
export interface PaginationLinkProps extends Omit<React.ComponentProps<'a'>, 'children'> {
|
|
34
35
|
/** Página atual: vira `outline` e ganha `aria-current="page"`. */
|
|
35
36
|
isActive?: boolean
|
|
36
|
-
/**
|
|
37
|
-
* com os dígitos) ou
|
|
37
|
+
/** A escala de control.ts, via Button. `default` (números: altura fixa, largura mínima
|
|
38
|
+
* quadrada que cresce com os dígitos) ou um `icon*` (setas quadradas, sem padding). */
|
|
38
39
|
size?: ButtonProps['size']
|
|
39
40
|
/** Número da página: dá o nome acessível (“Página N”) e o conteúdo, quando `children` falta. */
|
|
40
41
|
page?: number
|
|
@@ -83,10 +84,11 @@ function PaginationLink({ className, isActive, page, disabled, href, size = 'def
|
|
|
83
84
|
export interface PaginationControlProps extends Omit<PaginationLinkProps, 'page' | 'isActive'> {
|
|
84
85
|
/** Nome acessível e texto visível. Default: “Página anterior” / “Próxima página”. */
|
|
85
86
|
label?: string
|
|
86
|
-
/** Só a seta, quadrada (
|
|
87
|
+
/** Só a seta, quadrada (`icon`, 2.25rem; `size="icon-sm"` para o rodapé denso); o `label`
|
|
88
|
+
* continua como nome acessível. */
|
|
87
89
|
iconOnly?: boolean
|
|
88
|
-
/** Classe do ícone
|
|
89
|
-
* fallback `[&_svg:not([class*=
|
|
90
|
+
/** Classe do ícone, quando o glifo da escala não servir. Vai no próprio svg, porque o
|
|
91
|
+
* fallback `[&_svg:not([class*=size-])]` do Button vence qualquer seletor de fora. */
|
|
90
92
|
iconClassName?: string
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -132,16 +134,22 @@ function PaginationNext({
|
|
|
132
134
|
)
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
export interface PaginationEllipsisProps extends React.ComponentProps<'span'> {
|
|
138
|
+
/** O quadrado da escala de control.ts — o mesmo das setas ao lado. */
|
|
139
|
+
size?: ControlIconSize
|
|
140
|
+
}
|
|
141
|
+
|
|
135
142
|
/** Decorativa: `aria-hidden`; os números ao redor já dizem que há páginas no vão. */
|
|
136
|
-
function PaginationEllipsis({ className, ...props }:
|
|
143
|
+
function PaginationEllipsis({ className, size = 'icon', ...props }: PaginationEllipsisProps) {
|
|
137
144
|
return (
|
|
138
145
|
<span
|
|
139
146
|
aria-hidden
|
|
140
147
|
data-slot="pagination-ellipsis"
|
|
141
|
-
|
|
148
|
+
data-size={size}
|
|
149
|
+
className={cn('flex items-center justify-center', controlSquare[size], className)}
|
|
142
150
|
{...props}
|
|
143
151
|
>
|
|
144
|
-
<MoreHorizontalIcon className=
|
|
152
|
+
<MoreHorizontalIcon className={size === 'icon-xs' || size === 'icon-sm' ? 'size-3.5' : 'size-4'} />
|
|
145
153
|
</span>
|
|
146
154
|
)
|
|
147
155
|
}
|
|
@@ -25,7 +25,7 @@ import { Check, ChevronDownIcon, SearchIcon, X } from 'lucide-react'
|
|
|
25
25
|
import { Command as CommandPrimitive } from 'cmdk'
|
|
26
26
|
|
|
27
27
|
import { cn } from '../../lib/cn.ts'
|
|
28
|
-
import { focusRing, focusRingWithin, type ControlShape } from './control.ts'
|
|
28
|
+
import { focusRing, focusRingWithin, type ControlShape, type ControlSize } from './control.ts'
|
|
29
29
|
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from './command.tsx'
|
|
30
30
|
import { Popover, PopoverAnchor, PopoverContent } from './popover.tsx'
|
|
31
31
|
|
|
@@ -60,7 +60,7 @@ interface SelectBaseProps {
|
|
|
60
60
|
'aria-invalid'?: boolean
|
|
61
61
|
'aria-describedby'?: string
|
|
62
62
|
/** Altura: `default` (h-9, a do Input/Button) ou `sm` (h-8) pra toolbar densa. */
|
|
63
|
-
size?: 'sm' | 'default'
|
|
63
|
+
size?: Extract<ControlSize, 'sm' | 'default'>
|
|
64
64
|
/** Geometria do controle; `pill` preserva a variante visual escolhida. */
|
|
65
65
|
shape?: ControlShape
|
|
66
66
|
'aria-label'?: string
|
|
@@ -1,28 +1,25 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { Loader2Icon } from 'lucide-react'
|
|
3
|
-
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
3
|
import { cn } from '../../lib/cn.ts'
|
|
4
|
+
import { controlGlyph, type ControlSize } from './control.ts'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
size: { sm: 'size-3', default: 'size-4', lg: 'size-6' },
|
|
11
|
-
},
|
|
12
|
-
defaultVariants: { size: 'default' },
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
export interface SpinnerProps extends React.ComponentProps<'svg'>, VariantProps<typeof spinnerVariants> {}
|
|
6
|
+
export interface SpinnerProps extends React.ComponentProps<'svg'> {
|
|
7
|
+
/** O glifo do controle daquele tamanho na escala de control.ts (`sm` cabe num Button sm). */
|
|
8
|
+
size?: ControlSize | null
|
|
9
|
+
}
|
|
16
10
|
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
11
|
+
/**
|
|
12
|
+
* Indicador de carregamento (Loader2Icon girando) — substitui o `<Loader2Icon
|
|
13
|
+
* className="animate-spin"/>` inline repetido. É decorativo: quem nomeia a espera é o contêiner
|
|
14
|
+
* (`role="status"` em DataState, PageState ou no seu) ou o texto ao lado; assim a superfície
|
|
15
|
+
* carrega um status só, sem role duplicado. Pra placeholder de conteúdo, use Skeleton.
|
|
16
|
+
*/
|
|
19
17
|
export function Spinner({ className, size, ...props }: SpinnerProps): React.ReactElement {
|
|
20
18
|
return (
|
|
21
19
|
<Loader2Icon
|
|
22
20
|
data-slot="spinner"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
className={cn(spinnerVariants({ size }), className)}
|
|
21
|
+
aria-hidden="true"
|
|
22
|
+
className={cn('animate-spin', controlGlyph[size ?? 'default'], className)}
|
|
26
23
|
{...props}
|
|
27
24
|
/>
|
|
28
25
|
)
|
|
@@ -2,13 +2,16 @@ import * as React from "react"
|
|
|
2
2
|
import { Switch as SwitchPrimitive } from "radix-ui"
|
|
3
3
|
|
|
4
4
|
import { cn } from '../../lib/cn.ts'
|
|
5
|
+
import type { ControlSize } from './control.ts'
|
|
5
6
|
|
|
7
|
+
/** `size` usa os nomes da escala de control.ts; a chave é um trilho (não uma linha), então a
|
|
8
|
+
* medida é a do glifo daquele tamanho: `sm` acompanha o svg de um controle sm (0.875rem). */
|
|
6
9
|
function Switch({
|
|
7
10
|
className,
|
|
8
11
|
size = "default",
|
|
9
12
|
...props
|
|
10
13
|
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
|
11
|
-
size?: "sm" | "default"
|
|
14
|
+
size?: Extract<ControlSize, "sm" | "default">
|
|
12
15
|
}) {
|
|
13
16
|
return (
|
|
14
17
|
<SwitchPrimitive.Root
|
|
@@ -3,11 +3,13 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|
|
3
3
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
|
4
4
|
|
|
5
5
|
import { cn } from '../../lib/cn.ts'
|
|
6
|
+
import { controlHeight, type ControlSize } from './control.ts'
|
|
6
7
|
|
|
7
8
|
// Divergência da casa (ejetado): o Tabs ganhou `size` — Button e Select têm `sm`, o Tabs
|
|
8
9
|
// não tinha, e uma fileira densa ficava com o segmento 0.25rem mais alto
|
|
9
|
-
// que os irmãos. O tamanho flui pra TabsList via contexto (a altura é dela)
|
|
10
|
-
|
|
10
|
+
// que os irmãos. O tamanho flui pra TabsList via contexto (a altura é dela) e a medida é a
|
|
11
|
+
// da escala de control.ts.
|
|
12
|
+
type TabsSize = Extract<ControlSize, "default" | "sm">
|
|
11
13
|
const TabsSizeContext = React.createContext<TabsSize>("default")
|
|
12
14
|
|
|
13
15
|
function Tabs({
|
|
@@ -64,7 +66,7 @@ function TabsList({
|
|
|
64
66
|
data-slot="tabs-list"
|
|
65
67
|
data-variant={variant}
|
|
66
68
|
data-size={size}
|
|
67
|
-
className={cn(tabsListVariants({ variant }), size
|
|
69
|
+
className={cn(tabsListVariants({ variant }), controlHeight[size], className)}
|
|
68
70
|
{...props}
|
|
69
71
|
/>
|
|
70
72
|
)
|
|
@@ -3,9 +3,13 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|
|
3
3
|
import { Toggle as TogglePrimitive } from "radix-ui"
|
|
4
4
|
|
|
5
5
|
import { cn } from '../../lib/cn.ts'
|
|
6
|
+
import { controlHeight, focusRing } from './control.ts'
|
|
6
7
|
|
|
7
8
|
const toggleVariants = cva(
|
|
8
|
-
|
|
9
|
+
[
|
|
10
|
+
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-context-danger/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
11
|
+
focusRing,
|
|
12
|
+
],
|
|
9
13
|
{
|
|
10
14
|
variants: {
|
|
11
15
|
variant: {
|
|
@@ -13,10 +17,11 @@ const toggleVariants = cva(
|
|
|
13
17
|
outline:
|
|
14
18
|
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
|
15
19
|
},
|
|
20
|
+
// Alturas da escala de control.ts (`sm` = o mesmo h-8 de Button e Select).
|
|
16
21
|
size: {
|
|
17
|
-
default:
|
|
18
|
-
sm:
|
|
19
|
-
lg:
|
|
22
|
+
default: `${controlHeight.default} min-w-9 px-2`,
|
|
23
|
+
sm: `${controlHeight.sm} min-w-8 px-1.5`,
|
|
24
|
+
lg: `${controlHeight.lg} min-w-10 px-2.5`,
|
|
20
25
|
},
|
|
21
26
|
shape: {
|
|
22
27
|
default: '',
|
|
@@ -88,6 +88,32 @@ ciclo de vida que o pattern não cobre; validação e execução continuam iguai
|
|
|
88
88
|
| `options` | `SelectOption[]` | Opções por id de runtime — sobrepõe o fieldOptions do form e o z.enum. |
|
|
89
89
|
| `className` | `string` | Classes do invólucro (ex.: `col-span-2` em uma grid). |
|
|
90
90
|
|
|
91
|
+
## Ajuda na label
|
|
92
|
+
|
|
93
|
+
`FieldSpec.help` aparece como um ícone junto à label, com o texto num tooltip. Escreva ali o
|
|
94
|
+
critério, o efeito ou a limitação que a label não diz; ajuda que só repete a label deve ser omitida.
|
|
95
|
+
Não existe texto auxiliar sob o campo: quem precisa de uma frase permanente abaixo do controle usa
|
|
96
|
+
`FieldDescription` na composição própria.
|
|
97
|
+
|
|
98
|
+
`LabelHelp` é esse mesmo ícone, exportado para um formulário sem contrato mostrar a ajuda no mesmo
|
|
99
|
+
lugar:
|
|
100
|
+
|
|
101
|
+
```tsx preview col md
|
|
102
|
+
<Field>
|
|
103
|
+
<FieldLabel htmlFor="staff" className="items-center gap-1.5">
|
|
104
|
+
<span>Staff</span>
|
|
105
|
+
<LabelHelp help="Tem acesso ao back-office e a todos os workspaces." />
|
|
106
|
+
</FieldLabel>
|
|
107
|
+
<Input id="staff" placeholder="Empresa X" />
|
|
108
|
+
</Field>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Propriedades de LabelHelp
|
|
112
|
+
|
|
113
|
+
| Propriedade | Tipo | Descrição |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `help` | `string` | O texto do tooltip. Vazio ou ausente, o ícone não renderiza. |
|
|
116
|
+
|
|
91
117
|
## Origem das opções de seleção
|
|
92
118
|
|
|
93
119
|
As opções seguem esta precedência: `options` no campo, `fieldOptions` no formulário, `options` no
|
|
@@ -23,7 +23,7 @@ render(
|
|
|
23
23
|
<Plus /> Workspace
|
|
24
24
|
</Button>
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
emptyMessage="Nenhum workspace."
|
|
27
27
|
>
|
|
28
28
|
{(workspaces) => (
|
|
29
29
|
<div className="space-y-2">
|
|
@@ -69,5 +69,5 @@ corpo do modal.
|
|
|
69
69
|
| `actions` | `ReactNode` | | Ação à direita da toolbar — em geral o botão de criar. |
|
|
70
70
|
| `empty` | `(items) => boolean` | `items.length === 0` | Sobrepõe o vazio derivado. |
|
|
71
71
|
| `loading` | `boolean` | | Carga extra agregada à do fetch (query irmã). |
|
|
72
|
-
| `
|
|
72
|
+
| `emptyMessage / errorMessage / retryLabel` | `string` | | Textos dos estados; `retryLabel` nomeia a ação somente com ícone e seu tooltip. |
|
|
73
73
|
| `className` | `string` | `sm:max-w-3xl` | Largura do DialogContent. |
|