@softize/opus 12.10.0 → 13.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 +56 -0
- package/bin/lib/check.mjs +1098 -310
- package/bin/lib/copy.mjs +12 -5
- package/docs/adr/0003-dictionary-presentation-is-declared.md +3 -0
- package/docs/adr/0004-page-content-state-is-composed.md +65 -0
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +180 -0
- package/docs/adr/0006-semantic-context-precedes-visual-variant.md +182 -0
- package/docs/adr/0007-toast-actions-form-an-ordered-collection.md +63 -0
- package/docs/adr/0008-hierarchical-navigation-is-composed-at-the-consumer-boundary.md +71 -0
- package/docs/radius-scale.md +1 -1
- package/package.json +1 -1
- package/registry/instructions/opus.md +5 -0
- package/registry/skills/build-opus-ui/SKILL.md +27 -16
- package/registry/skills/build-opus-ui/references/evaluations.md +16 -5
- package/registry/skills/build-opus-ui/references/ui-patterns.md +38 -15
- package/registry/skills/maintain-opus-docs/SKILL.md +83 -0
- package/registry/skills/maintain-opus-docs/agents/openai.yaml +4 -0
- package/registry/skills/maintain-opus-docs/references/editorial-standard.md +85 -0
- package/registry/skills/maintain-opus-docs/references/evaluations.md +34 -0
- package/registry/skills/maintain-opus-docs/scripts/audit-docs.mjs +81 -0
- package/registry/skills/model-opus-dictionary/SKILL.md +4 -2
- package/registry/skills/model-opus-dictionary/references/evaluations.md +4 -3
- package/registry/templates/app/src/App.tsx +1 -1
- package/src/core/dictionary.ts +52 -14
- package/src/core/index.ts +10 -0
- package/src/core/ui-context.ts +29 -0
- package/src/schema/drivers/zod.ts +17 -8
- package/src/ui/components/patterns/action-form-card.tsx +18 -12
- package/src/ui/components/patterns/confirm.tsx +163 -40
- package/src/ui/components/patterns/content-header.tsx +335 -61
- package/src/ui/components/patterns/data-state.tsx +23 -10
- package/src/ui/components/patterns/list.tsx +1097 -783
- package/src/ui/components/patterns/page-state.tsx +115 -0
- package/src/ui/components/patterns/page.tsx +231 -41
- package/src/ui/components/patterns/sidebar.tsx +357 -83
- package/src/ui/components/patterns/trigger.tsx +37 -30
- package/src/ui/components/patterns/view.tsx +7 -11
- package/src/ui/components/primitives/alert.tsx +298 -110
- package/src/ui/components/primitives/ask.tsx +2 -1
- package/src/ui/components/primitives/badge.tsx +91 -30
- package/src/ui/components/primitives/button.tsx +99 -60
- package/src/ui/components/primitives/calendar.tsx +39 -39
- package/src/ui/components/primitives/card.tsx +96 -23
- package/src/ui/components/primitives/detail.tsx +2 -2
- package/src/ui/components/primitives/dialog.tsx +196 -39
- package/src/ui/components/primitives/dictionary-value.tsx +9 -14
- package/src/ui/components/primitives/dot.tsx +74 -21
- package/src/ui/components/primitives/drawer.tsx +40 -24
- package/src/ui/components/primitives/empty.tsx +3 -3
- package/src/ui/components/primitives/item.tsx +135 -79
- package/src/ui/components/primitives/menu.tsx +11 -3
- package/src/ui/components/primitives/metric-card.tsx +133 -0
- package/src/ui/components/primitives/sonner.tsx +187 -8
- package/src/ui/components/primitives/table.tsx +2 -2
- package/src/ui/docs/DocBrowser.tsx +104 -25
- package/src/ui/docs/changelog.tsx +1 -1
- package/src/ui/docs/content/accordion.md +22 -16
- package/src/ui/docs/content/action-form-card.md +8 -8
- package/src/ui/docs/content/action-form-dialog.md +9 -9
- package/src/ui/docs/content/action-form.md +28 -34
- package/src/ui/docs/content/action-list-dialog.md +11 -6
- package/src/ui/docs/content/action-list.md +64 -39
- package/src/ui/docs/content/action-trigger.md +21 -14
- package/src/ui/docs/content/action-view.md +8 -8
- package/src/ui/docs/content/actions.md +9 -9
- package/src/ui/docs/content/ai.md +3 -3
- package/src/ui/docs/content/alert.md +54 -28
- package/src/ui/docs/content/aspect-ratio.md +4 -4
- package/src/ui/docs/content/audit.md +2 -2
- package/src/ui/docs/content/auth.md +3 -3
- package/src/ui/docs/content/avatar.md +34 -14
- package/src/ui/docs/content/badge.md +21 -22
- package/src/ui/docs/content/breadcrumb.md +13 -8
- package/src/ui/docs/content/button.md +93 -15
- package/src/ui/docs/content/calendar.md +5 -5
- package/src/ui/docs/content/card.md +6 -6
- package/src/ui/docs/content/carousel.md +16 -11
- package/src/ui/docs/content/chat.md +3 -3
- package/src/ui/docs/content/checkbox.md +7 -7
- package/src/ui/docs/content/cli.md +5 -5
- package/src/ui/docs/content/collapsible.md +8 -8
- package/src/ui/docs/content/command.md +16 -8
- package/src/ui/docs/content/composer.md +2 -2
- package/src/ui/docs/content/content.md +44 -0
- package/src/ui/docs/content/copyable.md +4 -3
- package/src/ui/docs/content/customization.md +7 -7
- package/src/ui/docs/content/cycle.md +3 -3
- package/src/ui/docs/content/data-state.md +11 -12
- package/src/ui/docs/content/data.md +26 -33
- package/src/ui/docs/content/detail.md +8 -5
- package/src/ui/docs/content/dialog.md +339 -31
- package/src/ui/docs/content/dictionary-value.md +19 -18
- package/src/ui/docs/content/dock.md +3 -3
- package/src/ui/docs/content/dot.md +7 -7
- package/src/ui/docs/content/drawer.md +32 -16
- package/src/ui/docs/content/empty-value.md +2 -2
- package/src/ui/docs/content/empty.md +19 -12
- package/src/ui/docs/content/events.md +4 -4
- package/src/ui/docs/content/field.md +34 -12
- package/src/ui/docs/content/getting-started.md +1 -1
- package/src/ui/docs/content/icon-picker.md +8 -4
- package/src/ui/docs/content/input-otp.md +20 -12
- package/src/ui/docs/content/input.md +121 -9
- package/src/ui/docs/content/item.md +64 -24
- package/src/ui/docs/content/kbd.md +19 -11
- package/src/ui/docs/content/label.md +5 -3
- package/src/ui/docs/content/log.md +4 -4
- package/src/ui/docs/content/markdown.md +7 -6
- package/src/ui/docs/content/mcp.md +13 -15
- package/src/ui/docs/content/menu.md +36 -17
- package/src/ui/docs/content/metric-card.md +41 -0
- package/src/ui/docs/content/observability.md +2 -2
- package/src/ui/docs/content/page.md +93 -10
- package/src/ui/docs/content/pagination.md +22 -17
- package/src/ui/docs/content/popover.md +16 -8
- package/src/ui/docs/content/progress.md +7 -5
- package/src/ui/docs/content/queue.md +5 -5
- package/src/ui/docs/content/radio-group.md +20 -12
- package/src/ui/docs/content/router.md +11 -6
- package/src/ui/docs/content/scheduler.md +4 -5
- package/src/ui/docs/content/scroll-area.md +12 -7
- package/src/ui/docs/content/select.md +42 -29
- package/src/ui/docs/content/semantic-context.md +63 -0
- package/src/ui/docs/content/separator.md +5 -5
- package/src/ui/docs/content/sidebar.md +325 -56
- package/src/ui/docs/content/skeleton.md +5 -4
- package/src/ui/docs/content/slider.md +8 -7
- package/src/ui/docs/content/spinner.md +8 -8
- package/src/ui/docs/content/split.md +8 -5
- package/src/ui/docs/content/storage.md +6 -8
- package/src/ui/docs/content/switch.md +8 -7
- package/src/ui/docs/content/table.md +16 -6
- package/src/ui/docs/content/tabs.md +28 -14
- package/src/ui/docs/content/testing.md +9 -11
- package/src/ui/docs/content/textarea.md +5 -4
- package/src/ui/docs/content/toast.md +47 -13
- package/src/ui/docs/content/toggle.md +75 -7
- package/src/ui/docs/content/tokens.md +31 -3
- package/src/ui/docs/content/tooltip.md +19 -11
- package/src/ui/docs/content/truncate.md +7 -8
- package/src/ui/docs/content/ui.md +10 -9
- package/src/ui/docs/content/upgrading.md +7 -8
- package/src/ui/docs/doc-client.tsx +2 -2
- package/src/ui/docs/registry.tsx +580 -229
- package/src/ui/lib/semantic-context.ts +30 -0
- package/src/ui/meta.ts +278 -286
- package/src/ui/react.tsx +377 -111
- package/src/ui/theme.css +116 -0
- package/src/ui/components/primitives/alert-dialog.tsx +0 -190
- package/src/ui/docs/content/alert-dialog.md +0 -73
- package/src/ui/docs/content/button-group.md +0 -71
- package/src/ui/docs/content/confirm.md +0 -120
- package/src/ui/docs/content/input-group.md +0 -78
- package/src/ui/docs/content/toggle-group.md +0 -81
package/src/core/dictionary.ts
CHANGED
|
@@ -8,14 +8,20 @@
|
|
|
8
8
|
|
|
9
9
|
import { getLogicalType } from './logical-type.ts'
|
|
10
10
|
import type { LogicalTypeMeta } from './types.ts'
|
|
11
|
+
import {
|
|
12
|
+
DICT_CONTEXTS,
|
|
13
|
+
isDictContext,
|
|
14
|
+
type DictContext,
|
|
15
|
+
} from './ui-context.ts'
|
|
11
16
|
|
|
12
17
|
/** Papel de apresentação declarado no nível do dicionário. */
|
|
13
18
|
export const DICT_PRESENTATIONS = ['classification', 'status', 'stage', 'plain'] as const
|
|
14
19
|
export type DictPresentation = (typeof DICT_PRESENTATIONS)[number]
|
|
15
20
|
|
|
16
|
-
/**
|
|
17
|
-
export const DICT_TONES =
|
|
18
|
-
|
|
21
|
+
/** @deprecated Use `DICT_CONTEXTS` (ADR 0006). */
|
|
22
|
+
export const DICT_TONES = DICT_CONTEXTS
|
|
23
|
+
/** @deprecated Use `DictContext` (ADR 0006). */
|
|
24
|
+
export type DictTone = DictContext
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
27
|
* Meta de cada chave de um dict. `label` obrigatório; resto livre.
|
|
@@ -24,7 +30,7 @@ export type DictTone = (typeof DICT_TONES)[number]
|
|
|
24
30
|
* entra/sai) — a fonte rica de consulta rápida; flui pro manifest/Lens e NÃO vira tooltip.
|
|
25
31
|
* - `description` é o texto curto voltado à pessoa: aparece no tooltip do `DictionaryValue`
|
|
26
32
|
* e como apoio de opção. Não repetir o rótulo — descrição igual ao rótulo não renderiza.
|
|
27
|
-
* - `
|
|
33
|
+
* - `context` é o contexto semântico do status/estágio; classificação ignora.
|
|
28
34
|
* - `icon` é o identificador estável do catálogo de ícones do Opus (`iconPickerIcons`);
|
|
29
35
|
* nome fora do catálogo não renderiza ícone.
|
|
30
36
|
* - `color` permanece metadata livre (lida pela Lens); o renderer não a interpreta.
|
|
@@ -33,7 +39,9 @@ export interface DictEntryMeta {
|
|
|
33
39
|
label: string
|
|
34
40
|
doc?: string
|
|
35
41
|
description?: string
|
|
36
|
-
|
|
42
|
+
context?: DictContext
|
|
43
|
+
/** @deprecated Use `context`. Compatibilidade temporária da ADR 0006. */
|
|
44
|
+
tone?: DictContext
|
|
37
45
|
icon?: string
|
|
38
46
|
color?: string
|
|
39
47
|
order?: number
|
|
@@ -44,8 +52,16 @@ export function isDictPresentation(value: unknown): value is DictPresentation {
|
|
|
44
52
|
return typeof value === 'string' && (DICT_PRESENTATIONS as readonly string[]).includes(value)
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
/** @deprecated Use `isDictContext` (ADR 0006). */
|
|
56
|
+
export const isDictTone = isDictContext
|
|
57
|
+
|
|
58
|
+
/** Normaliza a metadata canônica e seu alias temporário. */
|
|
59
|
+
export function dictionaryEntryContext(entry: DictEntryMeta): DictContext | undefined {
|
|
60
|
+
return isDictContext(entry.context)
|
|
61
|
+
? entry.context
|
|
62
|
+
: isDictContext(entry.tone)
|
|
63
|
+
? entry.tone
|
|
64
|
+
: undefined
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
/** Forma normalizada de um dicionário, independente de onde a meta foi lida. */
|
|
@@ -92,7 +108,11 @@ export interface DictionaryValuePresentation {
|
|
|
92
108
|
/** False quando o valor não está no dicionário. */
|
|
93
109
|
known: boolean
|
|
94
110
|
presentation: DictPresentation
|
|
95
|
-
/**
|
|
111
|
+
/** Contexto semântico; `null` quando a apresentação é texto. */
|
|
112
|
+
context: DictContext | null
|
|
113
|
+
/** Tratamento visual; `null` quando a apresentação é texto. */
|
|
114
|
+
variant: 'subtle' | 'outline' | null
|
|
115
|
+
/** @deprecated Use `context` e `variant`. */
|
|
96
116
|
badge: 'outline' | DictTone | null
|
|
97
117
|
/** Identificador do catálogo, só quando declarado na entrada. */
|
|
98
118
|
icon: string | null
|
|
@@ -115,8 +135,8 @@ export function dictionaryDescription(label: string, description: unknown): stri
|
|
|
115
135
|
* Aplica a tabela de decisão da ADR 0003:
|
|
116
136
|
*
|
|
117
137
|
* | papel | forma | variante |
|
|
118
|
-
* | classification | badge |
|
|
119
|
-
* | status / stage | badge |
|
|
138
|
+
* | classification | badge | neutral + outline |
|
|
139
|
+
* | status / stage | badge | `context` + subtle |
|
|
120
140
|
* | plain / ausente | texto | — |
|
|
121
141
|
*
|
|
122
142
|
* Ícone só quando declarado; tooltip só quando `description` acrescenta. Valor fora do
|
|
@@ -132,20 +152,38 @@ export function presentDictionaryValue(
|
|
|
132
152
|
? descriptor.entries[value]
|
|
133
153
|
: undefined
|
|
134
154
|
if (entry === undefined || typeof entry.label !== 'string') {
|
|
135
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
value,
|
|
157
|
+
label: value,
|
|
158
|
+
known: false,
|
|
159
|
+
presentation,
|
|
160
|
+
context: null,
|
|
161
|
+
variant: null,
|
|
162
|
+
badge: null,
|
|
163
|
+
icon: null,
|
|
164
|
+
description: null,
|
|
165
|
+
}
|
|
136
166
|
}
|
|
137
|
-
const
|
|
167
|
+
const context: DictionaryValuePresentation['context'] =
|
|
168
|
+
presentation === 'classification'
|
|
169
|
+
? 'neutral'
|
|
170
|
+
: presentation === 'status' || presentation === 'stage'
|
|
171
|
+
? (dictionaryEntryContext(entry) ?? 'neutral')
|
|
172
|
+
: null
|
|
173
|
+
const variant: DictionaryValuePresentation['variant'] =
|
|
138
174
|
presentation === 'classification'
|
|
139
175
|
? 'outline'
|
|
140
176
|
: presentation === 'status' || presentation === 'stage'
|
|
141
|
-
?
|
|
177
|
+
? 'subtle'
|
|
142
178
|
: null
|
|
143
179
|
return {
|
|
144
180
|
value,
|
|
145
181
|
label: entry.label,
|
|
146
182
|
known: true,
|
|
147
183
|
presentation,
|
|
148
|
-
|
|
184
|
+
context,
|
|
185
|
+
variant,
|
|
186
|
+
badge: variant === 'outline' ? 'outline' : context,
|
|
149
187
|
icon: typeof entry.icon === 'string' && entry.icon.length > 0 ? entry.icon : null,
|
|
150
188
|
description: dictionaryDescription(entry.label, entry.description),
|
|
151
189
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -138,12 +138,22 @@ export { normalizeTraceContext } from './trace.ts'
|
|
|
138
138
|
// `@softize/opus/schema` re-exporta os dois pra manter a API de sempre.
|
|
139
139
|
export { attachLogicalType, getLogicalType } from './logical-type.ts'
|
|
140
140
|
|
|
141
|
+
// — Contextos semânticos de UI (ADR 0006) ————————————————————————————————————
|
|
142
|
+
export {
|
|
143
|
+
UI_CONTEXTS,
|
|
144
|
+
DICT_CONTEXTS,
|
|
145
|
+
isUiContext,
|
|
146
|
+
isDictContext,
|
|
147
|
+
} from './ui-context.ts'
|
|
148
|
+
export type { UiContext, DictContext } from './ui-context.ts'
|
|
149
|
+
|
|
141
150
|
// — Dicionários (apresentação declarada — ADR 0003) ————————————————————————————
|
|
142
151
|
export {
|
|
143
152
|
DICT_PRESENTATIONS,
|
|
144
153
|
DICT_TONES,
|
|
145
154
|
isDictPresentation,
|
|
146
155
|
isDictTone,
|
|
156
|
+
dictionaryEntryContext,
|
|
147
157
|
dictionaryDescriptor,
|
|
148
158
|
dictionaryDescription,
|
|
149
159
|
presentDictionaryValue,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Famílias semânticas compartilhadas entre contratos e UI (ADR 0006).
|
|
3
|
+
*
|
|
4
|
+
* Contexto responde por que um realce existe; cada componente decide como materializá-lo.
|
|
5
|
+
* Tema (light/dark), geometria e variante visual são eixos independentes.
|
|
6
|
+
*/
|
|
7
|
+
export const UI_CONTEXTS = [
|
|
8
|
+
'neutral',
|
|
9
|
+
'primary',
|
|
10
|
+
'info',
|
|
11
|
+
'success',
|
|
12
|
+
'warning',
|
|
13
|
+
'danger',
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
export type UiContext = (typeof UI_CONTEXTS)[number]
|
|
17
|
+
|
|
18
|
+
/** Contextos válidos para estados e estágios de domínio. */
|
|
19
|
+
export const DICT_CONTEXTS = ['neutral', 'info', 'success', 'warning', 'danger'] as const
|
|
20
|
+
|
|
21
|
+
export type DictContext = (typeof DICT_CONTEXTS)[number]
|
|
22
|
+
|
|
23
|
+
export function isUiContext(value: unknown): value is UiContext {
|
|
24
|
+
return typeof value === 'string' && (UI_CONTEXTS as readonly string[]).includes(value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isDictContext(value: unknown): value is DictContext {
|
|
28
|
+
return typeof value === 'string' && (DICT_CONTEXTS as readonly string[]).includes(value)
|
|
29
|
+
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
type DictEntryMeta,
|
|
37
37
|
type DictPresentation,
|
|
38
38
|
} from '../../core/dictionary.ts'
|
|
39
|
+
import { DICT_CONTEXTS, isDictContext } from '../../core/ui-context.ts'
|
|
39
40
|
import {
|
|
40
41
|
formatDatetime,
|
|
41
42
|
formatDate,
|
|
@@ -656,7 +657,8 @@ function isLogicalType(value: unknown): value is LogicalType<unknown> {
|
|
|
656
657
|
/**
|
|
657
658
|
* Meta de cada chave de um dict — o tipo mora no core (`@softize/opus/core`, ADR 0003) e é
|
|
658
659
|
* reexportado aqui pra manter a API: `label` obrigatório, `doc` (negócio, manifest/Lens),
|
|
659
|
-
* `description` (tooltip), `
|
|
660
|
+
* `description` (tooltip), `context` (contexto semântico de status/estágio), `icon` (catálogo).
|
|
661
|
+
* `tone` permanece como alias temporário de compatibilidade.
|
|
660
662
|
*/
|
|
661
663
|
export type { DictEntryMeta }
|
|
662
664
|
|
|
@@ -716,16 +718,23 @@ const dict = <const M extends Record<string, DictEntryMeta>>(
|
|
|
716
718
|
`t.dict: presentation "${String(opts.presentation)}" inválida; use ${DICT_PRESENTATIONS.join(' | ')}`,
|
|
717
719
|
)
|
|
718
720
|
}
|
|
719
|
-
const
|
|
721
|
+
const contextual = opts?.presentation === 'status' || opts?.presentation === 'stage'
|
|
720
722
|
for (const key of keys) {
|
|
721
|
-
const
|
|
722
|
-
if (tone
|
|
723
|
-
|
|
724
|
-
throw new Error(`t.dict: tone "${String(tone)}" inválido em "${key}"; use ${DICT_TONES.join(' | ')}`)
|
|
723
|
+
const entry = entries[key] as DictEntryMeta
|
|
724
|
+
if (entry.context !== undefined && entry.tone !== undefined) {
|
|
725
|
+
throw new Error(`t.dict: use context ou tone em "${key}", nunca os dois`)
|
|
725
726
|
}
|
|
726
|
-
|
|
727
|
+
const context = entry.context ?? entry.tone
|
|
728
|
+
if (context === undefined) continue
|
|
729
|
+
const legacy = entry.context === undefined
|
|
730
|
+
if (!(legacy ? isDictTone(context) : isDictContext(context))) {
|
|
731
|
+
const values = legacy ? DICT_TONES : DICT_CONTEXTS
|
|
732
|
+
const field = legacy ? 'tone' : 'context'
|
|
733
|
+
throw new Error(`t.dict: ${field} "${String(context)}" inválido em "${key}"; use ${values.join(' | ')}`)
|
|
734
|
+
}
|
|
735
|
+
if (!contextual) {
|
|
727
736
|
throw new Error(
|
|
728
|
-
`t.dict: tone em "${key}" exige presentation "status" ou "stage" (classificação usa outline; plain é texto)`,
|
|
737
|
+
`t.dict: ${legacy ? 'tone' : 'context'} em "${key}" exige presentation "status" ou "stage" (classificação usa outline; plain é texto)`,
|
|
729
738
|
)
|
|
730
739
|
}
|
|
731
740
|
}
|
|
@@ -6,22 +6,24 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import {
|
|
8
8
|
Card,
|
|
9
|
-
|
|
9
|
+
CardBody,
|
|
10
10
|
CardDescription,
|
|
11
11
|
CardFooter,
|
|
12
12
|
CardHeader,
|
|
13
13
|
CardTitle,
|
|
14
|
-
} from
|
|
15
|
-
import { ActionForm, type ActionFormProps } from
|
|
14
|
+
} from "../primitives/card.tsx";
|
|
15
|
+
import { ActionForm, type ActionFormProps } from "./form.tsx";
|
|
16
16
|
|
|
17
|
-
export interface ActionFormCardProps<
|
|
18
|
-
extends
|
|
17
|
+
export interface ActionFormCardProps<
|
|
18
|
+
TInput extends Record<string, unknown>,
|
|
19
|
+
TData,
|
|
20
|
+
> extends Omit<ActionFormProps<TInput, TData>, "body" | "footer"> {
|
|
19
21
|
/** Título do header do card. Sem ele, o card começa direto no conteúdo. */
|
|
20
|
-
title?: string
|
|
22
|
+
title?: string;
|
|
21
23
|
/** Subtítulo opcional, abaixo do título. */
|
|
22
|
-
description?: string
|
|
24
|
+
description?: string;
|
|
23
25
|
/** Classes do Card (a superfície). `className` vai pro <form>. */
|
|
24
|
-
cardClassName?: string
|
|
26
|
+
cardClassName?: string;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
|
|
@@ -35,14 +37,18 @@ export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
|
|
|
35
37
|
{(title !== undefined || description !== undefined) && (
|
|
36
38
|
<CardHeader>
|
|
37
39
|
{title !== undefined && <CardTitle>{title}</CardTitle>}
|
|
38
|
-
{description !== undefined &&
|
|
40
|
+
{description !== undefined && (
|
|
41
|
+
<CardDescription>{description}</CardDescription>
|
|
42
|
+
)}
|
|
39
43
|
</CardHeader>
|
|
40
44
|
)}
|
|
41
45
|
<ActionForm
|
|
42
46
|
{...rest}
|
|
43
|
-
body={(fields) => <
|
|
44
|
-
footer={(actions) =>
|
|
47
|
+
body={(fields) => <CardBody>{fields}</CardBody>}
|
|
48
|
+
footer={(actions) => (
|
|
49
|
+
<CardFooter className="justify-end">{actions}</CardFooter>
|
|
50
|
+
)}
|
|
45
51
|
/>
|
|
46
52
|
</Card>
|
|
47
|
-
)
|
|
53
|
+
);
|
|
48
54
|
}
|
|
@@ -1,42 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `dialog` —
|
|
2
|
+
* `dialog` — respostas imperativas (alert · confirm · prompt · choose) em UMA linha.
|
|
3
3
|
*
|
|
4
4
|
* await dialog.alert({ title: 'Sessão expirada', description: 'Você será desconectado.' })
|
|
5
|
-
* if (await dialog.confirm({ title: 'Excluir a sessão?',
|
|
5
|
+
* if (await dialog.confirm({ title: 'Excluir a sessão?', context: 'danger' })) …
|
|
6
6
|
* const nome = await dialog.prompt({ title: 'Nome do snapshot' })
|
|
7
7
|
*
|
|
8
8
|
* Mesma forma do `toast` (superfície imperativa + host no shell), com uma diferença: eles
|
|
9
9
|
* RESPONDEM — `alert` devolve `Promise<void>`, `confirm` `Promise<boolean>`, `prompt`
|
|
10
|
-
* `Promise<string | null
|
|
10
|
+
* `Promise<string | null>` e `choose` a união dos resultados declarados. É o que tira as
|
|
11
|
+
* ~15 linhas de JSX que cada tela montava.
|
|
11
12
|
*
|
|
12
13
|
* Namespace `dialog` de propósito: `window.alert/confirm/prompt` são globais do browser, e
|
|
13
14
|
* um import esquecido cairia no nativo (cinza, sem tema) sem erro. `window.dialog` não
|
|
14
15
|
* existe — a família fica imune ao footgun. `confirm()` segue exportado como alias.
|
|
15
16
|
*
|
|
16
|
-
* Por baixo é o <
|
|
17
|
+
* Por baixo é o <Dialog mode="alert">: `role="alertdialog"`, não fecha no clique fora, foco preso
|
|
17
18
|
* — a semântica que uma interrupção que EXIGE resposta pede. ESC/Cancelar resolve o "não"
|
|
18
19
|
* de cada tipo (void, `false`, `null`).
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import * as React from 'react'
|
|
23
|
+
import type { UiContext } from '../../../core/ui-context.ts'
|
|
22
24
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} from '../primitives/
|
|
25
|
+
Dialog,
|
|
26
|
+
DialogBody,
|
|
27
|
+
DialogClose,
|
|
28
|
+
DialogContent,
|
|
29
|
+
DialogDescription,
|
|
30
|
+
DialogFooter,
|
|
31
|
+
DialogHeader,
|
|
32
|
+
DialogMedia,
|
|
33
|
+
DialogTitle,
|
|
34
|
+
} from '../primitives/dialog.tsx'
|
|
35
|
+
import {
|
|
36
|
+
Button,
|
|
37
|
+
type ButtonContext,
|
|
38
|
+
type ButtonVariant,
|
|
39
|
+
} from '../primitives/button.tsx'
|
|
33
40
|
import { Input } from '../primitives/input.tsx'
|
|
34
41
|
|
|
35
42
|
/** Campos comuns aos três — o corpo (`body`) é o que o texto da `description` não expressa. */
|
|
36
43
|
interface DialogBase {
|
|
37
44
|
title: React.ReactNode
|
|
38
45
|
description?: React.ReactNode
|
|
39
|
-
/** Ícone grande acima do título (o quadro do
|
|
46
|
+
/** Ícone grande acima do título (o quadro do DialogMedia). */
|
|
40
47
|
media?: React.ReactNode
|
|
41
48
|
/** Conteúdo próprio entre a descrição e os botões (lista, detalhe destacado). */
|
|
42
49
|
body?: React.ReactNode
|
|
@@ -50,22 +57,43 @@ export interface AlertOptions extends DialogBase {}
|
|
|
50
57
|
/** `confirm` — pergunta de sim/não. */
|
|
51
58
|
export interface ConfirmOptions extends DialogBase {
|
|
52
59
|
cancel?: string
|
|
53
|
-
/**
|
|
60
|
+
/** Contexto do botão de confirmação. */
|
|
61
|
+
context?: Extract<UiContext, 'primary' | 'danger'>
|
|
62
|
+
/** @deprecated Use `context="danger"`. */
|
|
54
63
|
variant?: 'default' | 'destructive'
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
/** `prompt` — uma string livre. Form de verdade (campos, contrato) → ActionFormDialog. */
|
|
58
67
|
export interface PromptOptions extends DialogBase {
|
|
59
68
|
cancel?: string
|
|
69
|
+
context?: Extract<UiContext, 'primary' | 'danger'>
|
|
70
|
+
/** @deprecated Use `context="danger"`. */
|
|
60
71
|
variant?: 'default' | 'destructive'
|
|
61
72
|
placeholder?: string
|
|
62
73
|
defaultValue?: string
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
export interface DialogChoice<TResult extends string = string> {
|
|
77
|
+
/** Valor devolvido quando esta ação for escolhida. Deve ser único na coleção. */
|
|
78
|
+
result: TResult
|
|
79
|
+
label: React.ReactNode
|
|
80
|
+
context?: ButtonContext
|
|
81
|
+
variant?: ButtonVariant
|
|
82
|
+
initialFocus?: boolean
|
|
83
|
+
disabled?: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `choose` — uma escolha simples entre resultados explícitos. */
|
|
87
|
+
export interface ChooseOptions<TResult extends string = string>
|
|
88
|
+
extends Omit<DialogBase, 'action'> {
|
|
89
|
+
actions: readonly DialogChoice<TResult>[]
|
|
90
|
+
}
|
|
91
|
+
|
|
65
92
|
type Pending =
|
|
66
93
|
| ({ kind: 'alert'; id: number; resolve: () => void } & AlertOptions)
|
|
67
94
|
| ({ kind: 'confirm'; id: number; resolve: (ok: boolean) => void } & ConfirmOptions)
|
|
68
95
|
| ({ kind: 'prompt'; id: number; resolve: (value: string | null) => void } & PromptOptions)
|
|
96
|
+
| ({ kind: 'choose'; id: number; resolve: (result: string | null) => void } & ChooseOptions)
|
|
69
97
|
|
|
70
98
|
let nextId = 0
|
|
71
99
|
let listener: ((p: Pending) => void) | null = null
|
|
@@ -79,6 +107,33 @@ function requireHost(fn: string): Error | null {
|
|
|
79
107
|
)
|
|
80
108
|
}
|
|
81
109
|
|
|
110
|
+
function rejectAmbiguousContext(
|
|
111
|
+
fn: string,
|
|
112
|
+
options: ConfirmOptions | PromptOptions,
|
|
113
|
+
): Error | null {
|
|
114
|
+
if (options.context === undefined || options.variant === undefined) return null
|
|
115
|
+
return new Error(
|
|
116
|
+
`${fn} não permite combinar context com uma variante semântica legada.`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function validateChoose<TResult extends string>(options: ChooseOptions<TResult>): Error | null {
|
|
121
|
+
if (options.actions.length === 0) {
|
|
122
|
+
return new Error('dialog.choose() exige pelo menos uma ação.')
|
|
123
|
+
}
|
|
124
|
+
const focused = options.actions.filter((action) => action.initialFocus === true)
|
|
125
|
+
if (focused.length !== 1) {
|
|
126
|
+
return new Error('dialog.choose() exige uma única ação com initialFocus.')
|
|
127
|
+
}
|
|
128
|
+
if (focused[0]?.disabled === true) {
|
|
129
|
+
return new Error('dialog.choose() não permite initialFocus em uma ação desabilitada.')
|
|
130
|
+
}
|
|
131
|
+
if (new Set(options.actions.map((action) => action.result)).size !== options.actions.length) {
|
|
132
|
+
return new Error('dialog.choose() exige resultados únicos.')
|
|
133
|
+
}
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
82
137
|
export const dialog = {
|
|
83
138
|
/** Avisa e espera o "OK". `Promise<void>` — só reconhecimento, sem decisão. */
|
|
84
139
|
alert(options: AlertOptions): Promise<void> {
|
|
@@ -90,6 +145,8 @@ export const dialog = {
|
|
|
90
145
|
},
|
|
91
146
|
/** Pergunta sim/não. `Promise<boolean>` — fechar/ESC resolve `false`. */
|
|
92
147
|
confirm(options: ConfirmOptions): Promise<boolean> {
|
|
148
|
+
const ambiguous = rejectAmbiguousContext('dialog.confirm()', options)
|
|
149
|
+
if (ambiguous !== null) return Promise.reject(ambiguous)
|
|
93
150
|
const err = requireHost('dialog.confirm()')
|
|
94
151
|
if (err !== null) return Promise.reject(err)
|
|
95
152
|
return new Promise<boolean>((resolve) => {
|
|
@@ -98,12 +155,29 @@ export const dialog = {
|
|
|
98
155
|
},
|
|
99
156
|
/** Pede uma string. `Promise<string | null>` — fechar/ESC/Cancelar resolve `null`. */
|
|
100
157
|
prompt(options: PromptOptions): Promise<string | null> {
|
|
158
|
+
const ambiguous = rejectAmbiguousContext('dialog.prompt()', options)
|
|
159
|
+
if (ambiguous !== null) return Promise.reject(ambiguous)
|
|
101
160
|
const err = requireHost('dialog.prompt()')
|
|
102
161
|
if (err !== null) return Promise.reject(err)
|
|
103
162
|
return new Promise<string | null>((resolve) => {
|
|
104
163
|
listener?.({ ...options, kind: 'prompt', id: nextId++, resolve })
|
|
105
164
|
})
|
|
106
165
|
},
|
|
166
|
+
/** Oferece resultados explícitos. Fechar/ESC sem escolher resolve `null`. */
|
|
167
|
+
choose<const TResult extends string>(options: ChooseOptions<TResult>): Promise<TResult | null> {
|
|
168
|
+
const invalid = validateChoose(options)
|
|
169
|
+
if (invalid !== null) return Promise.reject(invalid)
|
|
170
|
+
const err = requireHost('dialog.choose()')
|
|
171
|
+
if (err !== null) return Promise.reject(err)
|
|
172
|
+
return new Promise<TResult | null>((resolve) => {
|
|
173
|
+
listener?.({
|
|
174
|
+
...options,
|
|
175
|
+
kind: 'choose',
|
|
176
|
+
id: nextId++,
|
|
177
|
+
resolve: (result) => resolve(result as TResult | null),
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
},
|
|
107
181
|
}
|
|
108
182
|
|
|
109
183
|
/** Alias retrocompatível — `confirm()` é `dialog.confirm()`. */
|
|
@@ -145,6 +219,7 @@ export function DialogHost(): React.ReactElement {
|
|
|
145
219
|
const cancel = (p: Pending): void => {
|
|
146
220
|
if (p.kind === 'alert') p.resolve()
|
|
147
221
|
else if (p.kind === 'confirm') p.resolve(false)
|
|
222
|
+
else if (p.kind === 'prompt') p.resolve(null)
|
|
148
223
|
else p.resolve(null)
|
|
149
224
|
}
|
|
150
225
|
const accept = (p: Pending, value: string): void => {
|
|
@@ -153,21 +228,28 @@ export function DialogHost(): React.ReactElement {
|
|
|
153
228
|
else p.resolve(value)
|
|
154
229
|
}
|
|
155
230
|
|
|
156
|
-
const variant = current !== undefined && current.kind
|
|
231
|
+
const variant = current !== undefined && (current.kind === 'confirm' || current.kind === 'prompt')
|
|
232
|
+
? current.variant
|
|
233
|
+
: undefined
|
|
234
|
+
const context = current !== undefined && (current.kind === 'confirm' || current.kind === 'prompt')
|
|
235
|
+
? current.context ?? (variant === 'destructive' ? 'danger' : 'primary')
|
|
236
|
+
: 'primary'
|
|
157
237
|
const defaultAction = current?.kind === 'alert' ? 'OK' : 'Confirmar'
|
|
158
238
|
|
|
159
239
|
return (
|
|
160
|
-
<
|
|
240
|
+
<Dialog
|
|
241
|
+
mode="alert"
|
|
161
242
|
open={current !== undefined}
|
|
162
243
|
onOpenChange={(open) => {
|
|
163
244
|
if (!open && current !== undefined) drop(current.id, cancel)
|
|
164
245
|
}}
|
|
165
246
|
>
|
|
166
247
|
{current !== undefined && (
|
|
167
|
-
<
|
|
248
|
+
<DialogContent
|
|
168
249
|
data-slot="dialog"
|
|
169
250
|
data-kind={current.kind}
|
|
170
|
-
|
|
251
|
+
className={current.kind === 'choose' ? 'max-w-lg' : undefined}
|
|
252
|
+
// Prompt: o foco vai pro input, não pro Cancelar (default do Dialog). Via
|
|
171
253
|
// querySelector (não ref) — o Input é function component sem forwardRef, e isto
|
|
172
254
|
// funciona igual em React 18 e 19.
|
|
173
255
|
onOpenAutoFocus={
|
|
@@ -182,14 +264,14 @@ export function DialogHost(): React.ReactElement {
|
|
|
182
264
|
: undefined
|
|
183
265
|
}
|
|
184
266
|
>
|
|
185
|
-
<
|
|
186
|
-
{current.media !== undefined && <
|
|
187
|
-
<
|
|
267
|
+
<DialogHeader>
|
|
268
|
+
{current.media !== undefined && <DialogMedia>{current.media}</DialogMedia>}
|
|
269
|
+
<DialogTitle>{current.title}</DialogTitle>
|
|
188
270
|
{current.description !== undefined && (
|
|
189
|
-
<
|
|
271
|
+
<DialogDescription>{current.description}</DialogDescription>
|
|
190
272
|
)}
|
|
191
|
-
</
|
|
192
|
-
{current.body !== undefined && <
|
|
273
|
+
</DialogHeader>
|
|
274
|
+
{current.body !== undefined && <DialogBody>{current.body}</DialogBody>}
|
|
193
275
|
{current.kind === 'prompt' && (
|
|
194
276
|
<Input
|
|
195
277
|
value={text}
|
|
@@ -203,22 +285,63 @@ export function DialogHost(): React.ReactElement {
|
|
|
203
285
|
}}
|
|
204
286
|
/>
|
|
205
287
|
)}
|
|
206
|
-
<
|
|
207
|
-
{
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
288
|
+
<DialogFooter
|
|
289
|
+
className={
|
|
290
|
+
current.kind === 'choose'
|
|
291
|
+
? 'grid-cols-[repeat(auto-fit,minmax(7rem,1fr))]'
|
|
292
|
+
: undefined
|
|
293
|
+
}
|
|
294
|
+
>
|
|
295
|
+
{current.kind === 'choose' ? (
|
|
296
|
+
current.actions.map((action, index) => {
|
|
297
|
+
const primary = index === current.actions.length - 1
|
|
298
|
+
return (
|
|
299
|
+
<DialogClose
|
|
300
|
+
key={action.result}
|
|
301
|
+
initialFocus={action.initialFocus}
|
|
302
|
+
asChild
|
|
303
|
+
>
|
|
304
|
+
<Button
|
|
305
|
+
context={action.context ?? (primary ? 'primary' : 'neutral')}
|
|
306
|
+
variant={action.variant ?? (primary ? 'solid' : 'ghost')}
|
|
307
|
+
disabled={action.disabled}
|
|
308
|
+
onClick={() => drop(current.id, (p) => {
|
|
309
|
+
if (p.kind === 'choose') p.resolve(action.result)
|
|
310
|
+
})}
|
|
311
|
+
>
|
|
312
|
+
{action.label}
|
|
313
|
+
</Button>
|
|
314
|
+
</DialogClose>
|
|
315
|
+
)
|
|
316
|
+
})
|
|
317
|
+
) : (
|
|
318
|
+
<>
|
|
319
|
+
{current.kind !== 'alert' && (
|
|
320
|
+
<DialogClose initialFocus asChild>
|
|
321
|
+
<Button
|
|
322
|
+
context="neutral"
|
|
323
|
+
variant="ghost"
|
|
324
|
+
onClick={() => drop(current.id, cancel)}
|
|
325
|
+
>
|
|
326
|
+
{current.cancel ?? 'Cancelar'}
|
|
327
|
+
</Button>
|
|
328
|
+
</DialogClose>
|
|
329
|
+
)}
|
|
330
|
+
<DialogClose initialFocus={current.kind === 'alert'} asChild>
|
|
331
|
+
<Button
|
|
332
|
+
context={context}
|
|
333
|
+
variant="solid"
|
|
334
|
+
onClick={() => drop(current.id, (p) => accept(p, text))}
|
|
335
|
+
>
|
|
336
|
+
{current.action ?? defaultAction}
|
|
337
|
+
</Button>
|
|
338
|
+
</DialogClose>
|
|
339
|
+
</>
|
|
211
340
|
)}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
onClick={() => drop(current.id, (p) => accept(p, text))}
|
|
215
|
-
>
|
|
216
|
-
{current.action ?? defaultAction}
|
|
217
|
-
</AlertDialogAction>
|
|
218
|
-
</AlertDialogFooter>
|
|
219
|
-
</AlertDialogContent>
|
|
341
|
+
</DialogFooter>
|
|
342
|
+
</DialogContent>
|
|
220
343
|
)}
|
|
221
|
-
</
|
|
344
|
+
</Dialog>
|
|
222
345
|
)
|
|
223
346
|
}
|
|
224
347
|
|