@softize/opus 25.7.0 → 26.1.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 +95 -0
- package/bin/lib/check.mjs +9 -32
- package/bin/lib/copy.mjs +7 -4
- package/docs/adr/0014-structural-headers-do-not-carry-description.md +1 -1
- package/docs/adr/0018-modal-header-only-names-the-surface.md +1 -1
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +4 -0
- package/docs/adr/0058-mensagem-da-ia-aceita-imagem-e-o-chat-recebe-anexos.md +201 -0
- package/docs/adr/0059-subheadings-orient-without-carrying-business-rules.md +51 -0
- package/docs/adr/README.md +2 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/references/evaluations.md +8 -0
- package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -4
- package/src/ai/drivers/anthropic.ts +49 -9
- package/src/ai/index.ts +5 -2
- package/src/core/ai-message.ts +20 -0
- package/src/core/index.ts +5 -0
- package/src/core/types.ts +28 -4
- package/src/ui/components/patterns/action-list-dialog.tsx +14 -7
- package/src/ui/components/patterns/form-dialog.tsx +18 -7
- package/src/ui/components/patterns/form.tsx +36 -8
- package/src/ui/components/patterns/page.tsx +22 -0
- package/src/ui/components/patterns/surface-anatomy.ts +1 -1
- package/src/ui/components/primitives/chat.tsx +179 -22
- package/src/ui/components/primitives/composer.tsx +397 -7
- package/src/ui/components/primitives/dialog.tsx +28 -1
- package/src/ui/components/primitives/drawer.tsx +30 -2
- package/src/ui/docs/content/action-form-card.md +1 -1
- package/src/ui/docs/content/action-form-dialog.md +8 -0
- package/src/ui/docs/content/action-form.md +18 -5
- package/src/ui/docs/content/action-list-dialog.md +8 -0
- package/src/ui/docs/content/ai.md +46 -0
- package/src/ui/docs/content/chat.md +97 -2
- package/src/ui/docs/content/composer.md +144 -1
- package/src/ui/docs/content/dialog.md +24 -2
- package/src/ui/docs/content/drawer.md +24 -2
- package/src/ui/docs/content/page.md +23 -1
- package/src/ui/docs/content/surface.md +8 -2
- package/src/ui/lib/modal-layers.ts +27 -3
- package/src/ui/lib/prepare-image.ts +386 -0
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +21 -1
|
@@ -84,12 +84,21 @@ do mesmo peso visual. Nesse caso, o cancelamento muda para `outline`, salvo esco
|
|
|
84
84
|
`cancelVariant`. Para apresentar o formulário como uma seção delimitada da página, use
|
|
85
85
|
[`ActionFormCard`](/ui/action-form-card). As propriedades de cada wrapper estão na página dele.
|
|
86
86
|
|
|
87
|
-
##
|
|
87
|
+
## Hint visível ou ícone de help
|
|
88
88
|
|
|
89
|
-
`FieldSpec.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`FieldDescription`
|
|
89
|
+
Use `FieldSpec.hint` abaixo do controle quando a pessoa precisar da informação para preencher
|
|
90
|
+
corretamente ou prever o efeito da escolha. Exemplo: “Sem áreas selecionadas, a pasta aparece em
|
|
91
|
+
todas as áreas.” O texto permanece visível e participa do `aria-describedby` do controle, junto
|
|
92
|
+
do erro quando houver. Em uma composição própria, use `FieldDescription` com a mesma associação.
|
|
93
|
+
|
|
94
|
+
Use `FieldSpec.help` no ícone junto à label para detalhes complementares, definições e exemplos
|
|
95
|
+
opcionais. A pergunta é: sem abrir essa ajuda, a pessoa ainda consegue escolher corretamente?
|
|
96
|
+
Se não, o trecho necessário deve ser hint. Não use o comprimento da frase como critério nem
|
|
97
|
+
esconda uma condição obrigatória no tooltip. Omita texto que apenas repete a label.
|
|
98
|
+
|
|
99
|
+
Hint e help podem coexistir se tiverem funções diferentes, sem duplicar a mesma frase. Um erro
|
|
100
|
+
explica uma entrada inválida e sua correção; não substitui uma orientação necessária antes de
|
|
101
|
+
preencher. Uma regra que afeta vários campos pertence ao corpo ou ao grupo correspondente.
|
|
93
102
|
|
|
94
103
|
`LabelHelp` é esse mesmo ícone, exportado para um formulário sem contrato mostrar a ajuda no mesmo
|
|
95
104
|
lugar:
|
|
@@ -185,3 +194,7 @@ ciclo de vida que o pattern não cobre; validação e execução continuam iguai
|
|
|
185
194
|
| Propriedade | Tipo | Descrição |
|
|
186
195
|
| ----------- | -------- | ------------------------------------------------------------ |
|
|
187
196
|
| `help` | `string` | O texto do tooltip. Vazio ou ausente, o ícone não renderiza. |
|
|
197
|
+
|
|
198
|
+
O `hint` aceita texto ou `I18nRef` e permanece visível; o contexto do formulário entrega o texto
|
|
199
|
+
resolvido aos campos personalizados. O botão de `help` participa do Tab e abre por foco ou hover.
|
|
200
|
+
Dialog e Drawer evitam que a ajuda seja o primeiro foco automático quando há outro controle.
|
|
@@ -96,6 +96,13 @@ const roleOptions = roles.items.map((role) => ({ value: role.id, label: role.nam
|
|
|
96
96
|
</ActionListDialog>;
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
## Orientação e contexto
|
|
100
|
+
|
|
101
|
+
`description` apresenta a finalidade no cabeçalho, como “Escolha onde a pasta e suas subpastas
|
|
102
|
+
aparecem.”. É opcional; não repita o título. `intro` apresenta no corpo informações necessárias
|
|
103
|
+
à decisão que não pertencem a um campo específico. Regras, condições e consequências não ficam
|
|
104
|
+
somente no subheading. Quando ambos são fornecidos, ambos participam de `aria-describedby`.
|
|
105
|
+
|
|
99
106
|
## Propriedades de ActionListDialog
|
|
100
107
|
|
|
101
108
|
| Propriedade | Tipo | Padrão | Descrição |
|
|
@@ -103,6 +110,7 @@ const roleOptions = roles.items.map((role) => ({ value: role.id, label: role.nam
|
|
|
103
110
|
| `action / input` | `ListAction / TInput` | `input`: `{}` | O contrato e o escopo base da consulta. Uma mudança em `input` refaz a consulta; as invalidações declaradas por forms e triggers também. |
|
|
104
111
|
| `open / onOpenChange` | `boolean / (open) => void` | | Estado controlado do modal. |
|
|
105
112
|
| `title` | `string` | | Nome da lista exibido no cabeçalho fixo. |
|
|
113
|
+
| `description` | `ReactNode` | | Orientação opcional sob o título; não recebe regras de negócio. |
|
|
106
114
|
| `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
|
|
107
115
|
| `children` | `(items, refetch) => ReactNode` | | A composição dos itens. Carregamento, erro e vazio já são tratados pelo `ActionList`. |
|
|
108
116
|
| `note` | `ReactNode` | | Contexto curto à esquerda da linha de ações. O total de itens já aparece no rodapé do `ActionList`. |
|
|
@@ -23,6 +23,7 @@ interface AiAdapter {
|
|
|
23
23
|
runStream?(input: string | AiMessage[], opts: AiRunOptions): AsyncIterable<ChatEvent>
|
|
24
24
|
}
|
|
25
25
|
// opts: { system?, model?, maxTokens?, temperature? } — tudo tem default do driver.
|
|
26
|
+
// AiMessage: { role: 'user' | 'assistant', content: string | AiContentPart[] } (ver Mensagem com imagem).
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
## Driver
|
|
@@ -125,6 +126,51 @@ O driver Anthropic emite o texto em trechos, à medida que chega, quando o clien
|
|
|
125
126
|
`messages.stream`. Com um client que só tem `create`, como um client falso de teste, o texto de cada
|
|
126
127
|
passo sai num único trecho. O componente [Chat](/ui/chat) consome os dois modos.
|
|
127
128
|
|
|
129
|
+
## Mensagem com imagem
|
|
130
|
+
|
|
131
|
+
Uma mensagem pode levar imagens junto com o texto, para o modelo ler um print, uma tabela ou um
|
|
132
|
+
gráfico. Nesse caso, `content` deixa de ser texto e passa a ser uma lista de partes, em uma forma que
|
|
133
|
+
não depende do provedor:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const messages: AiMessage[] = [
|
|
137
|
+
{
|
|
138
|
+
role: 'user',
|
|
139
|
+
content: [
|
|
140
|
+
{ type: 'text', text: 'Imagem 1: vendas.png' },
|
|
141
|
+
{ type: 'image', mediaType: 'image/png', data: base64 }, // sem o prefixo data:
|
|
142
|
+
{ type: 'text', text: 'Reproduza este relatório.' },
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
await runtime.aiFor(base)!.run(messages)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Os formatos aceitos são PNG, JPEG e WebP, e a imagem só pode estar em uma mensagem `user`. O driver
|
|
150
|
+
Anthropic converte cada parte para o bloco da API, em `run` e em `runStream`; uma parte que ele não
|
|
151
|
+
conhece, uma imagem em outro formato ou uma imagem na fala do assistente fazem a chamada falhar com
|
|
152
|
+
erro antes de chegar ao provedor, em vez de a imagem sumir em silêncio. `complete` e `extract`
|
|
153
|
+
continuam recebendo texto.
|
|
154
|
+
|
|
155
|
+
A parte de imagem não tem nome. Para que a pessoa e o modelo possam falar da “imagem 2”, ponha uma
|
|
156
|
+
parte de texto com o rótulo antes de cada imagem, como no exemplo. Imagens vão antes da pergunta,
|
|
157
|
+
como a Anthropic recomenda.
|
|
158
|
+
|
|
159
|
+
Quando precisar só do texto de uma mensagem, por exemplo para o título da conversa ou para montar um
|
|
160
|
+
bloco de contexto, use `aiMessageText(message)`. Ele devolve o texto puro ou as partes de texto unidas
|
|
161
|
+
por uma linha em branco, sem as imagens. Interpolar `content` direto num template produz
|
|
162
|
+
“[object Object]” quando a mensagem tem partes, e o compilador não aponta esse uso.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { aiMessageText } from '@softize/opus/ai'
|
|
166
|
+
|
|
167
|
+
const title = aiMessageText(messages[0]).slice(0, 80) || 'Conversa com imagem'
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
O pacote não guarda imagem. Enviar, guardar, expirar e mostrar de novo a imagem são do aplicativo; na
|
|
171
|
+
interface, o [Chat](/ui/chat#imagens-na-conversa) recebe as imagens prontas e mostra as que o
|
|
172
|
+
aplicativo devolve no transcript.
|
|
173
|
+
|
|
128
174
|
## Limites atuais
|
|
129
175
|
|
|
130
176
|
`run` e `runStream` aceitam o histórico da conversa, mas não mantêm memória longa nem resumem o
|
|
@@ -168,17 +168,111 @@ com o rótulo já humanizado pelo aplicativo; ou `''`, que mostra somente os pon
|
|
|
168
168
|
“Pensando…” como nome acessível. No modo autogerenciado, o Chat decide o indicador sozinho e ignora
|
|
169
169
|
`busy` e `activity`.
|
|
170
170
|
|
|
171
|
+
## Imagens na conversa
|
|
172
|
+
|
|
173
|
+
No modo controlado, a pessoa pode mandar imagens junto com o texto, como um print para a IA ler.
|
|
174
|
+
Passe `composerAttachments`, com a mesma forma do `attachments` do [Composer](/ui/composer#com-imagens-anexadas):
|
|
175
|
+
o composer ganha o botão **Anexar imagem** e a faixa de imagens, e soltar uma imagem em qualquer
|
|
176
|
+
ponto da conversa também anexa. `onSend` passa a receber, além do texto, as imagens prontas; a
|
|
177
|
+
mensagem pode ter só imagem, sem texto.
|
|
178
|
+
|
|
179
|
+
O aplicativo guarda as imagens e devolve cada uma no item `user` do transcript, em `attachments`, com
|
|
180
|
+
o endereço que a interface usa para mostrá-la. A fala da pessoa mostra as miniaturas acima do texto,
|
|
181
|
+
com o nome como texto alternativo. Quando a imagem não está mais disponível, o item traz
|
|
182
|
+
`status: 'expired'` e a conversa mostra um cartão com o nome e “Imagem expirada”.
|
|
183
|
+
|
|
184
|
+
```tsx preview
|
|
185
|
+
const nextId = useRef(2)
|
|
186
|
+
const [items, setItems] = useState<ChatTranscriptItem[]>([
|
|
187
|
+
{
|
|
188
|
+
id: '1',
|
|
189
|
+
role: 'user',
|
|
190
|
+
content: 'Esse era o painel da semana passada.',
|
|
191
|
+
attachments: [{ id: 'a0', name: 'painel-antigo.png', mediaType: 'image/png', status: 'expired' }],
|
|
192
|
+
},
|
|
193
|
+
])
|
|
194
|
+
const [pending, setPending] = useState<ComposerAttachment[]>([])
|
|
195
|
+
const sent = useRef<Record<string, { url: string; mediaType: string }>>({})
|
|
196
|
+
|
|
197
|
+
// Os endereços de objeto seguram as imagens na memória: revogue ao remover e ao desmontar.
|
|
198
|
+
function release(id: string) {
|
|
199
|
+
const image = sent.current[id]
|
|
200
|
+
if (image !== undefined) URL.revokeObjectURL(image.url)
|
|
201
|
+
delete sent.current[id]
|
|
202
|
+
}
|
|
203
|
+
useEffect(() => () => Object.keys(sent.current).forEach(release), [])
|
|
204
|
+
|
|
205
|
+
async function add(files: File[]) {
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
const id = `a${nextId.current++}`
|
|
208
|
+
const prepared = await prepareImage(file)
|
|
209
|
+
if (!prepared.ok) {
|
|
210
|
+
setPending((list) => [...list, { id, name: file.name, status: 'failed', error: prepared.message }])
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
// Num app real, a imagem vai ao servidor aqui, e o endereço devolvido é o da aplicação.
|
|
214
|
+
const url = URL.createObjectURL(prepared.file)
|
|
215
|
+
sent.current[id] = { url, mediaType: prepared.mediaType }
|
|
216
|
+
setPending((list) => [...list, { id, name: prepared.file.name, status: 'ready', previewUrl: url }])
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
render(
|
|
221
|
+
<div className="h-96 rounded-lg border">
|
|
222
|
+
<Chat
|
|
223
|
+
messages={items}
|
|
224
|
+
composerAttachments={{
|
|
225
|
+
items: pending,
|
|
226
|
+
onAddFiles: (files) => void add(files),
|
|
227
|
+
onRemove: (id) => {
|
|
228
|
+
release(id)
|
|
229
|
+
setPending((list) => list.filter((item) => item.id !== id))
|
|
230
|
+
},
|
|
231
|
+
}}
|
|
232
|
+
onSend={(text, attachments = []) => {
|
|
233
|
+
setItems((list) => [
|
|
234
|
+
...list,
|
|
235
|
+
{
|
|
236
|
+
id: String(nextId.current++),
|
|
237
|
+
role: 'user',
|
|
238
|
+
content: text,
|
|
239
|
+
attachments: attachments.map((a) => ({
|
|
240
|
+
id: a.id,
|
|
241
|
+
name: a.name,
|
|
242
|
+
status: 'available',
|
|
243
|
+
...sent.current[a.id],
|
|
244
|
+
})),
|
|
245
|
+
},
|
|
246
|
+
{ id: String(nextId.current++), role: 'assistant', content: 'Recebi a mensagem.' },
|
|
247
|
+
])
|
|
248
|
+
setPending([])
|
|
249
|
+
}}
|
|
250
|
+
/>
|
|
251
|
+
</div>,
|
|
252
|
+
)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
As miniaturas não passam pelo Markdown: a fala da pessoa continua sendo o que ela escreveu. Sirva o
|
|
256
|
+
endereço pela própria aplicação ou como `data:`; o [Markdown](/ui/markdown) do pacote não carrega
|
|
257
|
+
imagem de outra origem, e as miniaturas seguem o mesmo cuidado. O modo autogerenciado não aceita
|
|
258
|
+
imagens: ele não tem como enviá-las, e `composerAttachments` é ignorado nele.
|
|
259
|
+
|
|
260
|
+
O arraste de arquivos sobre a conversa é consumido pelo Chat e não sobe para os contêineres do
|
|
261
|
+
aplicativo: um contêiner com o próprio soltar, como uma área de envio de arquivos, não recebe a mesma
|
|
262
|
+
imagem duas vezes. Sem `composerAttachments`, o arraste segue para o contêiner, como antes.
|
|
263
|
+
|
|
171
264
|
## Propriedades de Chat
|
|
172
265
|
|
|
173
266
|
| Propriedade | Tipo | Padrão | Descrição |
|
|
174
267
|
|---|---|---|---|
|
|
175
268
|
| `send` | `(messages: ChatMessage[]) => Promise<string> \| AsyncIterable<ChatEvent>` | | Modo autogerenciado: recebe o histórico com a nova mensagem e devolve a resposta inteira ou um stream de eventos. Ignorado no modo controlado. |
|
|
176
269
|
| `messages` | `ChatTranscriptItem[]` | | Modo controlado: o transcript vem do aplicativo. Com ele, `onSend`, `busy` e `activity` passam a valer. |
|
|
177
|
-
| `onSend` | `(text: string) => void \| boolean \| Promise<void \| boolean>` | | Modo controlado: recebe o texto
|
|
270
|
+
| `onSend` | `(text: string, attachments?: ComposerAttachment[]) => void \| boolean \| Promise<void \| boolean>` | | Modo controlado: recebe o texto e, quando há `composerAttachments`, as imagens prontas. Retornar `false` devolve o texto ao composer; as imagens continuam na faixa. |
|
|
178
271
|
| `busy` | `boolean` | `false` | Modo controlado: bloqueia o envio enquanto o turno corre; a digitação continua liberada. |
|
|
179
272
|
| `activity` | `string \| null` | `undefined` | Modo controlado: indicador de atividade. `undefined` esconde, `null` mostra “Pensando…”, uma string mostra o rótulo e `''` mostra somente os pontos. |
|
|
180
273
|
| `notice` | `ReactNode` | | Aviso do aplicativo acima do composer, como credencial expirada ou agente desatualizado. |
|
|
181
274
|
| `composerActions` | `ReactNode` | | Seletores discretos na barra do composer, como agente, aplicativo ou escopo. |
|
|
275
|
+
| `composerAttachments` | `ComposerAttachments` | | Modo controlado: liga as imagens anexadas no composer e o soltar sobre a conversa. Ignorado no modo autogerenciado. |
|
|
182
276
|
| `composerClassName` | `string` | | Ajusta o contêiner externo do composer sem alcançar o DOM interno. |
|
|
183
277
|
| `greeting` | `string` | | Texto do estado vazio; some quando a conversa começa. |
|
|
184
278
|
| `empty` | `ReactNode` | | Estado vazio composto pelo aplicativo; vence `greeting` quando os dois existem. |
|
|
@@ -195,6 +289,7 @@ com o rótulo já humanizado pelo aplicativo; ou `''`, que mostra somente os pon
|
|
|
195
289
|
| Tipo | Forma | Uso |
|
|
196
290
|
|---|---|---|
|
|
197
291
|
| `ChatMessage` | `{ id?, createdAt?, role: 'user' \| 'assistant', content }` | Histórico enviado a `send` e aceito por `initialMessages`. `createdAt` é um instante ISO-8601. |
|
|
198
|
-
| `ChatTranscriptItem` | `ChatMessage` com `role` também `'error'
|
|
292
|
+
| `ChatTranscriptItem` | `ChatMessage` com `role` também `'error'` e `attachments?`, ou `{ id?, createdAt?, role: 'artifact', artifact: ChatArtifact }` | Itens de `messages` no modo controlado. Mensagens `error` aparecem destacadas no transcript; `attachments` só vale na fala `user`. |
|
|
293
|
+
| `ChatAttachment` | `{ id, name, mediaType, width?, height?, status: 'available' \| 'expired', url? }` | Imagem de uma fala da pessoa. `url` é o endereço fornecido pelo aplicativo; a imagem expirada não precisa dele. |
|
|
199
294
|
| `ChatTranscriptMessage` | Os itens de `ChatTranscriptItem` que têm `content` | Argumento de `renderMessageActions`. |
|
|
200
295
|
| `ChatArtifact` | `{ kind, ref, title? }` | Algo produzido na conversa, como um link ou um relatório; `ref` é o endereço ou identificador. |
|
|
@@ -63,13 +63,125 @@ render(
|
|
|
63
63
|
)
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
## Com imagens anexadas
|
|
67
|
+
|
|
68
|
+
Quando a mensagem pode levar imagens, como um print de relatório para a IA reproduzir, passe
|
|
69
|
+
`attachments`. O composer ganha o botão **Anexar imagem** na barra e uma faixa acima do campo, com a
|
|
70
|
+
miniatura, o nome e o estado de cada imagem. Colar uma imagem no campo e soltá-la sobre o composer
|
|
71
|
+
também anexam.
|
|
72
|
+
|
|
73
|
+
O estado é do aplicativo, porque é ele quem envia e guarda a imagem. O composer faz a triagem e
|
|
74
|
+
entrega os arquivos aceitos em `onAddFiles`; o aplicativo acrescenta cada um à lista como
|
|
75
|
+
`uploading`, prepara a imagem com [`prepareImage`](#preparar-a-imagem-antes-do-envio), envia e muda o
|
|
76
|
+
estado para `ready` ou `failed`. Depois do envio da mensagem, é o aplicativo que limpa a lista e
|
|
77
|
+
revoga os endereços das miniaturas que criou.
|
|
78
|
+
|
|
79
|
+
```tsx preview col
|
|
80
|
+
const [text, setText] = React.useState('')
|
|
81
|
+
const [items, setItems] = React.useState<ComposerAttachment[]>([])
|
|
82
|
+
const nextId = React.useRef(1)
|
|
83
|
+
const previews = React.useRef<Record<string, string>>({})
|
|
84
|
+
|
|
85
|
+
// A miniatura é um endereço de objeto, que segura a imagem na memória até ser revogado.
|
|
86
|
+
function release(id: string) {
|
|
87
|
+
const url = previews.current[id]
|
|
88
|
+
if (url !== undefined) URL.revokeObjectURL(url)
|
|
89
|
+
delete previews.current[id]
|
|
90
|
+
}
|
|
91
|
+
React.useEffect(() => () => Object.keys(previews.current).forEach(release), [])
|
|
92
|
+
|
|
93
|
+
async function add(files: File[]) {
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
const id = String(nextId.current++)
|
|
96
|
+
const prepared = await prepareImage(file)
|
|
97
|
+
if (!prepared.ok) {
|
|
98
|
+
setItems((list) => [...list, { id, name: file.name, status: 'failed', error: prepared.message }])
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
previews.current[id] = URL.createObjectURL(prepared.file)
|
|
102
|
+
setItems((list) => [
|
|
103
|
+
...list,
|
|
104
|
+
{ id, name: prepared.file.name, status: 'uploading', previewUrl: previews.current[id] },
|
|
105
|
+
])
|
|
106
|
+
// Aqui o aplicativo envia `prepared.file` ao próprio servidor.
|
|
107
|
+
await new Promise((r) => setTimeout(r, 800))
|
|
108
|
+
setItems((list) => list.map((item) => (item.id === id ? { ...item, status: 'ready' } : item)))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
render(
|
|
113
|
+
<Composer
|
|
114
|
+
value={text}
|
|
115
|
+
onChange={setText}
|
|
116
|
+
onSubmit={() => {
|
|
117
|
+
items.forEach((item) => release(item.id))
|
|
118
|
+
setText('')
|
|
119
|
+
setItems([])
|
|
120
|
+
}}
|
|
121
|
+
placeholder="Cole, solte ou anexe um print…"
|
|
122
|
+
attachments={{
|
|
123
|
+
items,
|
|
124
|
+
onAddFiles: (files) => void add(files),
|
|
125
|
+
onRemove: (id) => {
|
|
126
|
+
release(id)
|
|
127
|
+
setItems((list) => list.filter((item) => item.id !== id))
|
|
128
|
+
},
|
|
129
|
+
}}
|
|
130
|
+
/>,
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
O envio passa com texto ou com pelo menos uma imagem pronta. Enquanto alguma imagem está sendo
|
|
135
|
+
enviada, o botão de enviar espera; uma imagem com falha também bloqueia o envio até ser removida ou
|
|
136
|
+
reenviada pelo aplicativo, para que nada do que está na faixa fique de fora sem aviso. Quem usa leitor
|
|
137
|
+
de tela ouve quando uma imagem fica pronta ou falha, e, se o botão de anexar ficar desabilitado ao
|
|
138
|
+
atingir o limite, o foco volta ao campo.
|
|
139
|
+
|
|
140
|
+
A triagem aceita PNG, JPEG e WebP, até quatro imagens por mensagem; `accept` e `max` mudam esses
|
|
141
|
+
limites. `accept` é um subconjunto desses três formatos, comparado com o tipo exato do arquivo, sem
|
|
142
|
+
curinga como `image/*`. O que fica de fora é explicado acima do campo, como “Cada mensagem aceita até 4 imagens.”.
|
|
143
|
+
Para dar outra explicação, passe `onReject`: o composer deixa de mostrar o aviso e entrega os
|
|
144
|
+
arquivos recusados, com o motivo, ao aplicativo.
|
|
145
|
+
|
|
146
|
+
Colar texto continua colando texto, mesmo quando a área de transferência também traz uma imagem, como
|
|
147
|
+
ao copiar células de uma planilha. Uma imagem colada sem texto, como um print, recebe um nome que a
|
|
148
|
+
pessoa reconhece na conversa: “Imagem colada 1.png”. A numeração não volta atrás: remover a primeira e
|
|
149
|
+
colar outra dá “Imagem colada 3.png”, e nunca duas imagens com o mesmo nome no mesmo composer.
|
|
150
|
+
|
|
151
|
+
O soltar que o composer recebe não sobe para os contêineres do aplicativo. Dentro do
|
|
152
|
+
[Chat](/ui/chat#imagens-na-conversa), quem recebe o soltar é a conversa inteira.
|
|
153
|
+
|
|
154
|
+
## Preparar a imagem antes do envio
|
|
155
|
+
|
|
156
|
+
`prepareImage(file, options)` deixa a imagem pronta para ir à conversa. Ela lê só o cabeçalho do
|
|
157
|
+
arquivo para conferir o formato, pelos bytes e não pela extensão, e as dimensões; uma foto acima de
|
|
158
|
+
`maxPixels` é recusada antes de ser aberta, porque decodificá-la ocuparia centenas de megabytes de
|
|
159
|
+
memória. Depois, reduz a imagem até o lado maior caber em `maxEdge`, mantendo a proporção e a
|
|
160
|
+
orientação gravada pela câmera, de modo que a foto tirada com o celular em pé chega em pé; e codifica
|
|
161
|
+
a imagem de novo, o que também descarta metadados como a localização. PNG continua PNG quando cabe em `maxBytes`; JPEG, WebP e o PNG que passa do teto viram
|
|
162
|
+
JPEG com qualidade 0,85, sobre fundo branco.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const result = await prepareImage(file, { maxEdge: 1568, maxBytes: 3 * 1024 * 1024 })
|
|
166
|
+
if (result.ok) {
|
|
167
|
+
await upload(result.file) // result.mediaType, result.width, result.height, result.size
|
|
168
|
+
} else {
|
|
169
|
+
showError(result.message) // "Imagens animadas não são aceitas. Envie uma imagem parada…"
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
A função não lança erro. Uma imagem fora dos formatos aceitos, animada, ilegível, com pixels demais ou
|
|
174
|
+
que continua acima do teto depois de reduzida volta como `{ ok: false, reason, message }`, com a explicação pronta para a
|
|
175
|
+
pessoa. Os padrões repetem os tetos do primeiro consumidor; passe os do seu produto, de preferência os
|
|
176
|
+
mesmos que o servidor confere.
|
|
177
|
+
|
|
66
178
|
## Propriedades de Composer
|
|
67
179
|
|
|
68
180
|
| Propriedade | Tipo | Padrão | Descrição |
|
|
69
181
|
|---|---|---|---|
|
|
70
182
|
| `value` | `string` | | Texto controlado; o dono do estado é quem compõe. |
|
|
71
183
|
| `onChange` | `(value: string) => void` | | Recebe cada alteração do texto. |
|
|
72
|
-
| `onSubmit` | `() => void` | | Chamado por Enter (sem Shift) ou pelo botão de enviar, somente quando há texto e nem `busy` nem `submitDisabled` estão ativos. |
|
|
184
|
+
| `onSubmit` | `() => void` | | Chamado por Enter (sem Shift) ou pelo botão de enviar, somente quando há texto ou imagem pronta, nenhuma imagem está sendo enviada ou com falha, e nem `busy` nem `submitDisabled` estão ativos. |
|
|
73
185
|
| `onHistoryPrevious` / `onHistoryNext` | `() => boolean` | | Navegação por ↑/↓ no histórico do dono; retorne `true` quando a tecla foi consumida. |
|
|
74
186
|
| `busy` | `boolean` | `false` | Bloqueia o envio enquanto a operação anterior corre e troca o ícone de enviar por um spinner. O campo continua editável. |
|
|
75
187
|
| `submitDisabled` | `boolean` | `false` | Bloqueia o envio por uma condição do consumidor, como um seletor ainda sem escolha. |
|
|
@@ -77,4 +189,35 @@ render(
|
|
|
77
189
|
| `rows` | `number` | `1` | Linhas iniciais do campo, que cresce com o conteúdo até `10rem`. |
|
|
78
190
|
| `autoFocus` | `boolean` | | Foca o campo ao montar. |
|
|
79
191
|
| `actions` | `ReactNode` | | Controles discretos à esquerda do botão de enviar; com eles, o composer passa a ter duas linhas. |
|
|
192
|
+
| `attachments` | `ComposerAttachments` | | Liga as imagens anexadas: botão **Anexar imagem**, faixa acima do campo, colar e soltar. Com ele, o composer passa a ter duas linhas. |
|
|
80
193
|
| `className` | `string` | | Classes aplicadas à moldura do composer. |
|
|
194
|
+
|
|
195
|
+
## Propriedades de ComposerAttachments
|
|
196
|
+
|
|
197
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
198
|
+
|---|---|---|---|
|
|
199
|
+
| `items` | `ComposerAttachment[]` | | Imagens anexadas à mensagem que está sendo escrita, na ordem da faixa. |
|
|
200
|
+
| `onAddFiles` | `(files: File[]) => void` | | Recebe os arquivos aceitos pela triagem, vindos do botão, do colar ou do soltar. |
|
|
201
|
+
| `onRemove` | `(id: string) => void` | | A pessoa removeu uma imagem da faixa; o foco volta ao campo. |
|
|
202
|
+
| `max` | `number` | `4` | Limite de imagens por mensagem. O botão de anexar fica desabilitado quando ele é atingido. |
|
|
203
|
+
| `accept` | `readonly AiImageMediaType[]` | PNG, JPEG e WebP | Formatos aceitos pelo botão e pela triagem, comparados com o tipo exato do arquivo. |
|
|
204
|
+
| `onReject` | `(rejections: ComposerFileRejection[]) => void` | | Recebe os arquivos recusados, com o motivo `type` ou `limit`. Sem ele, o composer explica a recusa acima do campo. |
|
|
205
|
+
|
|
206
|
+
`ComposerAttachment` é `{ id, name, status, previewUrl?, error? }`. `status` vale `uploading`,
|
|
207
|
+
`ready` ou `failed`; `previewUrl` é o endereço da miniatura e `error` é o motivo mostrado quando a
|
|
208
|
+
imagem falha.
|
|
209
|
+
|
|
210
|
+
## Opções de prepareImage
|
|
211
|
+
|
|
212
|
+
| Opção | Tipo | Padrão | Descrição |
|
|
213
|
+
|---|---|---|---|
|
|
214
|
+
| `maxEdge` | `number` | `1568` | Lado maior, em pixels, depois da redução. Imagens menores não são ampliadas. |
|
|
215
|
+
| `maxBytes` | `number` | 3 MB | Teto da imagem preparada. Acima dele, o PNG vira `lossyType`; o que continua acima é recusado. |
|
|
216
|
+
| `accept` | `readonly AiImageMediaType[]` | PNG, JPEG e WebP | Formatos aceitos na entrada, conferidos pelos primeiros bytes. |
|
|
217
|
+
| `lossyType` | `'image/jpeg' \| 'image/webp'` | `'image/jpeg'` | Formato de JPEG, WebP e do PNG que passa do teto. |
|
|
218
|
+
| `quality` | `number` | `0.85` | Qualidade da codificação com perdas, de 0 a 1. |
|
|
219
|
+
| `maxPixels` | `number` | `40_000_000` | Teto de largura × altura da imagem original, lido do cabeçalho antes de decodificar. |
|
|
220
|
+
|
|
221
|
+
O resultado é `{ ok: true, file, mediaType, width, height, size, resized }` ou
|
|
222
|
+
`{ ok: false, reason, message }`, em que `reason` vale `unsupported-type`, `animated`,
|
|
223
|
+
`too-many-pixels`, `too-large` ou `unreadable`.
|
|
@@ -150,8 +150,21 @@ Voltar e ações do cabeçalho usam `Button` `ghost` no tamanho `icon` (2.25rem)
|
|
|
150
150
|
[`Presentation`](/ui/presentation) usa no voltar. O botão de fechar é menor: um `CloseButton` no
|
|
151
151
|
quadrado `icon-sm` (1.75rem) por padrão.
|
|
152
152
|
|
|
153
|
-
O cabeçalho
|
|
154
|
-
|
|
153
|
+
O cabeçalho aceita `DialogDescription` como subheading opcional para orientar sobre a finalidade.
|
|
154
|
+
Omita quando repetir o título. Condições, regras de negócio e consequências necessárias à decisão
|
|
155
|
+
ficam junto dos controles ou em `DialogBody`. Uma frase curta também pode ser regra: o critério é se
|
|
156
|
+
pular o texto compromete a decisão, não sua extensão.
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
<DialogHeader>
|
|
160
|
+
<DialogTitle>Organizar pasta</DialogTitle>
|
|
161
|
+
<DialogDescription>Escolha onde a pasta e suas subpastas aparecem.</DialogDescription>
|
|
162
|
+
</DialogHeader>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Na composição direta, associe o `id` do texto por `aria-describedby` em `DialogContent` quando ele
|
|
166
|
+
descrever a superfície para tecnologias assistivas. A regra sobre seleção vazia pertence ao
|
|
167
|
+
campo de áreas e permanece fora do subheading.
|
|
155
168
|
|
|
156
169
|
## Identificar a tarefa com um ícone
|
|
157
170
|
|
|
@@ -634,3 +647,12 @@ conteúdo no primeiro bloco de `DialogBody`. `DialogIntro` exige um `DialogTitle
|
|
|
634
647
|
|
|
635
648
|
No formato compacto, mantenha `DialogTitle` e `DialogActions` no próprio `DialogHeader`. Para
|
|
636
649
|
comparar as duas formas em cada `layout`, abra [Composições](/ui/compositions).
|
|
650
|
+
|
|
651
|
+
## Propriedades de DialogDescription
|
|
652
|
+
|
|
653
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
654
|
+
| --- | --- | --- | --- |
|
|
655
|
+
| `children` | `ReactNode` | | Orientação opcional sob o título em `DialogHeader` ou `DialogIntro`. |
|
|
656
|
+
| `id` | `string` | | Identificador para associação acessível explícita. |
|
|
657
|
+
| `className` | `string` | | Ajusta a apresentação do parágrafo. |
|
|
658
|
+
| `…props` | `ComponentProps<"p">` | | Atributos nativos. |
|
|
@@ -148,8 +148,21 @@ Voltar e ações do cabeçalho usam `Button` `ghost` no tamanho `icon` (2.25rem)
|
|
|
148
148
|
[`Presentation`](/ui/presentation) usa no voltar. O botão de fechar é menor: um `CloseButton` no
|
|
149
149
|
quadrado `icon-sm` (1.75rem) por padrão.
|
|
150
150
|
|
|
151
|
-
O cabeçalho
|
|
152
|
-
|
|
151
|
+
O cabeçalho aceita `DrawerDescription` como subheading opcional para orientar sobre a finalidade.
|
|
152
|
+
Omita quando repetir o título. Condições, regras de negócio e consequências necessárias à decisão
|
|
153
|
+
ficam junto dos controles ou em `DrawerBody`. Uma frase curta também pode ser regra: o critério é se
|
|
154
|
+
pular o texto compromete a decisão, não sua extensão.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
<DrawerHeader>
|
|
158
|
+
<DrawerTitle>Organizar pasta</DrawerTitle>
|
|
159
|
+
<DrawerDescription>Escolha onde a pasta e suas subpastas aparecem.</DrawerDescription>
|
|
160
|
+
</DrawerHeader>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Na composição direta, associe o `id` do texto por `aria-describedby` em `DrawerContent` quando ele
|
|
164
|
+
descrever a superfície para tecnologias assistivas. A regra sobre seleção vazia pertence ao
|
|
165
|
+
campo de áreas e permanece fora do subheading.
|
|
153
166
|
|
|
154
167
|
## Impedir o fechamento durante uma operação
|
|
155
168
|
|
|
@@ -301,3 +314,12 @@ Assim o título acompanha o conteúdo rolável enquanto o chrome permanece está
|
|
|
301
314
|
|
|
302
315
|
No formato compacto, mantenha `DrawerTitle` e `DrawerActions` no próprio `DrawerHeader`. Para
|
|
303
316
|
comparar as duas formas em cada `layout`, abra [Composições](/ui/compositions).
|
|
317
|
+
|
|
318
|
+
## Propriedades de DrawerDescription
|
|
319
|
+
|
|
320
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
321
|
+
| --- | --- | --- | --- |
|
|
322
|
+
| `children` | `ReactNode` | | Orientação opcional sob o título em `DrawerHeader` ou `DrawerIntro`. |
|
|
323
|
+
| `id` | `string` | | Identificador para associação acessível explícita. |
|
|
324
|
+
| `className` | `string` | | Ajusta a apresentação do parágrafo. |
|
|
325
|
+
| `…props` | `ComponentProps<"p">` | | Atributos nativos. |
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
## Esqueleto de página
|
|
2
2
|
|
|
3
3
|
Use `Page` para manter cabeçalho, estado e conteúdo na mesma anatomia. `PageHeader` é a única
|
|
4
|
-
introdução estrutural: reúne navegação contextual opcional, título e ações. O container é
|
|
4
|
+
introdução estrutural: reúne navegação contextual opcional, título, orientação opcional e ações. O container é
|
|
5
5
|
centralizado e ocupa a largura disponível até `80rem` (`max-w-7xl`). Use `className` somente quando
|
|
6
6
|
a composição pedir outro teto ou largura total.
|
|
7
7
|
|
|
@@ -41,6 +41,19 @@ render(
|
|
|
41
41
|
);
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
## Orientar sobre a finalidade
|
|
45
|
+
|
|
46
|
+
Use `description` na forma curta ou `PageDescription` em `PageHeader` quando uma frase breve
|
|
47
|
+
ajudar a reconhecer a finalidade da página. Omita texto que só repita o título. Regras de negócio,
|
|
48
|
+
limites e consequências ficam nos campos, ações ou corpo em que são necessários. Pular o
|
|
49
|
+
subheading não deve comprometer a decisão nem a conclusão correta da tarefa.
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
<Page title="Conhecimento" description="Consulte os materiais de apoio da sua área.">
|
|
53
|
+
<KnowledgeList />
|
|
54
|
+
</Page>
|
|
55
|
+
```
|
|
56
|
+
|
|
44
57
|
## Retorno para a página pai
|
|
45
58
|
|
|
46
59
|
Use `PageBack` em uma subpágina simples. O destino é explícito para continuar correto após refresh ou
|
|
@@ -311,6 +324,7 @@ estado na própria seção com [`DataState`](/ui/data-state), [`ActionView`](/ui
|
|
|
311
324
|
|
|
312
325
|
| Propriedade | Tipo | Padrão | Descrição |
|
|
313
326
|
| ----------- | -------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
327
|
+
| `description` | `ReactNode` | | Orientação opcional sob o título na forma curta. |
|
|
314
328
|
| `title` | `ReactNode` | | O h1 da página. |
|
|
315
329
|
| `actions` | `ReactNode` | | Ações contextuais no extremo oposto do cabeçalho. |
|
|
316
330
|
| `layout` | `'flow' \| 'fill'` | `'flow'` | Ocupação de altura, não anatomia: `fill` limita a página ao shell para que uma região interna role e leva essa altura até uma `ActionList` composta em `PageBody`. |
|
|
@@ -414,3 +428,11 @@ ocupa a altura.
|
|
|
414
428
|
| `retryLabel` | `string` | `'Tentar de novo'` | Texto do botão de recuperação. |
|
|
415
429
|
| `children` | `ReactNode` | | Conteúdo renderizado somente em `ready`. |
|
|
416
430
|
| `className` | `string` | | Classes da superfície de estado. |
|
|
431
|
+
|
|
432
|
+
## Propriedades de PageDescription
|
|
433
|
+
|
|
434
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
435
|
+
| --- | --- | --- | --- |
|
|
436
|
+
| `children` | `ReactNode` | | Orientação breve e opcional; filho direto de `PageHeader`. |
|
|
437
|
+
| `className` | `string` | | Ajusta a apresentação do parágrafo. |
|
|
438
|
+
| `…props` | `HTMLAttributes<HTMLParagraphElement>` | | Atributos nativos. |
|
|
@@ -131,12 +131,11 @@ render(
|
|
|
131
131
|
as="section"
|
|
132
132
|
framed
|
|
133
133
|
title="Plano atual"
|
|
134
|
-
description="Renova em 12 de maio."
|
|
135
134
|
level={3}
|
|
136
135
|
>
|
|
137
136
|
<SurfaceBody>
|
|
138
137
|
<p className="text-sm text-muted-foreground">
|
|
139
|
-
Até 10 workspaces e 50 agentes.
|
|
138
|
+
Renova em 12 de maio. Até 10 workspaces e 50 agentes.
|
|
140
139
|
</p>
|
|
141
140
|
</SurfaceBody>
|
|
142
141
|
<SurfaceFooter>
|
|
@@ -511,3 +510,10 @@ um wrapper filho declara relações locais mais compactas.
|
|
|
511
510
|
| ----------- | ----------- | ------ | ---------------------------------------------------- |
|
|
512
511
|
| `children` | `ReactNode` | | Ações persistentes, alinhadas no fim da linha. |
|
|
513
512
|
| `className` | `string` | | Ajusta o rodapé, inclusive a distribuição das ações. |
|
|
513
|
+
|
|
514
|
+
## Subheading e regras de negócio
|
|
515
|
+
|
|
516
|
+
`description` e `SurfaceDescription` apresentam brevemente a finalidade da região. A orientação
|
|
517
|
+
é opcional e não repete o título. A mesma regra vale para uma página (`level={1}`), uma seção ou
|
|
518
|
+
um card: condições, limites e consequências necessários à decisão ficam no corpo ou junto do
|
|
519
|
+
controle correspondente. Pular o subheading não deve comprometer a tarefa.
|
|
@@ -143,6 +143,21 @@ function syncHosts(): void {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** A ajuda permanece no Tab, mas não toma o foco inicial do campo ao abrir um modal. */
|
|
147
|
+
export function skipHelpOnAutoFocus(event: Event): void {
|
|
148
|
+
if (event.defaultPrevented || !(event.target instanceof HTMLElement)) return;
|
|
149
|
+
const candidates = tabbables(event.target).filter(
|
|
150
|
+
(element) => element.tagName !== "A",
|
|
151
|
+
);
|
|
152
|
+
if (candidates[0]?.dataset.slot !== "label-help") return;
|
|
153
|
+
const control = candidates.find(
|
|
154
|
+
(element) => element.dataset.slot !== "label-help",
|
|
155
|
+
);
|
|
156
|
+
if (control === undefined) return;
|
|
157
|
+
control.focus();
|
|
158
|
+
if (document.activeElement === control) event.preventDefault();
|
|
159
|
+
}
|
|
160
|
+
|
|
146
161
|
/** Elementos alcançáveis pelo Tab dentro de `root`, na ordem do documento. */
|
|
147
162
|
function tabbables(root: HTMLElement): HTMLElement[] {
|
|
148
163
|
const nodes: HTMLElement[] = [];
|
|
@@ -178,7 +193,10 @@ function tabbables(root: HTMLElement): HTMLElement[] {
|
|
|
178
193
|
function focusFromKeyboard(element: HTMLElement): void {
|
|
179
194
|
element.focus();
|
|
180
195
|
// O Tab do navegador seleciona o texto do campo que recebe o foco; a passagem manual imita.
|
|
181
|
-
if (
|
|
196
|
+
if (
|
|
197
|
+
element instanceof HTMLInputElement &&
|
|
198
|
+
typeof element.select === "function"
|
|
199
|
+
) {
|
|
182
200
|
element.select();
|
|
183
201
|
}
|
|
184
202
|
}
|
|
@@ -342,7 +360,10 @@ export function registerFloatingLayer(
|
|
|
342
360
|
// podem tratar o Tab e marcá-lo com `preventDefault`.
|
|
343
361
|
const onKeyDown = (event: KeyboardEvent): void => {
|
|
344
362
|
if (!isTabKey(event) || event.defaultPrevented) return;
|
|
345
|
-
if (
|
|
363
|
+
if (
|
|
364
|
+
!layerExemptionsActive() ||
|
|
365
|
+
layerOf(document.activeElement) !== record
|
|
366
|
+
) {
|
|
346
367
|
return;
|
|
347
368
|
}
|
|
348
369
|
const candidates = tabbables(host);
|
|
@@ -361,7 +382,10 @@ export function registerFloatingLayer(
|
|
|
361
382
|
if (layerWherePersonIs() === record) escape()?.(event);
|
|
362
383
|
};
|
|
363
384
|
const observer = new MutationObserver(() => {
|
|
364
|
-
if (
|
|
385
|
+
if (
|
|
386
|
+
pointerAnchor !== null &&
|
|
387
|
+
pointerAnchor.parentNode !== pointerLayer?.host
|
|
388
|
+
) {
|
|
365
389
|
// O painel apertado saiu da camada, como quando é fechado por código e vira o FAB.
|
|
366
390
|
pointerAnchor = null;
|
|
367
391
|
pointerLayer = null;
|