@softize/opus 18.5.0 → 20.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +275 -0
- package/bin/cli.mjs +5 -0
- package/bin/lib/check.mjs +198 -10
- package/bin/lib/copy.mjs +29 -0
- package/bin/lib/mcp.mjs +1 -1
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +298 -0
- package/docs/adr/0024-fill-height-propagates-by-context.md +118 -0
- package/docs/data-products.md +15 -9
- package/docs/protocol.md +3 -4
- package/docs/releasing.md +2 -2
- package/package.json +2 -2
- package/registry/skills/create-opus-action/scripts/scaffold.mjs +2 -1
- package/registry/templates/app/package.json +1 -1
- package/src/core/runtime.ts +96 -8
- package/src/core/types.ts +5 -2
- package/src/ui/components/patterns/action-form-card.tsx +11 -1
- package/src/ui/components/patterns/content-header.tsx +16 -12
- package/src/ui/components/patterns/fill-region.ts +150 -0
- package/src/ui/components/patterns/heading-level.ts +16 -0
- package/src/ui/components/patterns/list.tsx +103 -73
- package/src/ui/components/patterns/page.tsx +20 -4
- package/src/ui/components/patterns/pane.tsx +294 -0
- package/src/ui/components/patterns/presentation.tsx +81 -54
- package/src/ui/components/patterns/sidebar.tsx +13 -72
- package/src/ui/components/patterns/split.tsx +51 -38
- package/src/ui/components/patterns/surface-anatomy.ts +246 -0
- package/src/ui/components/patterns/surface-header.tsx +51 -6
- package/src/ui/components/patterns/surface-media.tsx +69 -0
- package/src/ui/components/patterns/trigger.tsx +12 -2
- package/src/ui/components/primitives/alert.tsx +9 -1
- package/src/ui/components/primitives/avatar.tsx +7 -3
- package/src/ui/components/primitives/card.tsx +91 -29
- package/src/ui/components/primitives/carousel.tsx +34 -5
- package/src/ui/components/primitives/checkbox.tsx +11 -3
- package/src/ui/components/primitives/command.tsx +14 -5
- package/src/ui/components/primitives/copyable.tsx +120 -10
- package/src/ui/components/primitives/detail.tsx +10 -13
- package/src/ui/components/primitives/dialog.tsx +179 -45
- package/src/ui/components/primitives/drawer.tsx +131 -24
- package/src/ui/components/primitives/empty.tsx +1 -1
- package/src/ui/components/primitives/icon-picker.tsx +6 -2
- package/src/ui/components/primitives/icon-tile.tsx +7 -0
- package/src/ui/components/primitives/input-group.tsx +9 -2
- package/src/ui/components/primitives/input-otp.tsx +1 -1
- package/src/ui/components/primitives/kbd.tsx +1 -1
- package/src/ui/components/primitives/label.tsx +3 -0
- package/src/ui/components/primitives/menu.tsx +5 -0
- package/src/ui/components/primitives/metric-card.tsx +98 -39
- package/src/ui/components/primitives/popover.tsx +1 -1
- package/src/ui/components/primitives/progress.tsx +9 -1
- package/src/ui/components/primitives/slider.tsx +62 -11
- package/src/ui/components/primitives/truncate.tsx +10 -1
- package/src/ui/docs/content/accordion.md +74 -27
- package/src/ui/docs/content/action-form-card.md +24 -15
- package/src/ui/docs/content/action-form-dialog.md +17 -7
- package/src/ui/docs/content/action-form.md +92 -77
- package/src/ui/docs/content/action-list-dialog.md +75 -38
- package/src/ui/docs/content/action-list.md +134 -52
- package/src/ui/docs/content/action-trigger.md +27 -10
- package/src/ui/docs/content/action-view.md +14 -14
- package/src/ui/docs/content/actions.md +161 -66
- package/src/ui/docs/content/ai.md +43 -44
- package/src/ui/docs/content/alert.md +83 -29
- package/src/ui/docs/content/ask.md +51 -26
- package/src/ui/docs/content/aspect-ratio.md +22 -24
- package/src/ui/docs/content/audit.md +23 -19
- package/src/ui/docs/content/auth.md +38 -19
- package/src/ui/docs/content/avatar.md +54 -5
- package/src/ui/docs/content/badge.md +29 -9
- package/src/ui/docs/content/breadcrumb.md +21 -8
- package/src/ui/docs/content/button.md +73 -43
- package/src/ui/docs/content/calendar.md +134 -26
- package/src/ui/docs/content/card.md +252 -35
- package/src/ui/docs/content/carousel.md +37 -6
- package/src/ui/docs/content/chat.md +118 -37
- package/src/ui/docs/content/checkbox.md +73 -7
- package/src/ui/docs/content/cli.md +94 -55
- package/src/ui/docs/content/collapsible.md +43 -19
- package/src/ui/docs/content/command.md +30 -8
- package/src/ui/docs/content/composer.md +23 -8
- package/src/ui/docs/content/content.md +37 -7
- package/src/ui/docs/content/copyable.md +39 -8
- package/src/ui/docs/content/customization.md +55 -46
- package/src/ui/docs/content/cycle.md +33 -18
- package/src/ui/docs/content/data-products.md +115 -0
- package/src/ui/docs/content/data-state.md +20 -6
- package/src/ui/docs/content/data.md +20 -19
- package/src/ui/docs/content/detail.md +12 -5
- package/src/ui/docs/content/dialog.md +219 -28
- package/src/ui/docs/content/dictionary-value.md +4 -4
- package/src/ui/docs/content/dock.md +54 -19
- package/src/ui/docs/content/dot.md +35 -5
- package/src/ui/docs/content/drawer.md +193 -22
- package/src/ui/docs/content/empty-value.md +20 -6
- package/src/ui/docs/content/empty.md +24 -3
- package/src/ui/docs/content/events.md +76 -16
- package/src/ui/docs/content/executions.md +9 -9
- package/src/ui/docs/content/field.md +21 -6
- package/src/ui/docs/content/getting-started.md +44 -34
- package/src/ui/docs/content/icon-picker.md +19 -13
- package/src/ui/docs/content/icon-tile.md +28 -5
- package/src/ui/docs/content/input-otp.md +58 -7
- package/src/ui/docs/content/input.md +22 -3
- package/src/ui/docs/content/item.md +53 -6
- package/src/ui/docs/content/kbd.md +5 -1
- package/src/ui/docs/content/label.md +13 -4
- package/src/ui/docs/content/log.md +5 -5
- package/src/ui/docs/content/markdown.md +5 -4
- package/src/ui/docs/content/mcp.md +49 -23
- package/src/ui/docs/content/menu.md +95 -10
- package/src/ui/docs/content/metric-card.md +68 -21
- package/src/ui/docs/content/observability.md +10 -9
- package/src/ui/docs/content/page.md +125 -11
- package/src/ui/docs/content/pagination.md +13 -5
- package/src/ui/docs/content/pane.md +300 -0
- package/src/ui/docs/content/popover.md +42 -5
- package/src/ui/docs/content/presentation.md +91 -47
- package/src/ui/docs/content/progress.md +27 -14
- package/src/ui/docs/content/queue.md +39 -21
- package/src/ui/docs/content/radio-group.md +26 -4
- package/src/ui/docs/content/router.md +29 -10
- package/src/ui/docs/content/runtime.md +90 -54
- package/src/ui/docs/content/scheduler.md +53 -21
- package/src/ui/docs/content/scroll-area.md +42 -35
- package/src/ui/docs/content/select.md +78 -33
- package/src/ui/docs/content/semantic-context.md +21 -2
- package/src/ui/docs/content/separator.md +15 -9
- package/src/ui/docs/content/sidebar.md +25 -45
- package/src/ui/docs/content/skeleton.md +13 -4
- package/src/ui/docs/content/slider.md +37 -9
- package/src/ui/docs/content/spinner.md +4 -3
- package/src/ui/docs/content/split.md +34 -20
- package/src/ui/docs/content/storage.md +8 -9
- package/src/ui/docs/content/switch.md +10 -7
- package/src/ui/docs/content/table.md +46 -8
- package/src/ui/docs/content/tabs.md +14 -4
- package/src/ui/docs/content/testing.md +76 -31
- package/src/ui/docs/content/textarea.md +5 -4
- package/src/ui/docs/content/toast.md +48 -7
- package/src/ui/docs/content/toggle.md +3 -3
- package/src/ui/docs/content/tokens.md +15 -26
- package/src/ui/docs/content/tooltip.md +18 -2
- package/src/ui/docs/content/truncate.md +36 -6
- package/src/ui/docs/content/ui.md +74 -22
- package/src/ui/docs/content/upgrading.md +45 -36
- package/src/ui/docs/plugin.ts +36 -2
- package/src/ui/docs/registry.tsx +14 -2
- package/src/ui/drivers/react.tsx +16 -8
- package/src/ui/meta.ts +38 -32
- package/src/ui/react.tsx +14 -0
- package/src/vite/design.ts +16 -4
|
@@ -2,6 +2,76 @@ import * as React from 'react'
|
|
|
2
2
|
import { CheckIcon, CopyIcon } from 'lucide-react'
|
|
3
3
|
import { cn } from '../../lib/cn.ts'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A região viva que anuncia "Copiado", uma por documento.
|
|
7
|
+
*
|
|
8
|
+
* Uma tela com dezenas de Copyable (uma tabela de IDs, por exemplo) montaria dezenas de regiões
|
|
9
|
+
* `role="status"` vazias no fim do `body`. Todas as instâncias compartilham a mesma: a primeira a
|
|
10
|
+
* montar cria o nó, a última a desmontar o remove. Quem escreve a confirmação vira dono do texto;
|
|
11
|
+
* só o dono o apaga, para que a expiração de uma cópia antiga não silencie a mais recente.
|
|
12
|
+
* Se algo fora do React tirar o nó do documento (um `body.innerHTML` de script legado, por
|
|
13
|
+
* exemplo), a próxima montagem ou cópia o recoloca, para que o anúncio não suma em silêncio.
|
|
14
|
+
*/
|
|
15
|
+
interface SharedStatus {
|
|
16
|
+
node: HTMLSpanElement
|
|
17
|
+
users: number
|
|
18
|
+
owner: symbol | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const statusByDocument = new WeakMap<Document, SharedStatus>()
|
|
22
|
+
|
|
23
|
+
function acquireStatus(doc: Document): SharedStatus {
|
|
24
|
+
let shared = statusByDocument.get(doc)
|
|
25
|
+
if (shared === undefined) {
|
|
26
|
+
const node = doc.createElement('span')
|
|
27
|
+
node.setAttribute('role', 'status')
|
|
28
|
+
node.setAttribute('aria-live', 'polite')
|
|
29
|
+
node.setAttribute('data-slot', 'copyable-status')
|
|
30
|
+
node.className = 'sr-only'
|
|
31
|
+
doc.body.appendChild(node)
|
|
32
|
+
shared = { node, users: 0, owner: undefined }
|
|
33
|
+
statusByDocument.set(doc, shared)
|
|
34
|
+
}
|
|
35
|
+
ensureConnected(shared.node, doc)
|
|
36
|
+
shared.users += 1
|
|
37
|
+
return shared
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Devolve `true` quando precisou recolocar a região no `body`.
|
|
41
|
+
function ensureConnected(node: HTMLSpanElement, doc: Document): boolean {
|
|
42
|
+
if (node.isConnected) return false
|
|
43
|
+
doc.body.appendChild(node)
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function releaseStatus(doc: Document, shared: SharedStatus, id: symbol): void {
|
|
48
|
+
if (shared.owner === id) clearStatus(shared, id)
|
|
49
|
+
shared.users -= 1
|
|
50
|
+
if (shared.users > 0) return
|
|
51
|
+
shared.node.remove()
|
|
52
|
+
if (statusByDocument.get(doc) === shared) statusByDocument.delete(doc)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function announceCopied(shared: SharedStatus, id: symbol): void {
|
|
56
|
+
shared.owner = id
|
|
57
|
+
const doc = shared.node.ownerDocument
|
|
58
|
+
// Um nó de texto novo, em vez de manter o anterior: uma segunda cópia enquanto "Copiado" ainda
|
|
59
|
+
// está na região (da mesma instância ou de outra) continua sendo uma mudança a anunciar.
|
|
60
|
+
const write = (): void => {
|
|
61
|
+
if (shared.owner === id) shared.node.replaceChildren(doc.createTextNode('Copiado'))
|
|
62
|
+
}
|
|
63
|
+
// Uma região recém-inserida já com texto costuma não ser lida: depois de recolocá-la, o texto
|
|
64
|
+
// entra no tick seguinte, quando o leitor de tela já a conhece.
|
|
65
|
+
if (ensureConnected(shared.node, doc)) setTimeout(write, 0)
|
|
66
|
+
else write()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clearStatus(shared: SharedStatus, id: symbol): void {
|
|
70
|
+
if (shared.owner !== id) return
|
|
71
|
+
shared.owner = undefined
|
|
72
|
+
shared.node.replaceChildren()
|
|
73
|
+
}
|
|
74
|
+
|
|
5
75
|
export interface CopyableProps extends Omit<React.ComponentProps<'button'>, 'value'> {
|
|
6
76
|
/** O texto que vai pro clipboard no clique. */
|
|
7
77
|
value: string
|
|
@@ -13,7 +83,16 @@ export interface CopyableProps extends Omit<React.ComponentProps<'button'>, 'val
|
|
|
13
83
|
* Clicar-pra-copiar com feedback: copia `value` pro clipboard e troca o ícone por um
|
|
14
84
|
* check por ~1.5s. Sem filhos é um botão-ícone (bom pra toolbar/célula); com filhos, o
|
|
15
85
|
* rótulo visível + o ícone à direita. Pra IDs, tokens, slugs, URLs. No-op silencioso se
|
|
16
|
-
* o clipboard não estiver disponível (contexto inseguro/SSR)
|
|
86
|
+
* o clipboard não estiver disponível (contexto inseguro/SSR) ou recusar a escrita (permissão
|
|
87
|
+
* negada, documento sem foco): o botão só confirma "copiado" depois que a escrita conclui.
|
|
88
|
+
*
|
|
89
|
+
* Nome acessível: com filhos, é o próprio texto visível (quem fala o que vê aciona o botão);
|
|
90
|
+
* sem filhos, "Copiar" e, na confirmação, "Copiado". Um `aria-label` do consumidor vence os
|
|
91
|
+
* dois. A confirmação é anunciada por uma região viva no fim de `document.body`, compartilhada
|
|
92
|
+
* por todas as instâncias do documento: dentro do botão, o texto entraria no nome calculado pelo
|
|
93
|
+
* conteúdo (e o botão não expõe descendentes como regiões); como irmã, mudaria a estrutura do
|
|
94
|
+
* contêiner e seletores como `:first-child` de um ButtonGroup. A região só é criada no cliente,
|
|
95
|
+
* depois da hidratação, e existe antes da primeira cópia para que a mudança seja anunciada.
|
|
17
96
|
*/
|
|
18
97
|
export function Copyable({
|
|
19
98
|
value,
|
|
@@ -25,26 +104,57 @@ export function Copyable({
|
|
|
25
104
|
}: CopyableProps): React.ReactElement {
|
|
26
105
|
const [copied, setCopied] = React.useState(false)
|
|
27
106
|
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
28
|
-
React.
|
|
107
|
+
const [id] = React.useState(() => Symbol('copyable'))
|
|
108
|
+
const status = React.useRef<SharedStatus | undefined>(undefined)
|
|
109
|
+
React.useEffect(() => {
|
|
110
|
+
const doc = document
|
|
111
|
+
const shared = acquireStatus(doc)
|
|
112
|
+
status.current = shared
|
|
113
|
+
return () => {
|
|
114
|
+
clearTimeout(timer.current)
|
|
115
|
+
status.current = undefined
|
|
116
|
+
releaseStatus(doc, shared, id)
|
|
117
|
+
}
|
|
118
|
+
}, [id])
|
|
119
|
+
const iconOnly = children === undefined || children === null || children === false || children === ''
|
|
29
120
|
return (
|
|
30
121
|
<button
|
|
31
122
|
type="button"
|
|
32
123
|
data-slot="copyable"
|
|
33
124
|
data-copied={copied || undefined}
|
|
34
|
-
aria-label={copied ? 'Copiado' : 'Copiar'}
|
|
35
|
-
onClick={(e) => {
|
|
125
|
+
aria-label={iconOnly ? (copied ? 'Copiado' : 'Copiar') : undefined}
|
|
126
|
+
onClick={async (e) => {
|
|
36
127
|
onClick?.(e)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
128
|
+
const clipboard = globalThis.navigator?.clipboard
|
|
129
|
+
if (clipboard === undefined) return
|
|
130
|
+
clearTimeout(timer.current)
|
|
131
|
+
try {
|
|
132
|
+
await clipboard.writeText(value)
|
|
133
|
+
} catch {
|
|
134
|
+
// Recusado: some com um "copiado" anterior para não afirmar o que não aconteceu.
|
|
135
|
+
setCopied(false)
|
|
136
|
+
if (status.current !== undefined) clearStatus(status.current, id)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
const shared = status.current
|
|
140
|
+
// Desmontou enquanto a escrita corria: não há botão nem região a atualizar.
|
|
141
|
+
if (shared === undefined) return
|
|
142
|
+
setCopied(true)
|
|
143
|
+
announceCopied(shared, id)
|
|
144
|
+
timer.current = setTimeout(() => {
|
|
145
|
+
setCopied(false)
|
|
146
|
+
clearStatus(shared, id)
|
|
147
|
+
}, feedbackMs)
|
|
42
148
|
}}
|
|
43
149
|
className={cn('inline-flex items-center gap-1.5', className)}
|
|
44
150
|
{...props}
|
|
45
151
|
>
|
|
46
152
|
{children}
|
|
47
|
-
{copied ?
|
|
153
|
+
{copied ? (
|
|
154
|
+
<CheckIcon aria-hidden="true" className="size-3.5 text-context-success-emphasis" />
|
|
155
|
+
) : (
|
|
156
|
+
<CopyIcon aria-hidden="true" className="size-3.5" />
|
|
157
|
+
)}
|
|
48
158
|
</button>
|
|
49
159
|
)
|
|
50
160
|
}
|
|
@@ -54,9 +54,7 @@ function DetailGroup({
|
|
|
54
54
|
columnClasses[columns],
|
|
55
55
|
variant === 'plain' && !dividers && 'gap-x-6 gap-y-4',
|
|
56
56
|
dividers && 'gap-px overflow-hidden',
|
|
57
|
-
(variant === 'framed' || dividers) &&
|
|
58
|
-
!splitHorizontalField &&
|
|
59
|
-
'[&>[data-slot=detail-field]]:p-3',
|
|
57
|
+
(variant === 'framed' || dividers) && !splitHorizontalField && '[&>[data-slot=detail-field]]:p-3',
|
|
60
58
|
variant === 'framed' &&
|
|
61
59
|
'overflow-hidden rounded-lg border border-border bg-transparent text-foreground',
|
|
62
60
|
// Default da coluna de rótulos como CLASSE, antes do `className`: assim uma classe do
|
|
@@ -74,10 +72,7 @@ function DetailGroup({
|
|
|
74
72
|
)
|
|
75
73
|
}
|
|
76
74
|
|
|
77
|
-
export interface DetailFieldProps extends Omit<
|
|
78
|
-
React.ComponentProps<'div'>,
|
|
79
|
-
'children'
|
|
80
|
-
> {
|
|
75
|
+
export interface DetailFieldProps extends Omit<React.ComponentProps<'div'>, 'children'> {
|
|
81
76
|
label: React.ReactNode
|
|
82
77
|
/** `null`, `undefined`, string vazia ou só com espaços renderizam a ausência
|
|
83
78
|
* como travessão; “Não informado” ou `empty` preservam o significado para
|
|
@@ -99,11 +94,13 @@ function DetailField({
|
|
|
99
94
|
...props
|
|
100
95
|
}: DetailFieldProps): React.ReactElement {
|
|
101
96
|
const { splitHorizontalField } = React.useContext(DetailGroupLayoutContext)
|
|
102
|
-
const content = !isAbsentValue(value)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
97
|
+
const content = !isAbsentValue(value) ? (
|
|
98
|
+
value
|
|
99
|
+
) : empty === undefined || typeof empty === 'string' ? (
|
|
100
|
+
<EmptyValue compact {...(empty !== undefined ? { label: empty } : {})} />
|
|
101
|
+
) : (
|
|
102
|
+
empty
|
|
103
|
+
)
|
|
107
104
|
return (
|
|
108
105
|
<div
|
|
109
106
|
data-slot="detail-field"
|
|
@@ -131,7 +128,7 @@ function DetailField({
|
|
|
131
128
|
<dt
|
|
132
129
|
data-slot="detail-field-label"
|
|
133
130
|
className={cn(
|
|
134
|
-
'flex items-center gap-
|
|
131
|
+
'flex items-center gap-1.5 text-sm leading-snug font-medium text-muted-foreground',
|
|
135
132
|
splitHorizontalField && 'bg-muted/30 p-3 dark:bg-input/30',
|
|
136
133
|
)}
|
|
137
134
|
>
|
|
@@ -2,6 +2,22 @@ import * as React from "react";
|
|
|
2
2
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
3
3
|
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
|
|
4
4
|
import { cn } from "../../lib/cn.ts";
|
|
5
|
+
import {
|
|
6
|
+
SurfaceLayoutProvider,
|
|
7
|
+
surfaceActionsClasses,
|
|
8
|
+
surfaceHeaderRowClasses,
|
|
9
|
+
surfaceRegionClasses,
|
|
10
|
+
surfaceTitleClasses,
|
|
11
|
+
useSurfaceLayout,
|
|
12
|
+
type SurfaceLayout,
|
|
13
|
+
} from "../patterns/surface-anatomy.ts";
|
|
14
|
+
import { FillBoundary } from "../patterns/fill-region.ts";
|
|
15
|
+
import { headingTags, type HeadingLevel } from "../patterns/heading-level.ts";
|
|
16
|
+
import { SurfaceHeader } from "../patterns/surface-header.tsx";
|
|
17
|
+
import {
|
|
18
|
+
SurfaceMedia,
|
|
19
|
+
type SurfaceMediaProps,
|
|
20
|
+
} from "../patterns/surface-media.tsx";
|
|
5
21
|
import {
|
|
6
22
|
CloseButton,
|
|
7
23
|
type CloseButtonSize,
|
|
@@ -30,6 +46,24 @@ const DialogContentContext = React.createContext<DialogContentContextValue>({
|
|
|
30
46
|
closeSize: "sm",
|
|
31
47
|
});
|
|
32
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Compõe o handler de dispensa do consumidor com o bloqueio de `closeDisabled`: o handler dele
|
|
51
|
+
* roda primeiro e, com a superfície bloqueada, o evento é cancelado para que Esc, clique fora ou
|
|
52
|
+
* foco fora não a fechem. Sem bloqueio, devolve o handler original.
|
|
53
|
+
*/
|
|
54
|
+
export function preventDismissWhen<
|
|
55
|
+
E extends { defaultPrevented: boolean; preventDefault(): void },
|
|
56
|
+
>(
|
|
57
|
+
blocked: boolean,
|
|
58
|
+
handler: ((event: E) => void) | undefined,
|
|
59
|
+
): ((event: E) => void) | undefined {
|
|
60
|
+
if (!blocked) return handler;
|
|
61
|
+
return (event) => {
|
|
62
|
+
handler?.(event);
|
|
63
|
+
if (!event.defaultPrevented) event.preventDefault();
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
function useDialogContext(): DialogContextValue {
|
|
34
68
|
return React.useContext(DialogContext);
|
|
35
69
|
}
|
|
@@ -175,15 +209,23 @@ export function DialogContent({
|
|
|
175
209
|
showCloseButton,
|
|
176
210
|
closeDisabled = false,
|
|
177
211
|
closeSize = "sm",
|
|
212
|
+
layout = "sectioned",
|
|
178
213
|
"aria-describedby": ariaDescribedBy,
|
|
214
|
+
onEscapeKeyDown,
|
|
215
|
+
onInteractOutside,
|
|
179
216
|
...props
|
|
180
217
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
181
218
|
/** Mostra a ação “Fechar” no modo padrão. O modo `alert` exige uma resposta identificável. */
|
|
182
219
|
showCloseButton?: boolean;
|
|
183
|
-
/**
|
|
220
|
+
/**
|
|
221
|
+
* Bloqueia o fechamento enquanto a superfície está ocupada: desabilita a ação “Fechar”, que
|
|
222
|
+
* continua visível, e cancela Esc e clique ou foco fora depois dos handlers do consumidor.
|
|
223
|
+
*/
|
|
184
224
|
closeDisabled?: boolean;
|
|
185
225
|
/** Tamanho da ação circular de fechar no cabeçalho. */
|
|
186
226
|
closeSize?: CloseButtonSize;
|
|
227
|
+
/** Anatomia da superfície: `sectioned` (default) ou `continuous` — ADR 0023. */
|
|
228
|
+
layout?: SurfaceLayout;
|
|
187
229
|
}) {
|
|
188
230
|
const { mode } = useDialogContext();
|
|
189
231
|
const showClose = showCloseButton ?? mode !== "alert";
|
|
@@ -199,17 +241,22 @@ export function DialogContent({
|
|
|
199
241
|
data-mode="alert"
|
|
200
242
|
className={cn(contentClasses, "grid max-w-xs gap-4 p-6", className)}
|
|
201
243
|
aria-describedby={ariaDescribedBy}
|
|
244
|
+
onEscapeKeyDown={preventDismissWhen(closeDisabled, onEscapeKeyDown)}
|
|
202
245
|
{...props}
|
|
203
246
|
>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
247
|
+
{/* Sem anatomia seccionada (exceção da ADR 0023), mas a altura continua sendo
|
|
248
|
+
decisão do diálogo: a cadeia `fill` da página não atravessa o portal. */}
|
|
249
|
+
<FillBoundary>
|
|
250
|
+
<DialogContentContext.Provider
|
|
251
|
+
value={{
|
|
252
|
+
showCloseButton: false,
|
|
253
|
+
closeDisabled: true,
|
|
254
|
+
closeSize,
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
{children}
|
|
258
|
+
</DialogContentContext.Provider>
|
|
259
|
+
</FillBoundary>
|
|
213
260
|
</AlertDialogPrimitive.Content>
|
|
214
261
|
</DialogPortal>
|
|
215
262
|
);
|
|
@@ -221,19 +268,24 @@ export function DialogContent({
|
|
|
221
268
|
<DialogPrimitive.Content
|
|
222
269
|
data-slot="dialog-content"
|
|
223
270
|
data-mode="default"
|
|
271
|
+
data-layout={layout}
|
|
224
272
|
className={cn(
|
|
225
273
|
contentClasses,
|
|
226
274
|
"flex max-h-[85vh] max-w-lg flex-col",
|
|
227
275
|
className,
|
|
228
276
|
)}
|
|
229
277
|
aria-describedby={ariaDescribedBy}
|
|
278
|
+
onEscapeKeyDown={preventDismissWhen(closeDisabled, onEscapeKeyDown)}
|
|
279
|
+
onInteractOutside={preventDismissWhen(closeDisabled, onInteractOutside)}
|
|
230
280
|
{...props}
|
|
231
281
|
>
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
282
|
+
<SurfaceLayoutProvider value={layout}>
|
|
283
|
+
<DialogContentContext.Provider
|
|
284
|
+
value={{ showCloseButton: showClose, closeDisabled, closeSize }}
|
|
285
|
+
>
|
|
286
|
+
{children}
|
|
287
|
+
</DialogContentContext.Provider>
|
|
288
|
+
</SurfaceLayoutProvider>
|
|
237
289
|
</DialogPrimitive.Content>
|
|
238
290
|
</DialogPortal>
|
|
239
291
|
);
|
|
@@ -245,40 +297,99 @@ export function DialogHeader({
|
|
|
245
297
|
...props
|
|
246
298
|
}: React.ComponentProps<"div">) {
|
|
247
299
|
const { mode } = useDialogContext();
|
|
300
|
+
const layout = useSurfaceLayout("dialog");
|
|
248
301
|
const { showCloseButton, closeDisabled, closeSize } =
|
|
249
302
|
React.useContext(DialogContentContext);
|
|
303
|
+
|
|
304
|
+
// O modo de alerta é a exceção declarada da ADR 0023: a resposta obrigatória empilha
|
|
305
|
+
// mídia, título e corpo centralizados, e o padding mora na raiz do conteúdo.
|
|
306
|
+
if (mode === "alert") {
|
|
307
|
+
return (
|
|
308
|
+
<div
|
|
309
|
+
data-slot="dialog-header"
|
|
310
|
+
className={cn(
|
|
311
|
+
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=dialog-media]:grid-rows-[auto_auto_1fr]",
|
|
312
|
+
className,
|
|
313
|
+
)}
|
|
314
|
+
{...props}
|
|
315
|
+
>
|
|
316
|
+
{children}
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
250
321
|
return (
|
|
251
|
-
<
|
|
252
|
-
|
|
322
|
+
<SurfaceHeader
|
|
323
|
+
name="DialogHeader"
|
|
324
|
+
slot="dialog"
|
|
325
|
+
slots={{
|
|
326
|
+
leading: DialogNavigation,
|
|
327
|
+
media: DialogMedia,
|
|
328
|
+
title: DialogTitle,
|
|
329
|
+
actions: DialogActions,
|
|
330
|
+
}}
|
|
331
|
+
leadingPlacement="inline"
|
|
332
|
+
titleRequired={false}
|
|
333
|
+
rowClassName={surfaceHeaderRowClasses("dialog")}
|
|
253
334
|
className={cn(
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
: "flex shrink-0 items-center gap-4 border-b p-4 text-left [&:has(+[data-slot=dialog-footer])]:border-b-0",
|
|
335
|
+
"text-left",
|
|
336
|
+
surfaceRegionClasses("dialog", layout, "header"),
|
|
257
337
|
className,
|
|
258
338
|
)}
|
|
339
|
+
trailing={
|
|
340
|
+
showCloseButton ? (
|
|
341
|
+
<DialogCloseButton disabled={closeDisabled} size={closeSize} />
|
|
342
|
+
) : undefined
|
|
343
|
+
}
|
|
259
344
|
{...props}
|
|
260
345
|
>
|
|
261
346
|
{children}
|
|
262
|
-
|
|
263
|
-
<DialogCloseButton disabled={closeDisabled} size={closeSize} />
|
|
264
|
-
)}
|
|
265
|
-
</div>
|
|
347
|
+
</SurfaceHeader>
|
|
266
348
|
);
|
|
267
349
|
}
|
|
268
350
|
|
|
269
|
-
|
|
351
|
+
/** Região introdutória do header — o voltar de um fluxo modal encadeado. */
|
|
352
|
+
export function DialogNavigation({ className, ...props }: React.ComponentProps<"div">) {
|
|
353
|
+
return (
|
|
354
|
+
<div
|
|
355
|
+
data-slot="dialog-navigation-content"
|
|
356
|
+
className={cn("flex min-w-0 shrink-0 items-center", className)}
|
|
357
|
+
{...props}
|
|
358
|
+
/>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Ações da tela no header, antes do botão de fechar. */
|
|
363
|
+
export function DialogActions({
|
|
270
364
|
className,
|
|
271
365
|
...props
|
|
272
366
|
}: React.ComponentProps<"div">) {
|
|
273
367
|
return (
|
|
274
368
|
<div
|
|
275
|
-
data-slot="dialog-
|
|
369
|
+
data-slot="dialog-actions"
|
|
370
|
+
className={cn(surfaceActionsClasses, className)}
|
|
371
|
+
{...props}
|
|
372
|
+
/>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function DialogMedia({ className, ...props }: SurfaceMediaProps) {
|
|
377
|
+
const { mode } = useDialogContext();
|
|
378
|
+
// No alerta a mídia é o elemento de reconhecimento da resposta e ocupa a moldura
|
|
379
|
+
// maior — exceção declarada da ADR 0023, coberta por teste. Fora dele, a mesma
|
|
380
|
+
// moldura das demais famílias.
|
|
381
|
+
return mode === "alert" ? (
|
|
382
|
+
<SurfaceMedia
|
|
383
|
+
slot="dialog-media"
|
|
384
|
+
size="lg"
|
|
276
385
|
className={cn(
|
|
277
|
-
'mb-2
|
|
386
|
+
'mb-2 size-16 *:[svg:not([class*="size-"])]:size-8',
|
|
278
387
|
className,
|
|
279
388
|
)}
|
|
280
389
|
{...props}
|
|
281
390
|
/>
|
|
391
|
+
) : (
|
|
392
|
+
<SurfaceMedia slot="dialog-media" {...props} className={className} />
|
|
282
393
|
);
|
|
283
394
|
}
|
|
284
395
|
|
|
@@ -288,11 +399,17 @@ export function DialogBody({
|
|
|
288
399
|
...props
|
|
289
400
|
}: React.ComponentProps<"div">) {
|
|
290
401
|
const { mode } = useDialogContext();
|
|
402
|
+
const layout = useSurfaceLayout("dialog");
|
|
291
403
|
return (
|
|
292
404
|
<div
|
|
293
405
|
data-slot="dialog-body"
|
|
294
406
|
className={cn(
|
|
295
|
-
mode === "alert"
|
|
407
|
+
mode === "alert"
|
|
408
|
+
? "min-w-0"
|
|
409
|
+
: cn(
|
|
410
|
+
"min-h-0 flex-1 overflow-y-auto",
|
|
411
|
+
surfaceRegionClasses("dialog", layout, "body"),
|
|
412
|
+
),
|
|
296
413
|
className,
|
|
297
414
|
)}
|
|
298
415
|
style={
|
|
@@ -310,13 +427,17 @@ export function DialogFooter({
|
|
|
310
427
|
...props
|
|
311
428
|
}: React.ComponentProps<"div">) {
|
|
312
429
|
const { mode } = useDialogContext();
|
|
430
|
+
const layout = useSurfaceLayout("dialog");
|
|
313
431
|
return (
|
|
314
432
|
<div
|
|
315
433
|
data-slot="dialog-footer"
|
|
316
434
|
className={cn(
|
|
317
435
|
mode === "alert"
|
|
318
436
|
? "w-full"
|
|
319
|
-
:
|
|
437
|
+
: cn(
|
|
438
|
+
"flex shrink-0 flex-row justify-end gap-2",
|
|
439
|
+
surfaceRegionClasses("dialog", layout, "footer"),
|
|
440
|
+
),
|
|
320
441
|
className,
|
|
321
442
|
)}
|
|
322
443
|
{...props}
|
|
@@ -326,23 +447,36 @@ export function DialogFooter({
|
|
|
326
447
|
|
|
327
448
|
export function DialogTitle({
|
|
328
449
|
className,
|
|
450
|
+
level,
|
|
451
|
+
children,
|
|
329
452
|
...props
|
|
330
|
-
}: React.ComponentProps<typeof DialogPrimitive.Title>
|
|
453
|
+
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
|
|
454
|
+
/** Hierarquia do documento. Sem ele vale o `h2` que a primitiva emite. */
|
|
455
|
+
level?: HeadingLevel;
|
|
456
|
+
}) {
|
|
331
457
|
const { mode } = useDialogContext();
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
458
|
+
const layout = useSurfaceLayout("dialog");
|
|
459
|
+
const Title = mode === "alert" ? AlertDialogPrimitive.Title : DialogPrimitive.Title;
|
|
460
|
+
const classes = cn(
|
|
461
|
+
mode === "alert" ? undefined : "min-w-0 truncate",
|
|
462
|
+
surfaceTitleClasses("dialog", layout),
|
|
463
|
+
className,
|
|
464
|
+
);
|
|
465
|
+
// O nível troca a TAG do mesmo nó, via `asChild` — nunca aninha um heading dentro
|
|
466
|
+
// de outro, e a escala continua vindo do layout (ADR 0023).
|
|
467
|
+
if (level === undefined) {
|
|
468
|
+
return (
|
|
469
|
+
<Title data-slot="dialog-title" className={classes} {...props}>
|
|
470
|
+
{children}
|
|
471
|
+
</Title>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
const Heading = headingTags[level];
|
|
475
|
+
return (
|
|
476
|
+
<Title asChild {...props}>
|
|
477
|
+
<Heading data-slot="dialog-title" className={classes}>
|
|
478
|
+
{children}
|
|
479
|
+
</Heading>
|
|
480
|
+
</Title>
|
|
347
481
|
);
|
|
348
482
|
}
|