@softize/opus 15.0.0 → 15.0.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 +32 -0
- package/bin/lib/check.mjs +11 -1
- package/docs/adr/0004-page-content-state-is-composed.md +9 -2
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/references/ui-patterns.md +7 -6
- package/src/ui/components/patterns/list.tsx +29 -3
- package/src/ui/components/patterns/page.tsx +5 -2
- package/src/ui/components/patterns/surface-header.tsx +9 -1
- package/src/ui/docs/content/action-list.md +1 -1
- package/src/ui/docs/content/page.md +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,38 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
7
7
|
`opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
|
|
8
8
|
mudança cobra do seu código.
|
|
9
9
|
|
|
10
|
+
## 15.0.1 — 2026-09-09
|
|
11
|
+
|
|
12
|
+
Correções sobre a 15.0.0, publicada horas antes, todas vindas da revisão dela. Nenhuma novidade de
|
|
13
|
+
API: nada entrou, saiu ou mudou de nome.
|
|
14
|
+
|
|
15
|
+
As ações de uma linha do `ActionList` só entram no `ButtonGroup` a partir de duas. Com uma só, o
|
|
16
|
+
grupo não é materializado: um `role="group"` sem nome por linha enchia a árvore de acessibilidade
|
|
17
|
+
sem informar nada, e o espaçamento não tinha o que espaçar. Fragmento devolvido pelo consumidor
|
|
18
|
+
conta como as ações que carrega, não como um filho só.
|
|
19
|
+
|
|
20
|
+
**Migração:** quem escreveu seletor contra o `[data-slot="button-group"]` da linha na 15.0.0
|
|
21
|
+
precisa mirar a célula. Com uma ação só, não há mais grupo.
|
|
22
|
+
|
|
23
|
+
A região introdutória do `PageHeader` — `PageBack` ou `PageNavigation` — passa a carregar
|
|
24
|
+
`data-slot="page-navigation"` também na apresentação em barra. Antes o marcador existia só no
|
|
25
|
+
header padrão, então um seletor de teste ou de CSS que funcionasse num não funcionava no outro.
|
|
26
|
+
|
|
27
|
+
`Page` deixa de emitir `min-h-0` e `min-h-full` ao mesmo tempo quando a barra encontra um estado
|
|
28
|
+
integral. O resultado dependia da ordem do CSS gerado; agora o estado integral declara a altura
|
|
29
|
+
que precisa e a barra só contém a rolagem quando não há estado.
|
|
30
|
+
|
|
31
|
+
`opus check` para de acrescentar um segundo diagnóstico sobre a forma de composição quando o
|
|
32
|
+
problema é uma propriedade removida. A prop já diz o que corrigir; a mensagem seguinte era
|
|
33
|
+
verdadeira e enganosa ao mesmo tempo.
|
|
34
|
+
|
|
35
|
+
A ADR 0004 ganha, no adendo, o motivo que sustenta a alternativa descartada — antes ela se apoiava
|
|
36
|
+
numa citação que não sustentava a conclusão — e a segunda consequência da decisão: `PageHeader`
|
|
37
|
+
desiste antes de validar os filhos, então um cabeçalho inválido só lança quando a página chega em
|
|
38
|
+
`ready` — o `opus check` continua pegando isso estaticamente. A guidance de UI e a doc de `Page`
|
|
39
|
+
passam a avisar que o retorno some junto com o cabeçalho e que a saída, nesse caso, é o `action` do
|
|
40
|
+
próprio `PageState`.
|
|
41
|
+
|
|
10
42
|
## 15.0.0 — 2026-09-09
|
|
11
43
|
|
|
12
44
|
`PageHeader` passa a oferecer `variant="bar"`, uma apresentação compacta da mesma região de título,
|
package/bin/lib/check.mjs
CHANGED
|
@@ -950,8 +950,10 @@ export function checkUiStructure(file, text) {
|
|
|
950
950
|
: [],
|
|
951
951
|
),
|
|
952
952
|
);
|
|
953
|
+
let removedProp = false;
|
|
953
954
|
for (const [attribute, hint] of root.removed ?? []) {
|
|
954
955
|
if (attributes.has(attribute)) {
|
|
956
|
+
removedProp = true;
|
|
955
957
|
add(
|
|
956
958
|
node,
|
|
957
959
|
component,
|
|
@@ -967,7 +969,15 @@ export function checkUiStructure(file, text) {
|
|
|
967
969
|
const structural =
|
|
968
970
|
children.names.includes(root.header) ||
|
|
969
971
|
children.names.includes(root.body);
|
|
970
|
-
|
|
972
|
+
// A prop removida saiu do `shorthand`, então sozinha ela não marca o nó como forma curta:
|
|
973
|
+
// ele cai no ramo explícito, que cobra header e body — uma segunda mensagem inteiramente
|
|
974
|
+
// induzida pela prop. Sem filhos estruturais essa cobrança dispara de qualquer jeito, e
|
|
975
|
+
// some aqui.
|
|
976
|
+
// Com filhos estruturais, o que as regras de forma acham é defeito independente: misturar
|
|
977
|
+
// shorthand com slots, ou um filho inesperado, não some quando a prop sai.
|
|
978
|
+
if (removedProp && !structural) {
|
|
979
|
+
// Nada a acrescentar; os filhos seguem sendo visitados no fim de `visit`.
|
|
980
|
+
} else if (shorthand && structural) {
|
|
971
981
|
add(
|
|
972
982
|
node,
|
|
973
983
|
component,
|
|
@@ -90,10 +90,17 @@ superfície de estado carrega `role="heading"` com `aria-level={1}`.
|
|
|
90
90
|
|
|
91
91
|
**Alternativa descartada.** Ocultar apenas título, descrição e ações, preservando a região
|
|
92
92
|
introdutória. Foi descartada para esta versão porque partiria o cabeçalho em duas regras de
|
|
93
|
-
visibilidade
|
|
94
|
-
|
|
93
|
+
visibilidade — uma para o retorno, outra para o resto —, e um cabeçalho que aparece pela metade
|
|
94
|
+
é mais difícil de prever do que um que some inteiro. Não é uma decisão confortável: ver
|
|
95
|
+
"O que se perde".
|
|
95
96
|
|
|
96
97
|
**O que se perde, e é conhecido.** Some junto o `PageBack`, então uma subpágina em erro fica sem o
|
|
97
98
|
retorno in-page para o pai — justamente quando a pessoa mais precisa sair. Enquanto esta ADR não
|
|
98
99
|
for revista, uma subpágina que dependa desse retorno oferece a saída pelo `action` do próprio
|
|
99
100
|
`PageState`. Reavaliar se o custo aparecer em uso real.
|
|
101
|
+
|
|
102
|
+
Uma segunda consequência é da mesma decisão: como `PageHeader` desiste antes de validar seus
|
|
103
|
+
filhos, um cabeçalho estruturalmente inválido — dois `PageBack`, ou sem `PageTitle` — deixa de
|
|
104
|
+
lançar enquanto a página está em estado integral, e só lança quando ela chega em `ready`. O
|
|
105
|
+
`opus check` continua pegando isso estaticamente, então o erro não passa despercebido até
|
|
106
|
+
produção; o que muda é o momento em que aparece no desenvolvimento.
|
package/package.json
CHANGED
|
@@ -19,12 +19,13 @@
|
|
|
19
19
|
`PageActionsTarget` fica reservado a workspaces imersivos que já possuam chrome próprio.
|
|
20
20
|
- `PageState` substitui todo o conteúdo principal quando a página carrega, falha ou está vazia. Na
|
|
21
21
|
forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
|
|
22
|
-
explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho some
|
|
23
|
-
ocupa a área disponível e seu título assume o heading
|
|
24
|
-
intermediário renderiza o estado.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho some inteiro —
|
|
23
|
+
incluindo o `PageBack` —, o estado ocupa a área disponível e seu título assume o heading
|
|
24
|
+
principal, inclusive quando um componente intermediário renderiza o estado. Uma subpágina que
|
|
25
|
+
dependa do retorno ao pai oferece essa saída pelo `action` do próprio `PageState`. O erro mantém
|
|
26
|
+
`role="alert"`, usa a mesma composição central e sem moldura dos demais estados e apresenta a
|
|
27
|
+
recuperação como botão `outline` textual. Estados de seção ou coleção continuam em `DataState`,
|
|
28
|
+
`ActionView`, `ActionList` ou `Alert`; não elevar uma falha parcial a estado da página.
|
|
28
29
|
- `Content` delimita uma seção e segue a mesma anatomia: `Content > ContentHeader (ContentTitle,
|
|
29
30
|
ContentDescription, ContentMeta, ContentActions) + ContentBody`. `ContentHeader` nunca fica
|
|
30
31
|
solto. `title`, `description`, `count` e `actions` no `Content` são a abreviação para o caso
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
|
+
Children,
|
|
24
|
+
Fragment,
|
|
25
|
+
isValidElement,
|
|
23
26
|
useEffect,
|
|
24
27
|
useLayoutEffect,
|
|
25
28
|
useMemo,
|
|
@@ -1142,6 +1145,31 @@ export function ActionFilterBar({
|
|
|
1142
1145
|
// ActionList
|
|
1143
1146
|
// =============================================================================
|
|
1144
1147
|
|
|
1148
|
+
/**
|
|
1149
|
+
* Ações de uma linha da tabela. Com duas ou mais, agrupa e espaça; com uma só, não
|
|
1150
|
+
* materializa o `ButtonGroup` — um `role="group"` sem nome por linha enche a árvore de
|
|
1151
|
+
* acessibilidade sem informar nada, e `mode="spaced"` não teria o que espaçar.
|
|
1152
|
+
*/
|
|
1153
|
+
function RowActions({ children }: { children: ReactNode }) {
|
|
1154
|
+
// O consumidor costuma devolver as ações dentro de um fragment, que conta como UM filho —
|
|
1155
|
+
// e às vezes dentro de fragments aninhados. Expandir recursivamente, e contar só elementos,
|
|
1156
|
+
// evita tanto tratar duas ações como uma quanto deixar um espaço em branco virar ação.
|
|
1157
|
+
const expand = (node: ReactNode): ReactNode[] =>
|
|
1158
|
+
Children.toArray(node).flatMap((child) =>
|
|
1159
|
+
isValidElement(child) && child.type === Fragment
|
|
1160
|
+
? expand((child.props as { children?: ReactNode }).children)
|
|
1161
|
+
: [child],
|
|
1162
|
+
);
|
|
1163
|
+
const alone = expand(children).filter(isValidElement).length < 2;
|
|
1164
|
+
return alone ? (
|
|
1165
|
+
<div className="flex justify-end">{children}</div>
|
|
1166
|
+
) : (
|
|
1167
|
+
<ButtonGroup mode="spaced" className="ml-auto">
|
|
1168
|
+
{children}
|
|
1169
|
+
</ButtonGroup>
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1145
1173
|
export function ActionList<TInput, TItem>({
|
|
1146
1174
|
action,
|
|
1147
1175
|
input,
|
|
@@ -1634,9 +1662,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1634
1662
|
className="w-px whitespace-nowrap text-right"
|
|
1635
1663
|
onClick={(e) => e.stopPropagation()}
|
|
1636
1664
|
>
|
|
1637
|
-
<
|
|
1638
|
-
{rowActions(item)}
|
|
1639
|
-
</ButtonGroup>
|
|
1665
|
+
<RowActions>{rowActions(item)}</RowActions>
|
|
1640
1666
|
</TableCell>
|
|
1641
1667
|
)}
|
|
1642
1668
|
</TableRow>
|
|
@@ -169,8 +169,11 @@ export function Page({
|
|
|
169
169
|
data-slot="page"
|
|
170
170
|
className={cn(
|
|
171
171
|
"min-w-0 flex-1",
|
|
172
|
-
headerVariant === "bar" && "flex
|
|
173
|
-
|
|
172
|
+
(headerVariant === "bar" || integralState) && "flex flex-col",
|
|
173
|
+
// A barra contém a rolagem (`min-h-0`); o estado integral ocupa a altura
|
|
174
|
+
// disponível (`min-h-full`). Emitir os dois juntos deixava o resultado por
|
|
175
|
+
// conta da ordem do CSS gerado — no estado integral, quem manda é ele.
|
|
176
|
+
integralState ? "min-h-full" : headerVariant === "bar" && "min-h-0",
|
|
174
177
|
)}
|
|
175
178
|
{...props}
|
|
176
179
|
>
|
|
@@ -146,9 +146,17 @@ export function SurfaceHeader({
|
|
|
146
146
|
{descriptions}
|
|
147
147
|
</div>
|
|
148
148
|
);
|
|
149
|
+
// A região introdutória carrega o mesmo `data-slot` nas duas apresentações: seletor de teste
|
|
150
|
+
// ou de CSS que funcione no header padrão precisa funcionar na barra.
|
|
149
151
|
const row = (
|
|
150
152
|
<>
|
|
151
|
-
{leadingPlacement === "inline" && leading
|
|
153
|
+
{leadingPlacement === "inline" && leading.length > 0 && (
|
|
154
|
+
// `min-w-0` aqui é o que deixa a trilha do PageNavigation ceder e truncar; sem ele o
|
|
155
|
+
// wrapper assume o min-content do breadcrumb e a compressão toda cai sobre o título.
|
|
156
|
+
<div data-slot={`${slot}-navigation`} className="flex min-w-0 items-center">
|
|
157
|
+
{leading}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
152
160
|
{heading}
|
|
153
161
|
{actionSlots}
|
|
154
162
|
</>
|
|
@@ -302,4 +302,4 @@ chamar a action.
|
|
|
302
302
|
| `retryLabel` | `string` | `'Tentar de novo'` | Nome acessível e tooltip da ação de recuperação. |
|
|
303
303
|
| `onRowClick` | `(item: TItem) => void` | | Clique na linha (ex.: navegar para o detalhe) — só na tabela. |
|
|
304
304
|
| `toolbarActions` | `ReactNode` | | Ações do consumidor no fim da barra, separadas do grupo de controles. |
|
|
305
|
-
| `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita),
|
|
305
|
+
| `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita). A partir de duas, entram num grupo com intervalo compacto; uma só fica solta, sem grupo. Cliques ali não disparam o `onRowClick`. |
|
|
@@ -123,10 +123,11 @@ render(
|
|
|
123
123
|
Use `PageState` quando carregamento, falha ou ausência substituírem todo o conteúdo principal. Na
|
|
124
124
|
forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
|
|
125
125
|
explícita, coloque-o sozinho dentro de `PageBody`. Enquanto `status` for `loading`, `error` ou
|
|
126
|
-
`empty`, `Page` oculta o cabeçalho e o estado ocupa a altura
|
|
127
|
-
|
|
128
|
-
`PageState
|
|
129
|
-
|
|
126
|
+
`empty`, `Page` oculta o cabeçalho inteiro — incluindo o `PageBack` — e o estado ocupa a altura
|
|
127
|
+
disponível. Uma subpágina que dependa desse retorno oferece a saída pelo `action` do próprio
|
|
128
|
+
`PageState`. O título do estado assume o heading principal. Esse registro também funciona quando um
|
|
129
|
+
componente intermediário decide qual `PageState` renderizar. Em `ready`, o cabeçalho e o conteúdo
|
|
130
|
+
voltam à composição normal.
|
|
130
131
|
|
|
131
132
|
```tsx preview col
|
|
132
133
|
<Page title="Relatório">
|
|
@@ -244,7 +245,7 @@ estado na própria seção com `DataState`, `ActionView`, `ActionList` ou `Alert
|
|
|
244
245
|
| `title` | `ReactNode` | Texto seguro por estado | Situação reconhecível pela pessoa. |
|
|
245
246
|
| `description` | `ReactNode` | | Impacto ou próximo passo aplicável. |
|
|
246
247
|
| `icon` | `ReactNode` | Alerta no erro | Ícone decorativo do estado. |
|
|
247
|
-
| `action` | `ReactNode` | | Seleção ou
|
|
248
|
+
| `action` | `ReactNode` | | Seleção, criação ou saída aplicável ao estado — inclusive o retorno ao pai, já que o cabeçalho está oculto. |
|
|
248
249
|
| `emptyMessage` | `string` | `'Nada por aqui'` | Título do vazio quando `title` não é informado. |
|
|
249
250
|
| `errorMessage` | `string` | `'Não foi possível carregar esta página'` | Título do erro quando `title` não é informado. |
|
|
250
251
|
| `onRetry` | `() => void \| Promise<void>` | | Recuperação do erro: acrescenta um botão `outline` textual ao lado de `action`. |
|