@softize/opus 25.2.0 → 25.3.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 +38 -0
- package/bin/lib/assistant-schema.d.mts +3 -2
- package/bin/lib/assistant-schema.mjs +41 -21
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +2 -1
- package/bin/lib/gen-stubs.mjs +3 -0
- 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 +82 -0
- package/docs/protocol.md +9 -2
- package/package.json +1 -1
- package/src/core/actions.ts +5 -2
- package/src/core/contracts.ts +2 -0
- package/src/core/list-grouping.ts +45 -0
- package/src/core/runtime.ts +2 -0
- package/src/core/types.ts +5 -0
- package/src/ui/components/patterns/grouped-list.ts +88 -0
- package/src/ui/components/patterns/list.tsx +395 -61
- package/src/ui/components/primitives/floating-layer.tsx +4 -4
- package/src/ui/docs/content/action-list.md +42 -9
- package/src/ui/docs/content/actions.md +6 -1
- package/src/ui/docs/content/floating-layer.md +17 -10
- package/src/ui/drivers/context.tsx +50 -0
- package/src/ui/drivers/react.tsx +9 -69
- package/src/ui/lib/modal-layers.ts +18 -10
- package/src/ui/meta.ts +1 -1
|
@@ -215,6 +215,37 @@ periods: [
|
|
|
215
215
|
// presetRange('last7') → { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' } — o mesmo cálculo do pattern.
|
|
216
216
|
```
|
|
217
217
|
|
|
218
|
+
## Agrupamento
|
|
219
|
+
|
|
220
|
+
Declare `grouping.fields` quando uma dimensão fechada puder organizar a tabela sem mudar o recorte
|
|
221
|
+
de negócio. Cada campo precisa ser um filtro `select` com opções `static` ou `dictionary`; texto,
|
|
222
|
+
número, data e lookup não são dimensões de agrupamento. O filtro pode aceitar uma ou várias opções:
|
|
223
|
+
cada consulta de grupo envia um único valor na forma declarada. A lista continua plana por padrão,
|
|
224
|
+
e a pessoa escolhe **Agrupar por** em **Exibição**.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
filters: {
|
|
228
|
+
status: {
|
|
229
|
+
label: 'Status',
|
|
230
|
+
type: 'select',
|
|
231
|
+
options: { kind: 'dictionary', ref: 'workspace.status' },
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
grouping: { fields: ['status'] },
|
|
235
|
+
paginate: 'offset',
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
O Opus consulta a mesma `ListAction` uma vez para cada opção, aplicando o valor ao filtro e
|
|
239
|
+
preservando busca, período, demais filtros e ordenação. Por isso, o handler precisa devolver
|
|
240
|
+
`total` e a action precisa usar `paginate: 'offset'`: o cabeçalho recolhível apresenta a contagem
|
|
241
|
+
autoritativa, e **Carregar mais** expande apenas aquele grupo. A soma aparece no rodapé, sem paginador
|
|
242
|
+
global. O limite é de doze opções por dimensão. Se um dicionário não estiver disponível ou exceder
|
|
243
|
+
esse limite, a dimensão não é oferecida em **Agrupar por**.
|
|
244
|
+
|
|
245
|
+
O agrupamento pertence à tabela derivada. Views alternativas e o modo `children` continuam planos.
|
|
246
|
+
`groupBy` integra `ActionListState`; `listStateToParams` o serializa como `group=<campo>`, e filtros
|
|
247
|
+
aplicados à mesma dimensão restringem os grupos exibidos.
|
|
248
|
+
|
|
218
249
|
## Paginação e carregamento
|
|
219
250
|
|
|
220
251
|
`ActionList` envia `limit`, definido por `pageSize`, e `page` no input. O handler aplica o recorte e
|
|
@@ -481,21 +512,23 @@ render(
|
|
|
481
512
|
|
|
482
513
|
## Declaração na ListAction
|
|
483
514
|
|
|
484
|
-
| Chave
|
|
485
|
-
|
|
|
486
|
-
| `columns`
|
|
487
|
-
| `filters`
|
|
488
|
-
| `text`
|
|
489
|
-
| `sort`
|
|
490
|
-
| `periods`
|
|
515
|
+
| Chave | O que declara |
|
|
516
|
+
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
517
|
+
| `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat?, dictionary?, empty? }` — a tabela. `hidden` nasce oculta e pode ser exibida pelo seletor de colunas; `dictionary` nomeia o dicionário do provider; `empty` dá o significado da ausência. |
|
|
518
|
+
| `filters` | `{ [nome]: { label, type, options?, multiple?, presentation?, searchable?, allowEmpty?, placement?, section?, depends?, … } }` — a toolbar. `placement` classifica o controle como `inline`, `advanced` ou `external`; filtros avançados com `section` são agrupados no diálogo; `external` mantém estado e input, mas delega a apresentação ao consumidor. `presentation: 'toggle'` mostra todas as opções curtas de um `select` de escolha única em um `ToggleGroup`; sem a propriedade, o controle continua sendo `Select`. `searchable` habilita busca local em `select`; `allowEmpty: false` omite a opção vazia. `depends` desabilita/cascateia; options `kind: 'lookup'` busca em uma action. Valor aplicado entra no input com o mesmo nome. |
|
|
519
|
+
| `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
|
+
| `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
|
|
521
|
+
| `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 fechadas disponíveis em **Agrupar por**. Cada nome referencia um filtro `select` com opções estáticas ou de dicionário. Não define agrupamento inicial. |
|
|
491
523
|
|
|
492
524
|
## useListAction
|
|
493
525
|
|
|
494
|
-
Quando o layout não cabe no `ActionList`, `useListAction(action, input)` entrega o mesmo fetch
|
|
526
|
+
Quando o layout não cabe no `ActionList`, `useListAction(action, input, options?)` entrega o mesmo fetch
|
|
495
527
|
declarativo (`items`, `total`, `error`, `isLoading`, `isFetching`, `refetch`) sob o
|
|
496
528
|
`QueryClientProvider`, e `presetRange(preset)` materializa os presets de período (`today`, `last7`,
|
|
497
529
|
`thisMonth`…) em `{ from, to }` no formato `YYYY-MM-DD` — o mesmo cálculo que o pattern faz antes de
|
|
498
|
-
chamar a action.
|
|
530
|
+
chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem a opção, vale
|
|
531
|
+
`true`.
|
|
499
532
|
|
|
500
533
|
## Propriedades de ActionList
|
|
501
534
|
|
|
@@ -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. |
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
|
2
|
+
import type { ClientAdapter } from '../../core/index.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dicionário estrutural pro resolvedor de opções: qualquer coisa com `options()`
|
|
6
|
+
* satisfaz o contrato sem este driver importar o driver de schema.
|
|
7
|
+
*/
|
|
8
|
+
export interface DictLike {
|
|
9
|
+
options(): Array<{ value: string; label: string; icon?: string }>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface OpusContextValue {
|
|
13
|
+
client: ClientAdapter
|
|
14
|
+
dicts: Record<string, DictLike>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const OpusContext = createContext<OpusContextValue | null>(null)
|
|
18
|
+
|
|
19
|
+
export interface OpusProviderProps {
|
|
20
|
+
client: ClientAdapter
|
|
21
|
+
/** Dicionários por ref usados por filtros e campos declarativos. */
|
|
22
|
+
dicts?: Record<string, DictLike>
|
|
23
|
+
children: ReactNode
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function OpusProvider({ client, dicts, children }: OpusProviderProps): ReactNode {
|
|
27
|
+
const value = useMemo<OpusContextValue>(() => ({ client, dicts: dicts ?? {} }), [client, dicts])
|
|
28
|
+
return <OpusContext.Provider value={value}>{children}</OpusContext.Provider>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Acesso interno ao adapter; não é reexportado por `@softize/opus/ui/react`. */
|
|
32
|
+
export function useClient(hook: string): ClientAdapter {
|
|
33
|
+
const ctx = useContext(OpusContext)
|
|
34
|
+
if (ctx === null) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`${hook} foi chamado fora de um <OpusProvider> e não tem como executar a action. ` +
|
|
37
|
+
'Os hooks de action e os componentes que os usam, como ActionForm, ActionList e ActionView, ' +
|
|
38
|
+
'precisam de um <OpusProvider client={...}> acima deles na árvore. Envolva a aplicação no provider; ' +
|
|
39
|
+
'em testes e previews, envolva o componente renderizado.',
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return ctx.client
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NO_DICTS: Record<string, DictLike> = {}
|
|
46
|
+
|
|
47
|
+
/** Dicionários do provider; fora dele, o resolvedor usa os fallbacks do contrato. */
|
|
48
|
+
export function useDicts(): Record<string, DictLike> {
|
|
49
|
+
return useContext(OpusContext)?.dicts ?? NO_DICTS
|
|
50
|
+
}
|
package/src/ui/drivers/react.tsx
CHANGED
|
@@ -12,12 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
-
createContext,
|
|
16
15
|
useCallback,
|
|
17
|
-
useContext,
|
|
18
16
|
useMemo,
|
|
19
17
|
useState,
|
|
20
|
-
type ReactNode,
|
|
21
18
|
} from 'react'
|
|
22
19
|
import {
|
|
23
20
|
useForm,
|
|
@@ -32,7 +29,6 @@ import type {
|
|
|
32
29
|
ActionContract,
|
|
33
30
|
ActionDef,
|
|
34
31
|
ActionError,
|
|
35
|
-
ClientAdapter,
|
|
36
32
|
FormContract,
|
|
37
33
|
Paginated,
|
|
38
34
|
} from '../../core/index.ts'
|
|
@@ -42,74 +38,16 @@ import {
|
|
|
42
38
|
idleAction,
|
|
43
39
|
idleLookup,
|
|
44
40
|
} from '../index.ts'
|
|
41
|
+
import { useClient } from './context.tsx'
|
|
42
|
+
export {
|
|
43
|
+
OpusProvider,
|
|
44
|
+
useDicts,
|
|
45
|
+
type DictLike,
|
|
46
|
+
type OpusProviderProps,
|
|
47
|
+
} from './context.tsx'
|
|
45
48
|
import { zodErrorMapPtBr } from '../lib/zod-pt-br.ts'
|
|
46
49
|
import { objectSchemaShape } from '../lib/object-schema.ts'
|
|
47
50
|
|
|
48
|
-
// =============================================================================
|
|
49
|
-
// Provider
|
|
50
|
-
// =============================================================================
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Dicionário estrutural pro resolvedor de `options: { kind: 'dictionary', ref }`:
|
|
54
|
-
* qualquer coisa com `options()` → `{ value, label, icon? }`. O `DictType` do `t.dict`
|
|
55
|
-
* satisfaz direto — sem este driver importar o driver de schema. A metadata opcional
|
|
56
|
-
* continua sendo declarada pelo dicionário; o provider não inventa apresentação.
|
|
57
|
-
*/
|
|
58
|
-
export interface DictLike {
|
|
59
|
-
options(): Array<{ value: string; label: string; icon?: string }>
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface OpusContextValue {
|
|
63
|
-
client: ClientAdapter
|
|
64
|
-
dicts: Record<string, DictLike>
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const OpusContext = createContext<OpusContextValue | null>(null)
|
|
68
|
-
|
|
69
|
-
export interface OpusProviderProps {
|
|
70
|
-
client: ClientAdapter
|
|
71
|
-
/** Dicionários por ref — resolvem `options: { kind: 'dictionary', ref }` de
|
|
72
|
-
* filters/fields (ActionList/ActionForm). Campo de dict sem registro aqui ainda
|
|
73
|
-
* resolve pelo fallback (a meta do `t.dict` viaja no schema do contrato). */
|
|
74
|
-
dicts?: Record<string, DictLike>
|
|
75
|
-
children: ReactNode
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function OpusProvider({
|
|
79
|
-
client,
|
|
80
|
-
dicts,
|
|
81
|
-
children,
|
|
82
|
-
}: OpusProviderProps): ReactNode {
|
|
83
|
-
const value = useMemo<OpusContextValue>(() => ({ client, dicts: dicts ?? {} }), [client, dicts])
|
|
84
|
-
return <OpusContext.Provider value={value}>{children}</OpusContext.Provider>
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* O adapter do provider. `hook` nomeia quem chamou, para a mensagem apontar o hook que a
|
|
89
|
-
* pessoa vê no stack — muitas vezes chamado por dentro de ActionForm, ActionList ou
|
|
90
|
-
* ActionView, e não diretamente pela tela.
|
|
91
|
-
*/
|
|
92
|
-
function useClient(hook: string): ClientAdapter {
|
|
93
|
-
const ctx = useContext(OpusContext)
|
|
94
|
-
if (ctx === null) {
|
|
95
|
-
throw new Error(
|
|
96
|
-
`${hook} foi chamado fora de um <OpusProvider> e não tem como executar a action. ` +
|
|
97
|
-
'Os hooks de action e os componentes que os usam, como ActionForm, ActionList e ActionView, ' +
|
|
98
|
-
'precisam de um <OpusProvider client={...}> acima deles na árvore. Envolva a aplicação no provider; ' +
|
|
99
|
-
'em testes e previews, envolva o componente renderizado.',
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
return ctx.client
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const NO_DICTS: Record<string, DictLike> = {}
|
|
106
|
-
|
|
107
|
-
/** Dicts do provider. Default {} — inclusive fora do provider: o resolvedor cai
|
|
108
|
-
* nos fallbacks (estáticas do spec, meta do `t.dict`) sem exigir registry. */
|
|
109
|
-
export function useDicts(): Record<string, DictLike> {
|
|
110
|
-
return useContext(OpusContext)?.dicts ?? NO_DICTS
|
|
111
|
-
}
|
|
112
|
-
|
|
113
51
|
// =============================================================================
|
|
114
52
|
// useAction
|
|
115
53
|
// =============================================================================
|
|
@@ -265,6 +203,7 @@ export interface UseListActionResult<TItem> {
|
|
|
265
203
|
export function useListAction<TItem = unknown, TInput extends Record<string, unknown> = Record<string, unknown>>(
|
|
266
204
|
action: { name: string; kind: 'list' },
|
|
267
205
|
input?: TInput,
|
|
206
|
+
options?: { enabled?: boolean },
|
|
268
207
|
): UseListActionResult<TItem> {
|
|
269
208
|
const client = useClient('useListAction')
|
|
270
209
|
|
|
@@ -278,6 +217,7 @@ export function useListAction<TItem = unknown, TInput extends Record<string, unk
|
|
|
278
217
|
// Input mudou (filtro/página) → mantém os itens na tela enquanto busca: o
|
|
279
218
|
// ActionList esmaece em vez de piscar skeleton. isPending = só o 1º load.
|
|
280
219
|
placeholderData: keepPreviousData,
|
|
220
|
+
enabled: options?.enabled ?? true,
|
|
281
221
|
})
|
|
282
222
|
|
|
283
223
|
const refetch = useCallback(async () => {
|
|
@@ -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/meta.ts
CHANGED
|
@@ -388,7 +388,7 @@ export const componentMeta = {
|
|
|
388
388
|
name: "action-list",
|
|
389
389
|
ancestry: "opus",
|
|
390
390
|
whenToUse:
|
|
391
|
-
"Apresente uma coleção pesquisável a partir de uma ListAction. O contrato declara colunas, filtros, período e
|
|
391
|
+
"Apresente uma coleção pesquisável a partir de uma ListAction. O contrato declara colunas, filtros, período, busca e dimensões fechadas de agrupamento; ActionList coordena visualizações, paginação, seleção em lote e sincronização com a URL. A tabela derivada não cria moldura própria. Em uma página, envolva a lista com Surface para manter título e criação junto da coleção. Para um único recurso, use ActionView.",
|
|
392
392
|
},
|
|
393
393
|
"action-trigger": {
|
|
394
394
|
name: "action-trigger",
|