@softize/opus 25.3.0 → 25.4.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 +37 -0
- package/bin/lib/assistant-schema.d.mts +3 -2
- package/bin/lib/assistant-schema.mjs +41 -21
- package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +19 -0
- package/docs/adr/0053-read-action-declares-the-resource-assistant.md +8 -1
- package/docs/adr/0054-action-list-grouping-is-a-declared-server-cut.md +3 -2
- package/docs/adr/0055-runtime-options-can-form-bounded-list-groups.md +57 -0
- package/docs/protocol.md +9 -4
- package/package.json +1 -1
- package/src/core/list-grouping.ts +4 -4
- package/src/core/types.ts +3 -3
- package/src/ui/components/patterns/grouped-list.ts +3 -4
- package/src/ui/components/patterns/list.tsx +165 -15
- package/src/ui/components/primitives/floating-layer.tsx +4 -4
- package/src/ui/docs/content/action-list.md +24 -10
- package/src/ui/docs/content/actions.md +6 -1
- package/src/ui/docs/content/floating-layer.md +17 -10
- package/src/ui/drivers/react.tsx +30 -0
- package/src/ui/lib/modal-layers.ts +18 -10
- package/src/ui/react.tsx +25 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@ 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
|
+
## 25.4.0 — 2026-10-05
|
|
11
|
+
|
|
12
|
+
`ListAction.grouping.fields` passa a aceitar filtros `select` ou `lookup` cujas opções completas
|
|
13
|
+
sejam fornecidas em runtime por `ActionList.filterOptions`. A dimensão só aparece em **Agrupar por**
|
|
14
|
+
com 1 a 12 opções; catálogo ausente, vazio ou maior mantém a lista plana, sem agrupar uma amostra.
|
|
15
|
+
A ADR 0055 registra essa extensão da decisão original.
|
|
16
|
+
|
|
17
|
+
As preferências de ordenação passam para **Exibição**, com a direção como ação dentro do próprio
|
|
18
|
+
campo. O botão sinaliza quando ordenação, agrupamento, colunas ou quantidade por página diferem do
|
|
19
|
+
padrão, e o rodapé explicita **Agrupado por** e **Ordenado por** quando aplicáveis.
|
|
20
|
+
|
|
21
|
+
Também corrige a `ActionList` agrupada em aplicações Vite que consomem o código-fonte TypeScript do
|
|
22
|
+
Opus. Ao ativar um agrupamento, a lista podia deixar de reconhecer o `OpusProvider` já presente na
|
|
23
|
+
aplicação e interromper a renderização. O agrupamento agora usa o mesmo driver React dos demais
|
|
24
|
+
hooks de action. Não há migração para o consumidor.
|
|
25
|
+
|
|
26
|
+
## 25.3.1 — 2026-10-05
|
|
27
|
+
|
|
28
|
+
Com um `Dialog` ou `Drawer` aberto e a pessoa numa `FloatingLayer` sem `onEscapeKeyDown`, o Esc
|
|
29
|
+
fecha o modal. Antes, o Opus impedia o modal de fechar e chamava um handler que não existia: quem
|
|
30
|
+
chegava ao botão do assistente pelo Tab e apertava Esc não via efeito nenhum. Com o handler, nada
|
|
31
|
+
muda: a camada recebe a tecla e o modal continua aberto. Para que o Esc no botão feche o modal,
|
|
32
|
+
passe `onEscapeKeyDown` só enquanto a camada tiver o que fechar, como com o painel aberto, e
|
|
33
|
+
`undefined` no restante do tempo. A decisão está numa nota da seção 5 da ADR 0052.
|
|
34
|
+
|
|
35
|
+
`assistantContextLeaves`, de `@softize/opus/assistant-schema`, passa a listar em `open` dois pontos
|
|
36
|
+
por onde qualquer campo chega ao modelo e que antes ficavam de fora: o `$ref` que não resolve dentro
|
|
37
|
+
do schema, que sumia do resultado, e o objeto que aceita chaves não declaradas
|
|
38
|
+
(`additionalProperties: true`, do `.passthrough()` do zod), que aparece como `<caminho>.*`. A forma
|
|
39
|
+
do resultado e a conferência do `opus gen` não mudam. Um teste de campos pessoais que reprova pontos
|
|
40
|
+
abertos pode passar a acusar esses casos; reprove-os ou justifique cada um.
|
|
41
|
+
|
|
42
|
+
A ADR 0052 e a página da `FloatingLayer` registram que o roteiro manual em Safari com VoiceOver e no
|
|
43
|
+
Firefox não foi executado: na adoção pela Grand Brasil, o dono do produto o dispensou. A evidência
|
|
44
|
+
é a dos testes de navegador no Chromium real, e o roteiro volta a ser exigido se surgir problema de
|
|
45
|
+
leitor de tela, de Safari ou de Firefox.
|
|
46
|
+
|
|
10
47
|
## 25.3.0 — 2026-10-05
|
|
11
48
|
|
|
12
49
|
`ListAction.grouping` declara dimensões fechadas pelas quais a `ActionList` pode agrupar a tabela.
|
|
@@ -13,8 +13,9 @@ export interface AssistantContextLeaves {
|
|
|
13
13
|
/** Os campos folha alcançados, com ponto e sem índice de lista; `*` marca a chave de um mapa. */
|
|
14
14
|
leaves: string[]
|
|
15
15
|
/**
|
|
16
|
-
* Pontos
|
|
17
|
-
*
|
|
16
|
+
* Pontos por onde qualquer campo pode ir ao modelo sem que a conferência alcance: o schema que não
|
|
17
|
+
* declara forma, o `$ref` que volta a si mesmo ou que não resolve, e, como `<ponto>.*`, o objeto
|
|
18
|
+
* que aceita chaves não declaradas (`additionalProperties: true`, do `.passthrough()` do zod).
|
|
18
19
|
*/
|
|
19
20
|
open: string[]
|
|
20
21
|
}
|
|
@@ -45,18 +45,26 @@ function isNullOnly(schema) {
|
|
|
45
45
|
const NO_REFS = new Set();
|
|
46
46
|
/** Marca o ponto em que um `$ref` volta a um schema que já está no caminho atual. */
|
|
47
47
|
const CYCLE = Object.freeze({ cycle: true });
|
|
48
|
+
/** Marca o ponto em que um `$ref` não resolve dentro do schema: não há forma a conferir ali. */
|
|
49
|
+
const UNRESOLVED = Object.freeze({ unresolved: true });
|
|
50
|
+
|
|
51
|
+
/** Um dos marcadores de `expand`, e não um schema de verdade. */
|
|
52
|
+
function isMarker(schema) {
|
|
53
|
+
return schema === CYCLE || schema === UNRESOLVED;
|
|
54
|
+
}
|
|
48
55
|
|
|
49
56
|
/**
|
|
50
57
|
* Os ramos concretos e não nulos de um schema, com `$ref` e combinadores expandidos. Cada ramo traz
|
|
51
58
|
* os `$ref` percorridos no caminho atual; um `$ref` que se repete ali vira `CYCLE`, em vez de
|
|
52
|
-
* expandir de novo.
|
|
59
|
+
* expandir de novo, e um `$ref` que não resolve vira `UNRESOLVED`, em vez de sumir.
|
|
53
60
|
*/
|
|
54
61
|
function expand(schema, root, active = NO_REFS, depth = 0) {
|
|
55
62
|
if (schema === null || typeof schema !== "object" || depth > MAX_DEPTH) return [];
|
|
56
63
|
if (typeof schema.$ref === "string") {
|
|
57
64
|
if (active.has(schema.$ref)) return [{ schema: CYCLE, active }];
|
|
58
65
|
const target = resolveRef(root, schema.$ref);
|
|
59
|
-
|
|
66
|
+
if (target === undefined) return [{ schema: UNRESOLVED, active }];
|
|
67
|
+
return expand(target, root, new Set([...active, schema.$ref]), depth + 1);
|
|
60
68
|
}
|
|
61
69
|
const nested = COMBINATORS.flatMap((key) =>
|
|
62
70
|
Array.isArray(schema[key]) ? schema[key].flatMap((branch) => expand(branch, root, active, depth + 1)) : [],
|
|
@@ -68,9 +76,9 @@ function expand(schema, root, active = NO_REFS, depth = 0) {
|
|
|
68
76
|
return [{ schema, active }, ...nested];
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
/** Os ramos sem os
|
|
79
|
+
/** Os ramos sem os marcadores, para quem só olha o tipo do próprio nó. */
|
|
72
80
|
function branches(schema, root) {
|
|
73
|
-
return expand(schema, root).flatMap(({ schema: branch }) => (branch
|
|
81
|
+
return expand(schema, root).flatMap(({ schema: branch }) => (isMarker(branch) ? [] : [branch]));
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
function isOpen(schema) {
|
|
@@ -89,7 +97,9 @@ function isArraySchema(schema) {
|
|
|
89
97
|
* Os schemas alcançados por `segments` em todos os ramos não nulos. Um schema sem forma alcança
|
|
90
98
|
* qualquer caminho abaixo dele e entra como está. Com `throughLists`, uma lista é atravessada. Um
|
|
91
99
|
* ciclo de `$ref` sem avançar no caminho (por lista ou união) não leva a lugar nenhum; ao descer
|
|
92
|
-
* por um campo, o caminho avança, e o mesmo `$ref` pode voltar, como numa árvore.
|
|
100
|
+
* por um campo, o caminho avança, e o mesmo `$ref` pode voltar, como numa árvore. Um `$ref` que não
|
|
101
|
+
* resolve antes do fim do caminho entra como `UNRESOLVED`: quem confere o caminho o descarta, e
|
|
102
|
+
* quem lista as folhas o marca como aberto.
|
|
93
103
|
*/
|
|
94
104
|
function collectTargets(schema, segments, root, throughLists, out, active = NO_REFS, depth = 0) {
|
|
95
105
|
if (depth > MAX_DEPTH) return;
|
|
@@ -99,7 +109,7 @@ function collectTargets(schema, segments, root, throughLists, out, active = NO_R
|
|
|
99
109
|
}
|
|
100
110
|
for (const { schema: branch, active: refs } of expand(schema, root, active)) {
|
|
101
111
|
if (branch === CYCLE) continue;
|
|
102
|
-
if (isOpen(branch)) {
|
|
112
|
+
if (branch === UNRESOLVED || isOpen(branch)) {
|
|
103
113
|
out.push(branch);
|
|
104
114
|
continue;
|
|
105
115
|
}
|
|
@@ -127,7 +137,7 @@ function collectTargets(schema, segments, root, throughLists, out, active = NO_R
|
|
|
127
137
|
export function schemaHasPath(schema, segments, root = schema) {
|
|
128
138
|
const targets = [];
|
|
129
139
|
collectTargets(schema, segments, root, true, targets);
|
|
130
|
-
return targets.
|
|
140
|
+
return targets.some((target) => target !== UNRESOLVED);
|
|
131
141
|
}
|
|
132
142
|
|
|
133
143
|
const TITLE_TYPES = new Set(["string", "number", "integer"]);
|
|
@@ -139,19 +149,24 @@ const TITLE_TYPES = new Set(["string", "number", "integer"]);
|
|
|
139
149
|
export function schemaTitleIsText(schema, segments, root = schema) {
|
|
140
150
|
const targets = [];
|
|
141
151
|
collectTargets(schema, segments, root, false, targets);
|
|
142
|
-
return targets.some(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
return targets.some(
|
|
153
|
+
(target) =>
|
|
154
|
+
target !== UNRESOLVED &&
|
|
155
|
+
(isOpen(target) ||
|
|
156
|
+
branches(target, root).some((branch) => {
|
|
157
|
+
if (isOpen(branch)) return true;
|
|
158
|
+
const types = Array.isArray(branch.type) ? branch.type : [branch.type];
|
|
159
|
+
return types.some((type) => TITLE_TYPES.has(type));
|
|
160
|
+
})),
|
|
149
161
|
);
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
/**
|
|
153
|
-
* As folhas abaixo de `schema`.
|
|
154
|
-
*
|
|
165
|
+
* As folhas abaixo de `schema`. Entram em `open`, no ponto em que aparecem, o schema sem forma, o
|
|
166
|
+
* `$ref` que volta a um schema já no caminho atual (uma estrutura recursiva) e o `$ref` que não
|
|
167
|
+
* resolve: abaixo deles, qualquer campo pode chegar ao modelo. Um objeto que aceita chaves não
|
|
168
|
+
* declaradas (`additionalProperties: true`, como o `.passthrough()` do zod) entra como `<ponto>.*`,
|
|
169
|
+
* a mesma marca da chave de um mapa, porque a projeção copia o objeto inteiro.
|
|
155
170
|
*/
|
|
156
171
|
function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, depth = 0) {
|
|
157
172
|
if (depth > MAX_DEPTH) {
|
|
@@ -159,7 +174,7 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
|
|
|
159
174
|
return;
|
|
160
175
|
}
|
|
161
176
|
for (const { schema: branch, active: refs } of expand(schema, root, active)) {
|
|
162
|
-
if (branch
|
|
177
|
+
if (isMarker(branch) || isOpen(branch)) {
|
|
163
178
|
open.add(prefix);
|
|
164
179
|
continue;
|
|
165
180
|
}
|
|
@@ -176,10 +191,13 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
|
|
|
176
191
|
}
|
|
177
192
|
}
|
|
178
193
|
const additional = branch.additionalProperties;
|
|
179
|
-
|
|
194
|
+
const hasAdditional = additional === true || (additional !== null && typeof additional === "object");
|
|
195
|
+
if (additional === true) {
|
|
196
|
+
open.add(`${prefix}.*`);
|
|
197
|
+
} else if (hasAdditional) {
|
|
180
198
|
collectLeaves(additional, `${prefix}.*`, root, leaves, open, refs, depth + 1);
|
|
181
199
|
}
|
|
182
|
-
if (!hasProperties && !
|
|
200
|
+
if (!hasProperties && !hasAdditional) leaves.add(prefix);
|
|
183
201
|
}
|
|
184
202
|
}
|
|
185
203
|
|
|
@@ -190,8 +208,10 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
|
|
|
190
208
|
* ao modelo por um caminho que termina num contêiner.
|
|
191
209
|
*
|
|
192
210
|
* - `leaves`: caminhos das folhas, com ponto e sem índice de lista; `*` marca a chave de um mapa;
|
|
193
|
-
* - `open`: pontos
|
|
194
|
-
*
|
|
211
|
+
* - `open`: pontos por onde qualquer campo pode ir ao modelo sem que a conferência alcance: o schema
|
|
212
|
+
* que não declara forma (`z.unknown()`, `z.any()`), o `$ref` que volta a si mesmo ou que não
|
|
213
|
+
* resolve, e, como `<ponto>.*`, o objeto que aceita chaves não declaradas
|
|
214
|
+
* (`additionalProperties: true`, do `.passthrough()` do zod).
|
|
195
215
|
*/
|
|
196
216
|
export function assistantContextLeaves(output, context, root = output) {
|
|
197
217
|
return context.map((path) => {
|
|
@@ -225,6 +225,17 @@ camada uma vez. No modal, o Esc fecha o modal, como hoje. A alternativa de fecha
|
|
|
225
225
|
está visualmente por cima foi descartada: quem preenche o modal e aperta Esc quer sair do modal, não
|
|
226
226
|
do painel que nem está usando.
|
|
227
227
|
|
|
228
|
+
*Nota (2026-10-05):* a regra vale para a camada que tem o que fechar. Uma camada sem
|
|
229
|
+
`onEscapeKeyDown` não engole o Esc: com a pessoa nela, a tecla segue para o modal de trabalho, que
|
|
230
|
+
fecha como fecharia com a pessoa nele. Antes, o Opus marcava o evento e chamava um handler ausente,
|
|
231
|
+
e nem o modal nem a camada reagiam; quem chegava ao FAB pelo Tab e apertava Esc encontrava uma
|
|
232
|
+
tecla sem efeito. Com o handler, nada muda: a camada recebe a tecla e o modal continua aberto. O
|
|
233
|
+
consumidor que só tem o que fechar com o painel aberto passa o handler nesse estado e `undefined`
|
|
234
|
+
com o FAB, como a Grand Brasil já faz. A revisão também apontou o handler presente que não tem o que
|
|
235
|
+
fechar naquele momento; o Opus não consegue distinguir esse caso, e a forma de dizer “não há nada a
|
|
236
|
+
fechar aqui” é não passar o handler. Os testes do pacote cobrem o FAB focado e o clique numa área
|
|
237
|
+
sem foco da camada, com Dialog e Drawer, e o navegador real cobre o FAB alcançado pelo Tab.
|
|
238
|
+
|
|
228
239
|
Antes dessa regra vale a do Radix: um `DismissableLayer` aberto depois do modal consome o Esc, onde
|
|
229
240
|
quer que a pessoa esteja. Isso dá três exceções conhecidas e aceitas:
|
|
230
241
|
|
|
@@ -658,6 +669,12 @@ cada repositório, o que mantém uma só pilha de escopos e um só contexto de `
|
|
|
658
669
|
navegadores. O roteiro manual acima fica para a adoção na Grand Brasil, executado no Mac do João
|
|
659
670
|
(Safari com VoiceOver) e no Firefox antes de a adoção chegar à `main` da Grand Brasil, com o
|
|
660
671
|
resultado registrado na entrega da adoção.
|
|
672
|
+
|
|
673
|
+
*Nota (2026-10-05):* o roteiro manual não foi executado. Na adoção pela Grand Brasil, o dono do
|
|
674
|
+
produto decidiu seguir sem ele. A evidência da camada é a dos testes de navegador do pacote, no
|
|
675
|
+
Chromium real, somada aos testes da adoção. Safari, Firefox e VoiceOver continuam sem
|
|
676
|
+
verificação. Se aparecer em uso um problema de leitor de tela, de Safari ou de Firefox com a
|
|
677
|
+
camada sobre um modal, o roteiro acima volta a ser exigido.
|
|
661
678
|
- **Arquitetura:**
|
|
662
679
|
- todo uso de portal em `src/ui` lê o contêiner da camada;
|
|
663
680
|
- só `modal-layers.ts`, `dialog.tsx`, `drawer.tsx` e `floating-layer.tsx` aplicam as exceções;
|
|
@@ -666,3 +683,5 @@ cada repositório, o que mantém uma só pilha de escopos e um só contexto de `
|
|
|
666
683
|
- o teste de numeração de ADRs continua passando.
|
|
667
684
|
- **Na Grand Brasil:** um teste do shell confirma que o FAB e o painel ficam dentro de
|
|
668
685
|
`[data-slot="floating-layer"]`, e o roteiro de navegador acima roda na pilha de desenvolvimento.
|
|
686
|
+
*Nota (2026-10-05):* o roteiro foi dispensado na adoção e não rodou; ver a nota da mesma data no
|
|
687
|
+
item **Navegador real**.
|
|
@@ -355,7 +355,14 @@ aberto assim:
|
|
|
355
355
|
`assistantContextLeaves(output, context)`: a partir do JSON Schema da saída, como o manifest o
|
|
356
356
|
traz, devolve por caminho os campos folha alcançados e os pontos sem forma declarada ou em que um
|
|
357
357
|
`$ref` volta a si mesmo, por onde qualquer campo pode passar. O subpath publica tipos
|
|
358
|
-
(`assistant-schema.d.mts`) para um teste em TypeScript estrito.
|
|
358
|
+
(`assistant-schema.d.mts`) para um teste em TypeScript estrito. *Nota (2026-10-05):* dois pontos
|
|
359
|
+
por onde qualquer campo passa ficavam fora de `open`. Um `$ref` que não resolve dentro do schema
|
|
360
|
+
sumia sem aparecer em `leaves` nem em `open`; agora entra em `open` no ponto em que aparece. Um
|
|
361
|
+
objeto que aceita chaves não declaradas (`additionalProperties: true`, que o `.passthrough()` do
|
|
362
|
+
zod produz) era tratado como fechado, embora a projeção copie o objeto inteiro; agora entra em
|
|
363
|
+
`open` como `<caminho>.*`, a mesma marca da chave de um mapa. A conferência do `opus gen` não
|
|
364
|
+
muda: um caminho abaixo de um `$ref` que não resolve continua recusado, e ele não vale como
|
|
365
|
+
`title`.
|
|
359
366
|
|
|
360
367
|
## Consequências
|
|
361
368
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# ADR 0054 — Agrupamento da ActionList é um recorte declarado e consultado no servidor
|
|
2
2
|
|
|
3
|
-
- **Status:** aceita.
|
|
3
|
+
- **Status:** aceita, com a fonte de opções ampliada pela ADR 0055.
|
|
4
4
|
- **Data:** 2026-10-03.
|
|
5
5
|
- **Relacionadas:** ADR 0013 (Presentation orientada a action) e ADR 0024 (preenchimento das
|
|
6
|
-
regiões de lista).
|
|
6
|
+
regiões de lista). A ADR 0055 substitui somente a restrição desta decisão a opções estáticas ou
|
|
7
|
+
de dicionário; recorte no servidor, limite de cardinalidade e demais garantias permanecem.
|
|
7
8
|
|
|
8
9
|
## Contexto
|
|
9
10
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# ADR 0055 — Opções resolvidas em runtime podem formar grupos limitados da ActionList
|
|
2
|
+
|
|
3
|
+
- **Status:** aceita.
|
|
4
|
+
- **Data:** 2026-10-05.
|
|
5
|
+
- **Relacionada:** ADR 0054 (agrupamento da ActionList é um recorte declarado e consultado no
|
|
6
|
+
servidor).
|
|
7
|
+
|
|
8
|
+
## Contexto
|
|
9
|
+
|
|
10
|
+
A ADR 0054 limitou `grouping.fields` a filtros `select` com opções estáticas ou de dicionário. A
|
|
11
|
+
restrição garantia que a interface conhecesse todos os grupos antes de consultar a listagem e que o
|
|
12
|
+
custo não passasse de doze requisições paralelas.
|
|
13
|
+
|
|
14
|
+
Alguns catálogos fechados, porém administráveis, chegam ao consumidor como opções resolvidas em
|
|
15
|
+
runtime. Equipe, unidade e serviço são exemplos: continuam sendo dimensões enumeráveis no recorte
|
|
16
|
+
da tela, mas não pertencem ao contrato como literais nem a um dicionário versionado. Proibir esses
|
|
17
|
+
campos força o produto a duplicar dados administráveis no contrato; aceitar qualquer lookup, por
|
|
18
|
+
outro lado, permitiria agrupar por resultados parciais de uma busca e produziria contagens
|
|
19
|
+
enganosas.
|
|
20
|
+
|
|
21
|
+
## Decisão
|
|
22
|
+
|
|
23
|
+
### 1. O contrato aceita filtros enumeráveis de runtime
|
|
24
|
+
|
|
25
|
+
Um campo declarado em `grouping.fields` pode referenciar um filtro `select` ou `lookup`. As opções
|
|
26
|
+
estáticas continuam sendo validadas na declaração e precisam conter entre uma e doze entradas.
|
|
27
|
+
Filtros sem opções estáticas são estruturalmente válidos porque sua cardinalidade só é conhecida na
|
|
28
|
+
interface.
|
|
29
|
+
|
|
30
|
+
### 2. `filterOptions` precisa representar o catálogo completo do recorte
|
|
31
|
+
|
|
32
|
+
A `ActionList` só oferece a dimensão quando consegue resolver entre uma e doze opções. Para
|
|
33
|
+
`lookup`, isso exige que o consumidor forneça explicitamente em `filterOptions[campo]` o catálogo
|
|
34
|
+
completo aplicável à tela; os resultados transitórios da busca do próprio lookup não são usados
|
|
35
|
+
como grupos.
|
|
36
|
+
|
|
37
|
+
Se as opções estiverem ausentes, vazias ou acima do limite, a dimensão não aparece em **Agrupar
|
|
38
|
+
por** e um `groupBy` recebido por estado ou URL é ignorado. Não há agrupamento parcial silencioso.
|
|
39
|
+
|
|
40
|
+
### 3. O protocolo de consulta não muda
|
|
41
|
+
|
|
42
|
+
Cada opção continua produzindo um recorte da mesma `ListAction`, como definido na ADR 0054. O
|
|
43
|
+
limite de doze consultas paralelas, a exigência de `paginate: 'offset'`, o `total` autoritativo e a
|
|
44
|
+
paginação independente por grupo permanecem iguais.
|
|
45
|
+
|
|
46
|
+
Catálogos maiores precisam de uma futura fronteira agregada no servidor; esta decisão não antecipa
|
|
47
|
+
esse protocolo.
|
|
48
|
+
|
|
49
|
+
## Consequências
|
|
50
|
+
|
|
51
|
+
- Produtos podem agrupar por catálogos administráveis sem copiá-los para o contrato.
|
|
52
|
+
- O consumidor assume explicitamente que `filterOptions[campo]` é exaustivo para o recorte atual.
|
|
53
|
+
- Um lookup pesquisável ou de alta cardinalidade não se torna uma dimensão de agrupamento apenas
|
|
54
|
+
por estar declarado.
|
|
55
|
+
- Serviço deixa de ser oferecido como agrupamento quando o catálogo resolvido tiver mais de doze
|
|
56
|
+
opções; isso é uma limitação visível e segura, não uma amostra dos primeiros resultados.
|
|
57
|
+
|
package/docs/protocol.md
CHANGED
|
@@ -207,9 +207,11 @@ type OptionsSpec =
|
|
|
207
207
|
**`search`**
|
|
208
208
|
- `filters?: Record<string, FilterSpec>` — filtros disponíveis (chave é nome).
|
|
209
209
|
- `sort?`, `paginate?`, `text?`, `periods?`, `grouping?` — ver tipos abaixo.
|
|
210
|
-
- `grouping.fields` referencia filtros `select`
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
- `grouping.fields` referencia filtros enumeráveis `select` ou `lookup`. Opções estáticas e de
|
|
211
|
+
dicionário podem vir da declaração/provider; catálogos administráveis vêm de `filterOptions` no
|
|
212
|
+
consumidor. A interface só oferece dimensões com 1 a 12 opções completas e consulta cada valor
|
|
213
|
+
como um recorte da mesma action; não agrupa só a página carregada nem resultados parciais de uma
|
|
214
|
+
busca.
|
|
213
215
|
|
|
214
216
|
```ts
|
|
215
217
|
type FilterSpec = {
|
|
@@ -263,7 +265,10 @@ o dado necessário. A action `view` que lê o recurso declara as três coisas em
|
|
|
263
265
|
um intermediário `null` aparece como `null`. Num campo anulável ou numa união, o `opus gen` aceita
|
|
264
266
|
o caminho que existe em algum ramo não nulo. Um caminho que termina num objeto ou numa lista leva
|
|
265
267
|
tudo abaixo dele (`assistantContextLeaves`, de `@softize/opus/assistant-schema`, lista o que cada
|
|
266
|
-
caminho alcança
|
|
268
|
+
caminho alcança: em `leaves`, as folhas; em `open`, os pontos por onde qualquer campo passa sem
|
|
269
|
+
conferência, como um schema sem forma, um `$ref` recursivo ou que não resolve e, como
|
|
270
|
+
`<caminho>.*`, um objeto com `additionalProperties: true`). `title` aponta para texto ou número,
|
|
271
|
+
fora de lista.
|
|
267
272
|
- **Leitura.** `runtime.readResource(resource, id, base, { context? })` executa a action dona com
|
|
268
273
|
`{ id }` pelo `execute`: autorização, loaders, auditoria, marca de dado pessoal e span são os da
|
|
269
274
|
action. Devolve `{ ok: true, resource: { kind, id, title, label, skill }, context?, meta }` ou o
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import type { ListAction } from './types.ts'
|
|
|
3
3
|
const MAX_GROUP_OPTIONS = 12
|
|
4
4
|
|
|
5
5
|
/** Valida a parte estrutural de `ListAction.grouping` no momento da declaração.
|
|
6
|
-
* Dicionários
|
|
6
|
+
* Dicionários e opções resolvidas em runtime têm a cardinalidade conferida pela UI. */
|
|
7
7
|
export function assertValidListGrouping(
|
|
8
8
|
action: Pick<ListAction<unknown, unknown>, 'name' | 'filters' | 'grouping' | 'paginate'>,
|
|
9
9
|
): void {
|
|
@@ -26,14 +26,14 @@ export function assertValidListGrouping(
|
|
|
26
26
|
if (filter === undefined) {
|
|
27
27
|
throw new Error(`A action "${action.name}" agrupa por "${field}", mas esse filtro não existe.`)
|
|
28
28
|
}
|
|
29
|
-
if (filter.type !== 'select'
|
|
29
|
+
if (filter.type !== 'select' && filter.type !== 'lookup') {
|
|
30
30
|
throw new Error(
|
|
31
31
|
`A action "${action.name}" agrupa por "${field}", mas a dimensão precisa ser ` +
|
|
32
|
-
'um filtro select com opções
|
|
32
|
+
'um filtro select ou lookup com opções enumeráveis.',
|
|
33
33
|
)
|
|
34
34
|
}
|
|
35
35
|
if (
|
|
36
|
-
filter.options
|
|
36
|
+
filter.options?.kind === 'static' &&
|
|
37
37
|
(filter.options.items.length === 0 || filter.options.items.length > MAX_GROUP_OPTIONS)
|
|
38
38
|
) {
|
|
39
39
|
throw new Error(
|
package/src/core/types.ts
CHANGED
|
@@ -924,8 +924,8 @@ export interface SearchFilters {
|
|
|
924
924
|
paginate?: 'cursor' | 'offset' | false
|
|
925
925
|
text?: { fields: string[]; placeholder?: I18nRef }
|
|
926
926
|
periods?: PeriodSpec[]
|
|
927
|
-
/** Dimensões
|
|
928
|
-
* referencia um filtro select com opções
|
|
927
|
+
/** Dimensões enumeráveis que a ActionList pode usar para agrupar a tabela. Cada nome
|
|
928
|
+
* referencia um filtro select/lookup com até 12 opções declaradas ou resolvidas em runtime. */
|
|
929
929
|
grouping?: { fields: string[] }
|
|
930
930
|
/**
|
|
931
931
|
* Cláusula DSL aplicada **sempre** pelo repository do consumer (ex:
|
|
@@ -959,7 +959,7 @@ export interface ListAction<In, Out, ParsedIn = In>
|
|
|
959
959
|
paginate?: 'cursor' | 'offset' | false
|
|
960
960
|
text?: { fields: string[]; placeholder?: I18nRef }
|
|
961
961
|
periods?: PeriodSpec[]
|
|
962
|
-
/** Dimensões
|
|
962
|
+
/** Dimensões enumeráveis que a ActionList pode usar para agrupar a tabela. */
|
|
963
963
|
grouping?: { fields: string[] }
|
|
964
964
|
/**
|
|
965
965
|
* Ver `SearchFilters.where`. Baseline DSL aplicado pelo repository
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useQueries } from '@tanstack/react-query'
|
|
2
2
|
import { useRef } from 'react'
|
|
3
|
-
import type { ActionDef, ActionError, Paginated } from '../../../core/index.ts'
|
|
4
|
-
import { useClient } from '../../drivers/context.tsx'
|
|
3
|
+
import type { ActionDef, ActionError, ClientAdapter, Paginated } from '../../../core/index.ts'
|
|
5
4
|
|
|
6
5
|
export interface ListActionGroup<TItem> {
|
|
7
6
|
value: string
|
|
@@ -15,7 +14,8 @@ export interface ListActionGroup<TItem> {
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
/** Implementação interna do ActionList para executar um recorte paginado por grupo. */
|
|
18
|
-
export function
|
|
17
|
+
export function useGroupedListQueries<TItem = unknown, TInput extends Record<string, unknown> = Record<string, unknown>>(
|
|
18
|
+
client: ClientAdapter,
|
|
19
19
|
action: { name: string; kind: 'list' },
|
|
20
20
|
input: TInput,
|
|
21
21
|
field: string | null,
|
|
@@ -24,7 +24,6 @@ export function useGroupedListAction<TItem = unknown, TInput extends Record<stri
|
|
|
24
24
|
pages: Record<string, number>,
|
|
25
25
|
pageSize: number,
|
|
26
26
|
): ListActionGroup<TItem>[] {
|
|
27
|
-
const client = useClient('ActionList grouping')
|
|
28
27
|
const previous = useRef<{
|
|
29
28
|
field: string | null
|
|
30
29
|
groups: Map<string, { items: TItem[]; total: number }>
|
|
@@ -62,11 +62,11 @@ import {
|
|
|
62
62
|
} from "../../../core/index.ts";
|
|
63
63
|
import {
|
|
64
64
|
useDicts,
|
|
65
|
+
useGroupedListAction,
|
|
65
66
|
useListAction,
|
|
66
67
|
useLookupAction,
|
|
67
68
|
type DictLike,
|
|
68
69
|
} from "../../drivers/react.tsx";
|
|
69
|
-
import { useGroupedListAction } from "./grouped-list.ts";
|
|
70
70
|
import { cn } from "../../lib/cn.ts";
|
|
71
71
|
import { FillBoundary, useFillLeaf } from "./fill-region.ts";
|
|
72
72
|
import { objectSchemaShape } from "../../lib/object-schema.ts";
|
|
@@ -1779,6 +1779,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1779
1779
|
const raw = controlledState ?? internal;
|
|
1780
1780
|
const state: ActionListState = {
|
|
1781
1781
|
...raw,
|
|
1782
|
+
sort: raw.sort ?? defaultSort,
|
|
1782
1783
|
period: raw.period ?? null,
|
|
1783
1784
|
from: raw.from ?? null,
|
|
1784
1785
|
to: raw.to ?? null,
|
|
@@ -1818,11 +1819,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1818
1819
|
const availableGroupingFields = groupingFields.filter((field) => {
|
|
1819
1820
|
if (action.filters?.group !== undefined) return false;
|
|
1820
1821
|
const spec = action.filters?.[field];
|
|
1821
|
-
if (
|
|
1822
|
-
spec?.type !== "select" ||
|
|
1823
|
-
(spec.options?.kind !== "static" && spec.options?.kind !== "dictionary")
|
|
1824
|
-
)
|
|
1825
|
-
return false;
|
|
1822
|
+
if (spec?.type !== "select" && spec?.type !== "lookup") return false;
|
|
1826
1823
|
const options = [
|
|
1827
1824
|
...optionsFor(field, spec, filterOptions),
|
|
1828
1825
|
...dictOptionsFor(spec, dicts),
|
|
@@ -1841,6 +1838,10 @@ export function ActionList<TInput, TItem>({
|
|
|
1841
1838
|
availableGroupingFields.includes(requestedGroupBy)
|
|
1842
1839
|
? requestedGroupBy
|
|
1843
1840
|
: null;
|
|
1841
|
+
const activeGroupingLabel =
|
|
1842
|
+
activeGroupBy === null
|
|
1843
|
+
? null
|
|
1844
|
+
: text(action.filters?.[activeGroupBy]?.label, activeGroupBy);
|
|
1844
1845
|
const hasSegment = children === undefined && viewEntries.length > 1;
|
|
1845
1846
|
const periods = action.periods ?? [];
|
|
1846
1847
|
const hasToolbar =
|
|
@@ -2090,6 +2091,34 @@ export function ActionList<TInput, TItem>({
|
|
|
2090
2091
|
state.sort?.field === key && state.sort.dir === "asc" ? "desc" : "asc";
|
|
2091
2092
|
setStateResetPage({ ...state, sort: { field: key, dir } });
|
|
2092
2093
|
};
|
|
2094
|
+
const activeSort = state.sort;
|
|
2095
|
+
const sortLabelFor = (field: string): string | null => {
|
|
2096
|
+
const declared = action.columns?.find((column) => column.key === field);
|
|
2097
|
+
if (declared !== undefined) return text(declared.label, field);
|
|
2098
|
+
const explicit = columns?.find((column) => column.key === field)?.header;
|
|
2099
|
+
return typeof explicit === "string" ? explicit : null;
|
|
2100
|
+
};
|
|
2101
|
+
const sortableFields = (action.sort?.fields ?? []).flatMap((field) => {
|
|
2102
|
+
const label = sortLabelFor(field);
|
|
2103
|
+
return label === null ? [] : [{ value: field, label }];
|
|
2104
|
+
});
|
|
2105
|
+
const activeSortLabel =
|
|
2106
|
+
activeSort === null
|
|
2107
|
+
? null
|
|
2108
|
+
: sortLabelFor(activeSort.field);
|
|
2109
|
+
const activeSortDirection =
|
|
2110
|
+
activeSort?.dir === "asc" ? "crescente" : "decrescente";
|
|
2111
|
+
const sortCustomized =
|
|
2112
|
+
activeSort?.field !== defaultSort?.field ||
|
|
2113
|
+
activeSort?.dir !== defaultSort?.dir;
|
|
2114
|
+
const displayCustomized =
|
|
2115
|
+
activeGroupBy !== null ||
|
|
2116
|
+
state.columns !== null ||
|
|
2117
|
+
state.limit !== null ||
|
|
2118
|
+
sortCustomized;
|
|
2119
|
+
const displayLabel = displayCustomized
|
|
2120
|
+
? "Exibição personalizada"
|
|
2121
|
+
: "Exibição";
|
|
2093
2122
|
|
|
2094
2123
|
// — Multi-seleção (batch): `can` de QUALQUER ação torna a linha marcável; nenhuma
|
|
2095
2124
|
// elegível na página → a coluna de checkbox nem aparece. Seleção é transitória
|
|
@@ -2180,14 +2209,20 @@ export function ActionList<TInput, TItem>({
|
|
|
2180
2209
|
<Button
|
|
2181
2210
|
variant="ghost"
|
|
2182
2211
|
size="icon"
|
|
2183
|
-
className="shrink-0"
|
|
2184
|
-
aria-label=
|
|
2212
|
+
className="relative shrink-0"
|
|
2213
|
+
aria-label={displayLabel}
|
|
2185
2214
|
>
|
|
2186
2215
|
<Settings2 className="h-3.5 w-3.5" />
|
|
2216
|
+
{displayCustomized && (
|
|
2217
|
+
<Dot
|
|
2218
|
+
context="primary"
|
|
2219
|
+
className="absolute top-1 right-1 ring-2 ring-background"
|
|
2220
|
+
/>
|
|
2221
|
+
)}
|
|
2187
2222
|
</Button>
|
|
2188
2223
|
</PopoverTrigger>
|
|
2189
2224
|
</TooltipTrigger>
|
|
2190
|
-
<TooltipContent>
|
|
2225
|
+
<TooltipContent>{displayLabel}</TooltipContent>
|
|
2191
2226
|
</Tooltip>
|
|
2192
2227
|
<PopoverContent
|
|
2193
2228
|
align="end"
|
|
@@ -2195,6 +2230,93 @@ export function ActionList<TInput, TItem>({
|
|
|
2195
2230
|
data-slot="action-list-display"
|
|
2196
2231
|
>
|
|
2197
2232
|
<div className="space-y-3">
|
|
2233
|
+
{children === undefined &&
|
|
2234
|
+
activeView === "table" &&
|
|
2235
|
+
sortableFields.length > 0 && (
|
|
2236
|
+
<>
|
|
2237
|
+
<div className="space-y-2">
|
|
2238
|
+
<div className="flex items-center justify-between">
|
|
2239
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
2240
|
+
Ordenação
|
|
2241
|
+
</span>
|
|
2242
|
+
{sortCustomized && (
|
|
2243
|
+
<button
|
|
2244
|
+
type="button"
|
|
2245
|
+
onClick={() =>
|
|
2246
|
+
setState({ ...state, sort: defaultSort, page: 1 })
|
|
2247
|
+
}
|
|
2248
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
2249
|
+
>
|
|
2250
|
+
<Eraser className="size-3" />
|
|
2251
|
+
Padrão
|
|
2252
|
+
</button>
|
|
2253
|
+
)}
|
|
2254
|
+
</div>
|
|
2255
|
+
<Select
|
|
2256
|
+
aria-label="Ordenar por"
|
|
2257
|
+
size="sm"
|
|
2258
|
+
className="w-full"
|
|
2259
|
+
value={activeSort?.field ?? "__none__"}
|
|
2260
|
+
onChange={(field) =>
|
|
2261
|
+
setState({
|
|
2262
|
+
...state,
|
|
2263
|
+
sort:
|
|
2264
|
+
field === "__none__"
|
|
2265
|
+
? null
|
|
2266
|
+
: {
|
|
2267
|
+
field,
|
|
2268
|
+
dir:
|
|
2269
|
+
activeSort?.field === field
|
|
2270
|
+
? activeSort.dir
|
|
2271
|
+
: "asc",
|
|
2272
|
+
},
|
|
2273
|
+
page: 1,
|
|
2274
|
+
})
|
|
2275
|
+
}
|
|
2276
|
+
options={[
|
|
2277
|
+
...(defaultSort === null
|
|
2278
|
+
? [{ value: "__none__", label: "Sem ordenação" }]
|
|
2279
|
+
: []),
|
|
2280
|
+
...sortableFields,
|
|
2281
|
+
]}
|
|
2282
|
+
trailing={
|
|
2283
|
+
activeSort !== null ? (
|
|
2284
|
+
<Button
|
|
2285
|
+
type="button"
|
|
2286
|
+
variant="ghost"
|
|
2287
|
+
size="icon-xs"
|
|
2288
|
+
aria-label={
|
|
2289
|
+
activeSort.dir === "asc"
|
|
2290
|
+
? "Ordem crescente. Alterar para decrescente"
|
|
2291
|
+
: "Ordem decrescente. Alterar para crescente"
|
|
2292
|
+
}
|
|
2293
|
+
onClick={() =>
|
|
2294
|
+
setState({
|
|
2295
|
+
...state,
|
|
2296
|
+
sort: {
|
|
2297
|
+
...activeSort,
|
|
2298
|
+
dir:
|
|
2299
|
+
activeSort.dir === "asc"
|
|
2300
|
+
? "desc"
|
|
2301
|
+
: "asc",
|
|
2302
|
+
},
|
|
2303
|
+
page: 1,
|
|
2304
|
+
})
|
|
2305
|
+
}
|
|
2306
|
+
>
|
|
2307
|
+
{activeSort.dir === "asc" ? (
|
|
2308
|
+
<ArrowUp aria-hidden="true" />
|
|
2309
|
+
) : (
|
|
2310
|
+
<ArrowDown aria-hidden="true" />
|
|
2311
|
+
)}
|
|
2312
|
+
</Button>
|
|
2313
|
+
) : undefined
|
|
2314
|
+
}
|
|
2315
|
+
/>
|
|
2316
|
+
</div>
|
|
2317
|
+
<Separator />
|
|
2318
|
+
</>
|
|
2319
|
+
)}
|
|
2198
2320
|
{children === undefined &&
|
|
2199
2321
|
activeView === "table" &&
|
|
2200
2322
|
availableGroupingFields.length > 0 && (
|
|
@@ -2676,21 +2798,49 @@ export function ActionList<TInput, TItem>({
|
|
|
2676
2798
|
? Math.max(1, Math.ceil(total / (state.limit ?? pageSize)))
|
|
2677
2799
|
: 1;
|
|
2678
2800
|
const goTo = (page: number): void => setState({ ...state, page });
|
|
2801
|
+
const displaySummary =
|
|
2802
|
+
activeGroupingLabel !== null || activeSortLabel !== null ? (
|
|
2803
|
+
<span
|
|
2804
|
+
data-slot="action-list-display-summary"
|
|
2805
|
+
className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
|
|
2806
|
+
>
|
|
2807
|
+
{activeGroupingLabel !== null && (
|
|
2808
|
+
<span className="min-w-0 break-words">
|
|
2809
|
+
Agrupado por {activeGroupingLabel}
|
|
2810
|
+
</span>
|
|
2811
|
+
)}
|
|
2812
|
+
{activeGroupingLabel !== null && activeSortLabel !== null && (
|
|
2813
|
+
<span aria-hidden="true">·</span>
|
|
2814
|
+
)}
|
|
2815
|
+
{activeSortLabel !== null && (
|
|
2816
|
+
<span className="min-w-0 break-words">
|
|
2817
|
+
Ordenado por {activeSortLabel} ({activeSortDirection})
|
|
2818
|
+
</span>
|
|
2819
|
+
)}
|
|
2820
|
+
</span>
|
|
2821
|
+
) : null;
|
|
2679
2822
|
const footer =
|
|
2680
2823
|
total !== undefined && !isError && activeGroupBy !== null ? (
|
|
2681
2824
|
<div
|
|
2682
2825
|
data-slot="action-list-footer"
|
|
2683
|
-
className="flex min-h-10 items-center justify-
|
|
2826
|
+
className="flex min-h-10 flex-wrap items-center justify-between gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground"
|
|
2684
2827
|
>
|
|
2685
|
-
{
|
|
2828
|
+
{displaySummary}
|
|
2829
|
+
<span className={cn(displaySummary === null && "ml-auto")}>
|
|
2830
|
+
{total} {total === 1 ? "item" : "itens"} no total
|
|
2831
|
+
</span>
|
|
2686
2832
|
</div>
|
|
2687
2833
|
) : total !== undefined && !isError ? (
|
|
2688
2834
|
<div
|
|
2689
2835
|
data-slot="action-list-footer"
|
|
2690
|
-
className="grid min-h-10 grid-cols-[
|
|
2836
|
+
className="grid min-h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground"
|
|
2691
2837
|
>
|
|
2692
|
-
<span>
|
|
2693
|
-
|
|
2838
|
+
<span className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
|
2839
|
+
<span className="whitespace-nowrap">
|
|
2840
|
+
Página {state.page} de {pages}
|
|
2841
|
+
</span>
|
|
2842
|
+
{displaySummary !== null && <span aria-hidden="true">·</span>}
|
|
2843
|
+
{displaySummary}
|
|
2694
2844
|
</span>
|
|
2695
2845
|
{pages > 1 && (
|
|
2696
2846
|
// A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
|
|
@@ -2735,7 +2885,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2735
2885
|
)}
|
|
2736
2886
|
{/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
|
|
2737
2887
|
{pages <= 1 && <span />}
|
|
2738
|
-
<span className="justify-self-end">
|
|
2888
|
+
<span className="justify-self-end whitespace-nowrap">
|
|
2739
2889
|
{total} {total === 1 ? "item" : "itens"} no total
|
|
2740
2890
|
</span>
|
|
2741
2891
|
</div>
|
|
@@ -14,7 +14,9 @@ export interface FloatingLayerProps {
|
|
|
14
14
|
/**
|
|
15
15
|
* Esc enquanto a pessoa está na camada, com ou sem modal aberto. É chamado uma vez por tecla.
|
|
16
16
|
* Nenhum modal fecha nesse caso, e o Opus não fecha a camada sozinho, porque o estado de aberto
|
|
17
|
-
* pertence ao consumidor.
|
|
17
|
+
* pertence ao consumidor. Sem o handler, o Esc com a pessoa na camada fecha o modal de trabalho
|
|
18
|
+
* aberto: passe `undefined` enquanto a camada não tiver o que fechar, como com o painel fechado e
|
|
19
|
+
* só o botão que o abre.
|
|
18
20
|
*/
|
|
19
21
|
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
|
20
22
|
}
|
|
@@ -49,9 +51,7 @@ export function FloatingLayer({
|
|
|
49
51
|
const element = document.createElement("div");
|
|
50
52
|
element.setAttribute("data-slot", "floating-layer");
|
|
51
53
|
document.body.appendChild(element);
|
|
52
|
-
const unregister = registerFloatingLayer(element, (
|
|
53
|
-
escapeRef.current?.(event),
|
|
54
|
-
);
|
|
54
|
+
const unregister = registerFloatingLayer(element, () => escapeRef.current);
|
|
55
55
|
setHost(element);
|
|
56
56
|
// Desfaz o registro, retira o hospedeiro e, se o foco estava nele com um modal aberto,
|
|
57
57
|
// devolve o foco ao modal.
|
|
@@ -217,11 +217,11 @@ periods: [
|
|
|
217
217
|
|
|
218
218
|
## Agrupamento
|
|
219
219
|
|
|
220
|
-
Declare `grouping.fields` quando uma dimensão
|
|
221
|
-
de negócio. Cada campo precisa ser um filtro `select`
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
220
|
+
Declare `grouping.fields` quando uma dimensão enumerável puder organizar a tabela sem mudar o
|
|
221
|
+
recorte de negócio. Cada campo precisa ser um filtro `select` ou `lookup`; texto, número e data não
|
|
222
|
+
são dimensões de agrupamento. O filtro pode aceitar uma ou várias opções: cada consulta de grupo
|
|
223
|
+
envia um único valor na forma declarada. A lista continua plana por padrão, e a pessoa escolhe
|
|
224
|
+
**Agrupar por** em **Exibição**.
|
|
225
225
|
|
|
226
226
|
```ts
|
|
227
227
|
filters: {
|
|
@@ -242,6 +242,12 @@ autoritativa, e **Carregar mais** expande apenas aquele grupo. A soma aparece no
|
|
|
242
242
|
global. O limite é de doze opções por dimensão. Se um dicionário não estiver disponível ou exceder
|
|
243
243
|
esse limite, a dimensão não é oferecida em **Agrupar por**.
|
|
244
244
|
|
|
245
|
+
Opções administráveis também podem chegar por `filterOptions`. Nesse caso, o consumidor precisa
|
|
246
|
+
fornecer o catálogo completo aplicável ao recorte — não apenas os resultados correntes de uma busca.
|
|
247
|
+
Um `lookup` só é oferecido para agrupamento quando `filterOptions[campo]` contém de uma a doze
|
|
248
|
+
opções. Sem opções, ou acima desse limite, a dimensão e qualquer `groupBy` recebido pela URL são
|
|
249
|
+
ignorados; a lista nunca agrupa uma amostra parcial.
|
|
250
|
+
|
|
245
251
|
O agrupamento pertence à tabela derivada. Views alternativas e o modo `children` continuam planos.
|
|
246
252
|
`groupBy` integra `ActionListState`; `listStateToParams` o serializa como `group=<campo>`, e filtros
|
|
247
253
|
aplicados à mesma dimensão restringem os grupos exibidos.
|
|
@@ -427,9 +433,17 @@ filters: {
|
|
|
427
433
|
## Exibição e estado na URL
|
|
428
434
|
|
|
429
435
|
O botão de exibição abre as preferências da listagem em qualquer visualização. Na tabela, permite
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
436
|
+
escolher a ordenação, inverter sua direção, agrupar e mostrar ou ocultar colunas, inclusive as
|
|
437
|
+
declaradas com `hidden`; em todas as visualizações, permite alterar a quantidade de itens por
|
|
438
|
+
página. Um ponto sinaliza quando alguma preferência diverge do padrão. O rodapé explicita a
|
|
439
|
+
ordenação e o agrupamento ativos junto da paginação e do total. Busca, ordenação, filtros,
|
|
440
|
+
visualização, colunas e limite podem ser serializados na query string pelos helpers do componente:
|
|
441
|
+
|
|
442
|
+
Cada campo de `sort.fields` precisa de uma coluna com rótulo textual para aparecer no seletor e no
|
|
443
|
+
resumo do rodapé; a coluna pode estar `hidden`. Campos sem coluna continuam válidos para o handler e
|
|
444
|
+
para uma ordenação padrão, mas o Opus não expõe seu identificador técnico na interface. **Sem
|
|
445
|
+
ordenação** só aparece quando o contrato não declara `sort.default`; com padrão, use **Padrão** para
|
|
446
|
+
restaurá-lo.
|
|
433
447
|
|
|
434
448
|
```tsx
|
|
435
449
|
<ActionList
|
|
@@ -519,7 +533,7 @@ render(
|
|
|
519
533
|
| `text` | `{ fields }` — liga a busca; convenção: param `q` no input. Ela aparece depois dos filtros inline e imediatamente antes do acesso aos filtros avançados. A busca participa da contagem de controles inline: com mais de um controle, a barra exibe as labels; quando ela é o único recorte, sua label é omitida. |
|
|
520
534
|
| `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
|
|
521
535
|
| `periods` | `{ value, label, default? }[]` — o controle de período (presets + Personalizado com calendário); `default: true` marca o preset inicial, senão vale o primeiro; materializa em `from`/`to` no input. |
|
|
522
|
-
| `grouping` | `{ fields }` — dimensões
|
|
536
|
+
| `grouping` | `{ fields }` — dimensões enumeráveis disponíveis em **Agrupar por**. Cada nome referencia um filtro `select` ou `lookup` com até 12 opções declaradas ou resolvidas por `filterOptions`. Não define agrupamento inicial. |
|
|
523
537
|
|
|
524
538
|
## useListAction
|
|
525
539
|
|
|
@@ -538,7 +552,7 @@ chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem
|
|
|
538
552
|
| `input` | `TInput` | | O escopo base, como `{ workspaceId }`. A toolbar acrescenta busca, filtros e paginação sem sobrescrever essas chaves. |
|
|
539
553
|
| `layout` | `'flow' \| 'fill'` | `'fill'` como filha direta de `PageBody`, `SurfaceBody` ou do corpo da `Presentation` em uma página `fill`; `'flow'` nos demais casos | Em `fill`, ocupa a altura de um ancestral limitado e rola somente a região de resultados; na tabela, o cabeçalho fica fixo. Sem ancestral limitado, equivale a `flow`. |
|
|
540
554
|
| `cells` | `Record<string, (item) => ReactNode>` | | Células custom por cima das colunas do contrato (chave = column.key). |
|
|
541
|
-
| `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para
|
|
555
|
+
| `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para filtros select/lookup. Em uma dimensão de agrupamento, a coleção precisa ser completa e ter de 1 a 12 itens. |
|
|
542
556
|
| `filterDefaults` | `Record<string, string>` | | Valores de repouso aplicados à consulta; o `X` só aparece quando o filtro diverge deles e retorna a eles. |
|
|
543
557
|
| `filterIcons` | `Record<string, ReactNode>` | | Ícones leading dos filtros, indexados pelo nome declarado no contrato. |
|
|
544
558
|
| `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode>; sectionVariant?: 'plain' \| 'framed' }` | `{ columns: 1, sectionVariant: 'plain' }` | Define as colunas, os ícones e o tratamento dos grupos; o Opus deriva a largura correspondente do diálogo. |
|
|
@@ -225,7 +225,12 @@ caminho que passa por uma lista vale para cada item, e um campo intermediário `
|
|
|
225
225
|
endereço, e o nome já vai como `title`. Um caminho que termina num objeto ou numa lista leva tudo o
|
|
226
226
|
que está abaixo dele, inclusive campos acrescentados depois à saída; para conferir num teste o que
|
|
227
227
|
cada caminho alcança, use `assistantContextLeaves`, de `@softize/opus/assistant-schema`, sobre o
|
|
228
|
-
schema de saída do manifest.
|
|
228
|
+
schema de saída do manifest. Ela devolve, por caminho, as folhas em `leaves` e, em `open`, os pontos
|
|
229
|
+
que a conferência não alcança e por onde qualquer campo pode passar: um campo sem forma declarada
|
|
230
|
+
(`z.unknown()`, `z.any()`), uma estrutura recursiva, uma referência que não resolve e, como
|
|
231
|
+
`<caminho>.*`, um objeto que aceita chaves não declaradas (`.passthrough()`). Num teste que reprova
|
|
232
|
+
campo pessoal, trate também `open`: reprove cada ponto aberto ou justifique por que ele não leva dado
|
|
233
|
+
pessoal. O `title` precisa apontar para texto ou número.
|
|
229
234
|
|
|
230
235
|
O `opus check` cobra a forma da declaração (regra `assistant-declaration`), o `opus gen` recusa
|
|
231
236
|
`title`, caminho de `context` ou entrada que não conferem com os schemas, e o registro recusa duas
|
|
@@ -38,7 +38,7 @@ function AssistantDemo() {
|
|
|
38
38
|
</DialogContent>
|
|
39
39
|
</Dialog>
|
|
40
40
|
{editing && (
|
|
41
|
-
<FloatingLayer onEscapeKeyDown={() => setAssistant(false)}>
|
|
41
|
+
<FloatingLayer onEscapeKeyDown={assistant ? () => setAssistant(false) : undefined}>
|
|
42
42
|
{assistant ? (
|
|
43
43
|
<aside
|
|
44
44
|
aria-label="Assistente"
|
|
@@ -126,6 +126,11 @@ não recebe foco, nenhum modal fecha e o Opus chama `onEscapeKeyDown` uma vez po
|
|
|
126
126
|
no modal, o Esc fecha o modal, como sempre. Sem modal aberto, `onEscapeKeyDown` também é chamado
|
|
127
127
|
quando a pessoa está na camada.
|
|
128
128
|
|
|
129
|
+
Passe `onEscapeKeyDown` só enquanto a camada tiver o que fechar. Sem ele, o Esc com a pessoa na
|
|
130
|
+
camada segue para o modal aberto, que fecha. É o que permite a quem chega ao botão do assistente
|
|
131
|
+
pelo Tab sair do modal com o Esc. No exemplo acima, o handler só existe com o painel aberto: com o
|
|
132
|
+
painel, o Esc fecha o painel; com o botão, fecha o formulário.
|
|
133
|
+
|
|
129
134
|
Trate o Esc da camada só por `onEscapeKeyDown`. Para impedir que o modal feche, o Opus marca o evento
|
|
130
135
|
com `preventDefault` antes de ele chegar aos elementos da camada; um `onKeyDown` próprio para Esc no
|
|
131
136
|
painel trataria a tecla duas vezes ou leria um evento já marcado.
|
|
@@ -164,15 +169,17 @@ você.
|
|
|
164
169
|
- O hospedeiro carrega `aria-live="off"`, o valor padrão do atributo: a camada não anuncia mudanças
|
|
165
170
|
por conta própria, e regiões vivas de dentro dela, como a de um [`Ask`](/ui/ask), mantêm o próprio
|
|
166
171
|
comportamento. O atributo é o que mantém a camada visível ao leitor de tela com um modal aberto.
|
|
167
|
-
- O foco e o
|
|
168
|
-
Firefox e a leitura com VoiceOver
|
|
169
|
-
|
|
170
|
-
|
|
172
|
+
- O foco, o Tab e o Esc descritos acima são verificados em Chromium por testes automatizados.
|
|
173
|
+
Safari, Firefox e a leitura com VoiceOver não foram verificados. Em 2026-10-05, na adoção pela
|
|
174
|
+
Grand Brasil, o dono do produto dispensou o roteiro manual da ADR 0052 para esses navegadores, e
|
|
175
|
+
ele não foi executado. Se surgir um problema de leitor de tela, de Safari ou de Firefox com a
|
|
176
|
+
camada sobre um modal, o roteiro volta a ser exigido. Leitores de tela do Windows, como o NVDA,
|
|
177
|
+
ficam fora dessa verificação.
|
|
171
178
|
|
|
172
179
|
## Propriedades de FloatingLayer
|
|
173
180
|
|
|
174
|
-
| Propriedade | Tipo | Padrão | Descrição
|
|
175
|
-
| ----------------- | -------------------------------- | ------ |
|
|
176
|
-
| `children` | `ReactNode` | | Elementos da camada. Cada filho direto recebe clique e se posiciona contra a janela.
|
|
177
|
-
| `className` | `string` | | Classes do hospedeiro. A posição de cada filho continua sendo sua.
|
|
178
|
-
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` | | Chamado uma vez por tecla quando a pessoa aperta Esc na camada, com ou sem modal aberto. Nenhum modal fecha nesse caso, e o Opus não fecha a camada. |
|
|
181
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
182
|
+
| ----------------- | -------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
183
|
+
| `children` | `ReactNode` | | Elementos da camada. Cada filho direto recebe clique e se posiciona contra a janela. |
|
|
184
|
+
| `className` | `string` | | Classes do hospedeiro. A posição de cada filho continua sendo sua. |
|
|
185
|
+
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` | | Chamado uma vez por tecla quando a pessoa aperta Esc na camada, com ou sem modal aberto. Nenhum modal fecha nesse caso, e o Opus não fecha a camada. Sem ele, o Esc na camada fecha o modal aberto. |
|
package/src/ui/drivers/react.tsx
CHANGED
|
@@ -47,6 +47,10 @@ export {
|
|
|
47
47
|
} from './context.tsx'
|
|
48
48
|
import { zodErrorMapPtBr } from '../lib/zod-pt-br.ts'
|
|
49
49
|
import { objectSchemaShape } from '../lib/object-schema.ts'
|
|
50
|
+
import {
|
|
51
|
+
useGroupedListQueries,
|
|
52
|
+
type ListActionGroup,
|
|
53
|
+
} from '../components/patterns/grouped-list.ts'
|
|
50
54
|
|
|
51
55
|
// =============================================================================
|
|
52
56
|
// useAction
|
|
@@ -238,6 +242,32 @@ export function useListAction<TItem = unknown, TInput extends Record<string, unk
|
|
|
238
242
|
}
|
|
239
243
|
}
|
|
240
244
|
|
|
245
|
+
/** Implementação interna do ActionList para executar recortes paginados por grupo. */
|
|
246
|
+
export function useGroupedListAction<
|
|
247
|
+
TItem = unknown,
|
|
248
|
+
TInput extends Record<string, unknown> = Record<string, unknown>,
|
|
249
|
+
>(
|
|
250
|
+
action: { name: string; kind: 'list' },
|
|
251
|
+
input: TInput,
|
|
252
|
+
field: string | null,
|
|
253
|
+
values: string[],
|
|
254
|
+
multiple: boolean,
|
|
255
|
+
pages: Record<string, number>,
|
|
256
|
+
pageSize: number,
|
|
257
|
+
): ListActionGroup<TItem>[] {
|
|
258
|
+
const client = useClient('ActionList grouping')
|
|
259
|
+
return useGroupedListQueries<TItem, TInput>(
|
|
260
|
+
client,
|
|
261
|
+
action,
|
|
262
|
+
input,
|
|
263
|
+
field,
|
|
264
|
+
values,
|
|
265
|
+
multiple,
|
|
266
|
+
pages,
|
|
267
|
+
pageSize,
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
241
271
|
// =============================================================================
|
|
242
272
|
// useFormAction
|
|
243
273
|
// =============================================================================
|
|
@@ -12,7 +12,8 @@ import * as React from "react";
|
|
|
12
12
|
* - um único ouvinte no `body`, em borbulha, impede que `focusin`, `focusout`, `wheel` e `touchmove`
|
|
13
13
|
* ligados ao hospedeiro cheguem ao documento, onde a trava de foco do Radix e a trava de rolagem
|
|
14
14
|
* os veriam;
|
|
15
|
-
* - o Esc age onde a pessoa está: na camada
|
|
15
|
+
* - o Esc age onde a pessoa está: na camada que tem `onEscapeKeyDown`, chama esse handler e não
|
|
16
|
+
* fecha o modal; na camada sem handler, segue para o modal, que fecha como sempre;
|
|
16
17
|
* - o Tab passa do último elemento do modal para a camada e da camada de volta ao modal;
|
|
17
18
|
* - quando o elemento focado da camada some, o foco volta ao modal;
|
|
18
19
|
* - o hospedeiro carrega `aria-live="off"`, que faz o `hideOthers` do Radix preservá-lo.
|
|
@@ -37,9 +38,12 @@ interface ModalEntry {
|
|
|
37
38
|
content: HTMLElement | null;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/** O `onEscapeKeyDown` corrente da camada; `undefined` quando ela não tem o que fechar. */
|
|
42
|
+
export type LayerEscape = () => ((event: KeyboardEvent) => void) | undefined;
|
|
43
|
+
|
|
40
44
|
interface LayerRecord {
|
|
41
45
|
host: HTMLElement;
|
|
42
|
-
escape:
|
|
46
|
+
escape: LayerEscape;
|
|
43
47
|
dispose: () => void;
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -328,7 +332,7 @@ function removeGlobalListeners(): void {
|
|
|
328
332
|
*/
|
|
329
333
|
export function registerFloatingLayer(
|
|
330
334
|
host: HTMLElement,
|
|
331
|
-
escape:
|
|
335
|
+
escape: LayerEscape,
|
|
332
336
|
): () => void {
|
|
333
337
|
const onPointerDown = (event: PointerEvent): void => {
|
|
334
338
|
// Pertença decidida no aperto: no fim do clique, o elemento apertado pode já ter sumido.
|
|
@@ -354,7 +358,7 @@ export function registerFloatingLayer(
|
|
|
354
358
|
const onDocumentKeyDown = (event: KeyboardEvent): void => {
|
|
355
359
|
// Com um modal de trabalho aberto, a composição do modal já tratou o Esc e o marcou.
|
|
356
360
|
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
357
|
-
if (layerWherePersonIs() === record) escape(event);
|
|
361
|
+
if (layerWherePersonIs() === record) escape()?.(event);
|
|
358
362
|
};
|
|
359
363
|
const observer = new MutationObserver(() => {
|
|
360
364
|
if (pointerAnchor !== null && pointerAnchor.parentNode !== pointerLayer?.host) {
|
|
@@ -424,16 +428,20 @@ export function isLayerInteraction(event: {
|
|
|
424
428
|
}
|
|
425
429
|
|
|
426
430
|
/**
|
|
427
|
-
* Esc no modal de trabalho do topo com a pessoa
|
|
428
|
-
* tecla uma única vez. O `preventDefault` impede o Radix de fechar e marca
|
|
429
|
-
* da própria camada, que ignora eventos já tratados.
|
|
431
|
+
* Esc no modal de trabalho do topo com a pessoa numa camada que tem `onEscapeKeyDown`: nenhum modal
|
|
432
|
+
* fecha e a camada recebe a tecla uma única vez. O `preventDefault` impede o Radix de fechar e marca
|
|
433
|
+
* o evento para o ouvinte da própria camada, que ignora eventos já tratados.
|
|
434
|
+
*
|
|
435
|
+
* Numa camada sem handler, a camada não tem o que fechar, e o Esc segue para o modal (nota de
|
|
436
|
+
* 2026-10-05 na seção 5 da ADR 0052): engolir a tecla deixaria quem chega ao FAB pelo Tab sem
|
|
437
|
+
* nenhum efeito.
|
|
430
438
|
*/
|
|
431
439
|
export function routeEscapeToLayer(event: KeyboardEvent): boolean {
|
|
432
440
|
if (!layerExemptionsActive()) return false;
|
|
433
|
-
const
|
|
434
|
-
if (
|
|
441
|
+
const escape = layerWherePersonIs()?.escape();
|
|
442
|
+
if (escape === undefined) return false;
|
|
435
443
|
event.preventDefault();
|
|
436
|
-
|
|
444
|
+
escape(event);
|
|
437
445
|
return true;
|
|
438
446
|
}
|
|
439
447
|
|
package/src/ui/react.tsx
CHANGED
|
@@ -5,7 +5,31 @@
|
|
|
5
5
|
* `meta` de cada componente NÃO é re-exportado aqui de propósito — é metadado de
|
|
6
6
|
* build-time (lido pelo manifesto/MCP da fonte), fora do bundle de runtime.
|
|
7
7
|
*/
|
|
8
|
-
export
|
|
8
|
+
export {
|
|
9
|
+
OpusProvider,
|
|
10
|
+
useAction,
|
|
11
|
+
useDicts,
|
|
12
|
+
useFormAction,
|
|
13
|
+
useListAction,
|
|
14
|
+
useLookupAction,
|
|
15
|
+
useTriggerAction,
|
|
16
|
+
useViewAction,
|
|
17
|
+
} from "./drivers/react.tsx";
|
|
18
|
+
export type {
|
|
19
|
+
ActionError,
|
|
20
|
+
ActionState,
|
|
21
|
+
DictLike,
|
|
22
|
+
ListState,
|
|
23
|
+
OpusProviderProps,
|
|
24
|
+
UseActionResult,
|
|
25
|
+
UseFormActionOptions,
|
|
26
|
+
UseFormActionResult,
|
|
27
|
+
UseListActionResult,
|
|
28
|
+
UseLookupActionResult,
|
|
29
|
+
UseTriggerActionOptions,
|
|
30
|
+
UseTriggerActionResult,
|
|
31
|
+
UseViewActionResult,
|
|
32
|
+
} from "./drivers/react.tsx";
|
|
9
33
|
|
|
10
34
|
// Utilitário
|
|
11
35
|
export { cn } from "./lib/cn.ts";
|