@meistrari/tela-build 1.73.0 → 1.74.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 CHANGED
@@ -2,8 +2,33 @@
2
2
 
3
3
  Latest updates and announcements.
4
4
 
5
+ ## September 18, 2026
6
+
7
+ - [Floating chat](/templates/chat) adds `closeOnOutsideClick` (default `true`); set it to `false` to keep the window open while using the surrounding page.
8
+
5
9
  ## September 17, 2026
6
10
 
11
+ - [Chat Answer](/templates/chat#answer) option descriptions now use the single-line tooltip. It wraps the option label and opens to the label's right on hover (0.5s) or keyboard focus, instead of above it.
12
+
13
+ - [Tooltip](/components/tooltip): new `asChild` prop merges the trigger into the slotted element instead of wrapping it in a button, and `reference` anchors the tooltip to another element. An `open` value set after mount now takes effect; leaving it `undefined` keeps hover and focus in charge.
14
+ - [Kbd](/components/kbd) is now a fixed 16×16px key with 10px text, a 5px radius and `bg-muted` / `text-secondary` colors. The glyph is nudged half a pixel so it sits vertically centered, and the key resets inherited letter-spacing so it is horizontally centered too.
15
+ - [Chat Answer](/templates/chat#answer) shows single-choice shortcuts (A/B/C) with `TelaKbd` instead of a `TelaBadge`, so the key stays visible on hovered and selected rows.
16
+ - [Chat Answer](/templates/chat#answer) single-choice rows now match multiple-choice rows: 34px tall, 8px radius, 10px inset, muted background on hover and selection, and no selection outline.
17
+ - [Chat Answer](/templates/chat#answer) single and multiple-choice options share one tooltip spec: the whole row (including the checkbox) triggers the description after 0.5s, anchored to the option label. From a checkbox only keyboard focus opens it, so clicking an option no longer pins the tooltip open.
18
+
19
+ - [Chat Answer](/templates/chat#answer) exposes multiple-choice descriptions on checkbox focus and associates them with checkboxes for screen readers.
20
+
21
+ - [Chat Answer](/templates/chat#answer) shows option descriptions in left-aligned tooltips after a 0.5-second hover, with arrows centered on the option text, keeping option rows compact.
22
+ - [Chat history](/templates/chat) shows an empty state when there are no conversations.
23
+
24
+ - [Chat Answer](/templates/chat#answer) limits single-choice shortcuts to A/B/C and leaves repeated key events untouched.
25
+
26
+ - [Chat Answer](/templates/chat#answer) displays single-choice shortcuts as 16×16px filled `TelaBadge` indicators with 9px letters, a muted background and neutral-500 text.
27
+
28
+ - [Chat Answer](/templates/chat#answer) uses 16px secondary-colored header icons and lets users click anywhere on the collapsed header to expand it; dismiss remains independent. Expanding restores keyboard focus so option shortcuts work immediately.
29
+
30
+ - [Chat Answer](/templates/chat#answer) now uses standard Tela Build typography, compact controls, keyboard badges, and utility-based spacing instead of custom scoped CSS.
31
+
7
32
  - **Dropdown menu spacing.** Items with tooltips now use the same icon-to-label gap as other items. See [Dropdown menu](/components/dropdown-menu).
8
33
 
9
34
  ## September 16, 2026
@@ -26,6 +51,11 @@ Latest updates and announcements.
26
51
  - **New `TelaChatAnimateEnter`.** Enter/exit preset (fade, slide, slight scale) for the prompt input and stop button; `TelaChatBody` docks the wrapper when it holds a docked surface. Props: `offset`, `scale`, `duration`. It reuses the prompt input's dock, so the conversation padding and scrollbar inset stay correct. See [Chat → Stop Run](/templates/chat#stop-run).
27
52
  - **`TelaChatMessage` stacks its children with an 8px gap.** A running turn can hold the indicator plus a `TelaChatTextShimmer` or reasoning trace in one message; single-child messages are unaffected.
28
53
 
54
+ ### Chat: Answer widget
55
+
56
+ - [Chat Answer](/templates/chat#answer): choice-only questions; typed answers belong in normal chat. Multiselect uses checkboxes without letter badges or shortcuts.
57
+ - [Chat bodies](/templates/chat) can pin and measure a widget/composer stack together, leaving space between the card and input.
58
+
29
59
  ## September 15, 2026
30
60
 
31
61
  ### Chat: Floating window
@@ -0,0 +1,251 @@
1
+ import { compileFunction } from 'node:vm'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { compileScript, parse } from '@vue/compiler-sfc'
5
+ import { transpileModule, ModuleKind, ScriptTarget } from 'typescript'
6
+ import * as Vue from 'vue'
7
+ import { describe, expect, it, vi } from 'vitest'
8
+ import { cn } from '../../../../utils/component-utils'
9
+
10
+ // Compile the real SFCs with Nuxt's Vue auto-imports, using a DOM-free renderer.
11
+ function loadComponent(name: string, imports: Record<string, unknown> = {}) {
12
+ const filename = resolve(__dirname, name)
13
+ const source = readFileSync(filename, 'utf8').replace('<script setup lang="ts">', '<script setup lang="ts">\nimport { inject, provide, ref, shallowRef, computed, reactive, watch, watchEffect, useId, unref, nextTick } from "vue"\nimport { cn } from "cn"')
14
+ const { descriptor } = parse(source, { filename })
15
+ const compiled = compileScript(descriptor, { id: name, inlineTemplate: true })
16
+ const code = transpileModule(compiled.content, { compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
17
+ const dependencies: Record<string, unknown> = { vue: Vue, cn: { cn }, ...imports }
18
+ const module = { exports: {} as { default?: Vue.Component } }
19
+ const execute = compileFunction(code, ['require', 'module', 'exports'], { filename }) as (require: (id: string) => unknown, module: unknown, exports: unknown) => void
20
+ execute((id: string) => {
21
+ if (!(id in dependencies))
22
+ throw new Error(`Unexpected import: ${id}`)
23
+ return dependencies[id]
24
+ }, module, module.exports)
25
+ return module.exports.default!
26
+ }
27
+
28
+ const Widget = loadComponent('answer.vue')
29
+ type Node = { contains: (target: unknown) => boolean, tag: string, props: Record<string, unknown>, children: Node[], style: { display: string }, parent?: Node, text?: string, focus: () => void, closest: (selector: string) => Node | null, addEventListener: (name: string, handler: unknown) => void, removeEventListener: (name: string, handler: unknown) => void }
30
+ function node(tag: string): Node {
31
+ return {
32
+ tag,
33
+ props: {},
34
+ children: [],
35
+ style: { display: '' },
36
+ focus: () => {},
37
+ contains(target) {
38
+ return target === this || this.children.some(child => child.contains(target))
39
+ },
40
+ closest(selector) {
41
+ if ('data-chat-message-list' in this.props)
42
+ return this
43
+ return this.parent?.closest(selector) ?? null
44
+ },
45
+ addEventListener(name, handler) { this.props[`listener:${name}`] = handler },
46
+ removeEventListener(name, handler) {
47
+ if (this.props[`listener:${name}`] === handler)
48
+ delete this.props[`listener:${name}`]
49
+ },
50
+ }
51
+ }
52
+ const renderer = Vue.createRenderer<Node, Node>({
53
+ createElement: node,
54
+ createText: text => ({ ...node('#text'), text }),
55
+ createComment: text => ({ ...node('#comment'), text }),
56
+ setText: (el, text) => { el.text = text },
57
+ setElementText: (el, text) => { el.text = text },
58
+ patchProp: (el, key, _old, value) => { el.props[key] = value },
59
+ parentNode: el => el.parent ?? null,
60
+ nextSibling: el => el.parent?.children[el.parent.children.indexOf(el) + 1] ?? null,
61
+ insert(el, parent, anchor) {
62
+ el.parent = parent
63
+ const index = anchor ? parent.children.indexOf(anchor) : -1
64
+ if (index < 0)
65
+ parent.children.push(el)
66
+ else parent.children.splice(index, 0, el)
67
+ },
68
+ remove(el) {
69
+ if (el.parent)
70
+ el.parent.children.splice(el.parent.children.indexOf(el), 1)
71
+ },
72
+ })
73
+ function all(root: Node, tag: string): Node[] {
74
+ return [...(root.tag === tag ? [root] : []), ...root.children.flatMap(child => all(child, tag))]
75
+ }
76
+ function fire(el: Node | undefined, event: string, ...args: unknown[]) {
77
+ if (!el)
78
+ throw new Error(`Missing target for ${event}`)
79
+ const handler = el.props[event]
80
+ if (typeof handler !== 'function')
81
+ throw new Error(`Missing handler: ${event}`)
82
+ return (handler as (...args: unknown[]) => unknown)(...args)
83
+ }
84
+
85
+ const question = { id: 'q1', title: 'Pick one', type: 'single', options: [{ id: 'a', label: 'First' }, { id: 'b', label: 'Second' }] }
86
+ function mount(overrides: Record<string, unknown> = {}) {
87
+ const events = { select: vi.fn(), toggle: vi.fn(), continue: vi.fn(), dismiss: vi.fn(), expanded: vi.fn() }
88
+ const props = Vue.reactive({ question, selected: [], ...overrides })
89
+ const root = node('host')
90
+ const app = renderer.createApp({ setup: () => () => Vue.h(Widget, {
91
+ ...props,
92
+ 'onSelect': events.select,
93
+ 'onToggle': events.toggle,
94
+ 'onContinue': events.continue,
95
+ 'onDismiss': events.dismiss,
96
+ 'onUpdate:expanded': events.expanded,
97
+ }) })
98
+ for (const name of ['TelaCard', 'TelaLabel', 'TelaCheckbox', 'TelaTextarea', 'TelaButton', 'TelaIconButton', 'TelaCollapsible', 'TelaCollapsibleContent', 'TelaCollapsibleTrigger', 'TelaKbd', 'TelaBadge', 'TelaTooltip']) {
99
+ app.component(name, Vue.defineComponent({ props: ['modelValue'], setup: (props, { attrs, slots }) => () => Vue.h(name, { ...attrs, ...props }, slots.default?.()) }))
100
+ }
101
+ app.mount(root)
102
+ return { root, props, events, unmount: () => app.unmount() }
103
+ }
104
+
105
+ describe('controlled answer UI', () => {
106
+ it('emits selection intent without changing the supplied answer', () => {
107
+ const view = mount()
108
+ const options = all(view.root, 'TelaButton').filter(button => 'aria-pressed' in button.props)
109
+ fire(options[1], 'onClick')
110
+ expect(view.events.select).toHaveBeenCalledTimes(1)
111
+ expect(view.events.select).toHaveBeenCalledWith('b')
112
+ expect(options.every(button => button.props['aria-pressed'] === false)).toBe(true)
113
+ expect(view.events.continue).not.toHaveBeenCalled()
114
+ view.unmount()
115
+ })
116
+ it('preserves selected state when disabled and emits no answer action', () => {
117
+ const view = mount({ disabled: true, selected: ['b'] })
118
+ const options = all(view.root, 'TelaButton').filter(button => 'aria-pressed' in button.props)
119
+ fire(options[0], 'onClick')
120
+ expect(view.events.select).not.toHaveBeenCalled()
121
+ expect(options.map(button => button.props['data-selected'])).toEqual([false, true])
122
+ view.unmount()
123
+ })
124
+ it('emits explicit checkbox state and leaves state management to the host', () => {
125
+ const view = mount({ question: { ...question, type: 'multiple' }, selected: ['a'] })
126
+ fire(all(view.root, 'TelaCheckbox')[1], 'onUpdate:modelValue', true)
127
+ expect(view.events.toggle).toHaveBeenCalledTimes(1)
128
+ expect(view.events.toggle).toHaveBeenCalledWith('b', true)
129
+ expect(all(view.root, 'TelaCheckbox')[0]?.props.modelValue).toBe(true)
130
+ view.unmount()
131
+ })
132
+ it('exposes checkbox descriptions and opens them on focus without selecting', async () => {
133
+ const view = mount({ question: { ...question, type: 'multiple', options: [
134
+ { ...question.options[0], description: 'Details needed to choose the first option.' },
135
+ question.options[1],
136
+ ] } })
137
+ const [described, plain] = all(view.root, 'TelaCheckbox')
138
+ const descriptionId = described!.props['aria-describedby']
139
+ expect(descriptionId).toBeTypeOf('string')
140
+ expect(all(view.root, 'span').find(el => el.props.id === descriptionId)?.text)
141
+ .toBe('Details needed to choose the first option.')
142
+ expect(plain!.props['aria-describedby']).toBeUndefined()
143
+
144
+ const keyboardFocus = { target: { matches: (selector: string) => selector === ':focus-visible' } }
145
+ const pointerFocus = { target: { matches: () => false } }
146
+
147
+ fire(described, 'onFocus', pointerFocus)
148
+ await Vue.nextTick()
149
+ expect(all(view.root, 'TelaTooltip')[0]!.props.open).toBeUndefined()
150
+
151
+ fire(described, 'onFocus', keyboardFocus)
152
+ await Vue.nextTick()
153
+ expect(all(view.root, 'TelaTooltip')[0]!.props.open).toBe(true)
154
+ expect(view.events.toggle).not.toHaveBeenCalled()
155
+
156
+ fire(described, 'onBlur')
157
+ await Vue.nextTick()
158
+ expect(all(view.root, 'TelaTooltip')[0]!.props.open).toBeUndefined()
159
+ fire(plain, 'onFocus', keyboardFocus)
160
+ await Vue.nextTick()
161
+ expect(all(view.root, 'TelaTooltip')[1]!.props.open).toBeUndefined()
162
+ view.unmount()
163
+ })
164
+ it('only exposes A/B/C shortcuts even when a host supplies more options', async () => {
165
+ vi.stubGlobal('Element', class {})
166
+ const view = mount({ question: { ...question, options: [
167
+ ...question.options,
168
+ { id: 'c', label: 'Third' },
169
+ { id: 'd', label: 'Fourth' },
170
+ ] } })
171
+ try {
172
+ await Vue.nextTick()
173
+ const options = all(view.root, 'TelaButton').filter(button => 'aria-pressed' in button.props)
174
+ expect(options.map(button => button.props['aria-keyshortcuts'])).toEqual(['A', 'B', 'C', undefined])
175
+ expect(all(view.root, 'TelaKbd')).toHaveLength(3)
176
+ const widget = all(view.root, 'div')[0]!
177
+ const event = { key: 'd', preventDefault: vi.fn(), stopPropagation: vi.fn() }
178
+ fire(widget, 'listener:keydown', event)
179
+ expect(view.events.select).not.toHaveBeenCalled()
180
+ expect(event.preventDefault).not.toHaveBeenCalled()
181
+ expect(event.stopPropagation).not.toHaveBeenCalled()
182
+ fire(widget, 'listener:keydown', { ...event, key: 'c' })
183
+ expect(view.events.select).toHaveBeenCalledWith('c')
184
+ }
185
+ finally {
186
+ view.unmount()
187
+ vi.unstubAllGlobals()
188
+ }
189
+ })
190
+ it.each(['Enter', 'ArrowLeft', 'ArrowRight', 'a'])('does not consume repeated %s events', (key) => {
191
+ const host = node('scope')
192
+ const view = mount({ keyboardTarget: host, canPrevious: true, canNext: true })
193
+ const event = { key, repeat: true, preventDefault: vi.fn(), stopPropagation: vi.fn() }
194
+ fire(host, 'listener:keydown', event)
195
+ expect(event.preventDefault).not.toHaveBeenCalled()
196
+ expect(event.stopPropagation).not.toHaveBeenCalled()
197
+ expect(view.events.select).not.toHaveBeenCalled()
198
+ expect(view.events.continue).not.toHaveBeenCalled()
199
+ view.unmount()
200
+ })
201
+ it('focuses the widget when reopened so shortcuts work immediately', async () => {
202
+ const view = mount({ expanded: false })
203
+ const panel = all(view.root, 'div').find(el => el.props.tabindex === '-1')!
204
+ const focus = vi.spyOn(panel, 'focus')
205
+ expect(focus).not.toHaveBeenCalled()
206
+ Object.assign(view.props, { expanded: true })
207
+ await Vue.nextTick()
208
+ await Vue.nextTick()
209
+ expect(focus).toHaveBeenCalledWith({ preventScroll: true })
210
+ view.unmount()
211
+ })
212
+ it('scopes shortcuts, ignores external inputs, and cleans up on collapse and unmount', async () => {
213
+ class Target {
214
+ constructor(private editing = false) {}
215
+ closest(selector: string) { return this.editing && selector.startsWith('input,') ? this : null }
216
+ }
217
+ vi.stubGlobal('Element', Target)
218
+ const host = node('scope')
219
+ const view = mount({ keyboardTarget: host })
220
+ const key = (key: string, editing = false, extra = {}) => ({ key, target: new Target(editing), preventDefault: vi.fn(), stopPropagation: vi.fn(), ...extra })
221
+ try {
222
+ await Vue.nextTick()
223
+ fire(host, 'listener:keydown', key('b'))
224
+ expect(view.events.select).toHaveBeenCalledTimes(1)
225
+ expect(view.events.select).toHaveBeenCalledWith('b')
226
+ const input = key('a', true)
227
+ fire(host, 'listener:keydown', input)
228
+ fire(host, 'listener:keydown', key('a', false, { isComposing: true }))
229
+ fire(host, 'listener:keydown', key('a', false, { repeat: true }))
230
+ expect(input.preventDefault).not.toHaveBeenCalled()
231
+ expect(view.events.select).toHaveBeenCalledTimes(1)
232
+ // A docked widget may sit outside the host's keyboard scope.
233
+ const widget = all(view.root, 'div')[0]!
234
+ fire(widget, 'listener:keydown', key('a'))
235
+ expect(view.events.select).toHaveBeenCalledTimes(2)
236
+ expect(view.events.select).toHaveBeenLastCalledWith('a')
237
+ fire(widget, 'listener:keydown', key('a', false, { defaultPrevented: true }))
238
+ expect(view.events.select).toHaveBeenCalledTimes(2)
239
+ Object.assign(view.props, { expanded: false })
240
+ await Vue.nextTick()
241
+ expect(host.props['listener:keydown']).toBeUndefined()
242
+ expect(widget.props['listener:keydown']).toBeUndefined()
243
+ Object.assign(view.props, { expanded: true })
244
+ await Vue.nextTick()
245
+ expect(host.props['listener:keydown']).toBeTypeOf('function')
246
+ view.unmount()
247
+ expect(host.props['listener:keydown']).toBeUndefined()
248
+ }
249
+ finally { vi.unstubAllGlobals() }
250
+ })
251
+ })
@@ -0,0 +1,297 @@
1
+ <script setup lang="ts">
2
+ import type { TelaChatAnswerQuestion, TelaChatAnswerLabels } from '../../../../types/chat-answer'
3
+
4
+ const props = withDefaults(defineProps<{
5
+ question: TelaChatAnswerQuestion
6
+ selected?: readonly string[]
7
+ current?: number
8
+ total?: number
9
+ expanded?: boolean
10
+ disabled?: boolean
11
+ submitting?: boolean
12
+ error?: string
13
+ showNavigation?: boolean
14
+ canPrevious?: boolean
15
+ canNext?: boolean
16
+ showContinue?: boolean
17
+ canContinue?: boolean
18
+ /** Optional host-owned keyboard scope. Defaults to this component only. */
19
+ keyboardTarget?: HTMLElement | null
20
+ labels?: Partial<TelaChatAnswerLabels>
21
+ }>(), {
22
+ selected: () => [],
23
+ current: 1,
24
+ total: 1,
25
+ expanded: true,
26
+ showContinue: true,
27
+ canContinue: true,
28
+ error: '',
29
+ keyboardTarget: null,
30
+ labels: () => ({}),
31
+ })
32
+ const emit = defineEmits<{
33
+ 'select': [id: string]
34
+ 'toggle': [id: string, checked: boolean]
35
+ 'update:expanded': [value: boolean]
36
+ 'previous': []
37
+ 'next': []
38
+ 'skip': []
39
+ 'continue': []
40
+ 'dismiss': []
41
+ }>()
42
+ const copy = computed<TelaChatAnswerLabels>(() => ({
43
+ minimize: 'Minimize answers',
44
+ expand: 'Expand answers',
45
+ dismiss: 'Dismiss questions',
46
+ previous: 'Previous question',
47
+ next: 'Next question',
48
+ skip: 'Skip',
49
+ continue: 'Continue',
50
+ recommended: '(recommended)',
51
+ selected: count => `${count} selected`,
52
+ progress: (current, total) => `${current} of ${total}`,
53
+ submitting: 'Sending answers…',
54
+ ...props.labels,
55
+ }))
56
+ const uid = useId()
57
+ const root = ref<HTMLElement | null>(null)
58
+ const questionPanel = ref<HTMLElement | null>(null)
59
+ const keyboardFocusedOption = ref<string | null>(null)
60
+
61
+ // Shared by single and multiple choice: the option label triggers the
62
+ // description, shown to its right. Hover is left to the tooltip itself.
63
+ function optionTooltip(option: TelaChatAnswerQuestion['options'][number]) {
64
+ return {
65
+ asChild: true,
66
+ side: 'right' as const,
67
+ delayDuration: 500,
68
+ disabled: !option.description,
69
+ content: option.description,
70
+ open: (Boolean(option.description) && keyboardFocusedOption.value === option.id) || undefined,
71
+ }
72
+ }
73
+
74
+ // The focusable control (button or checkbox) is not the tooltip trigger, so
75
+ // keyboard focus forces the description open. A mouse click also focuses it
76
+ // and would pin the tooltip, hence the :focus-visible check.
77
+ function trackKeyboardFocus(optionId: string, event: FocusEvent) {
78
+ const target = event.target
79
+ const keyboardFocus = target !== null && 'matches' in target && typeof target.matches === 'function' && Boolean(target.matches(':focus-visible'))
80
+
81
+ keyboardFocusedOption.value = keyboardFocus ? optionId : null
82
+ }
83
+ const interactive = computed(() => !props.disabled && !props.submitting)
84
+ watch(() => props.question.id, async () => {
85
+ keyboardFocusedOption.value = null
86
+ await nextTick()
87
+ if (props.expanded)
88
+ questionPanel.value?.focus()
89
+ })
90
+ watch(() => props.expanded, async (expanded) => {
91
+ keyboardFocusedOption.value = null
92
+ if (expanded) {
93
+ await nextTick()
94
+ root.value?.focus({ preventScroll: true })
95
+ }
96
+ else if (!expanded && typeof document !== 'undefined' && root.value?.contains(document.activeElement)) {
97
+ await nextTick()
98
+ root.value?.querySelector<HTMLButtonElement>('[data-answer-collapse]')?.focus()
99
+ }
100
+ })
101
+ watchEffect((onCleanup) => {
102
+ if (!interactive.value || !props.expanded)
103
+ return
104
+ const targets = new Set([root.value, props.keyboardTarget])
105
+ for (const target of targets)
106
+ target?.addEventListener('keydown', onKeydown, true)
107
+ onCleanup(() => {
108
+ for (const target of targets)
109
+ target?.removeEventListener('keydown', onKeydown, true)
110
+ })
111
+ })
112
+
113
+ function shortcut(index: number) {
114
+ return 'ABC'[index] ?? ''
115
+ }
116
+ function select(id: string) {
117
+ if (interactive.value)
118
+ emit('select', id)
119
+ }
120
+ function toggle(id: string, checked: boolean | 'indeterminate') {
121
+ if (interactive.value)
122
+ emit('toggle', id, checked === true)
123
+ }
124
+ function previous() {
125
+ if (interactive.value && props.canPrevious)
126
+ emit('previous')
127
+ }
128
+ function next() {
129
+ if (interactive.value && props.canNext)
130
+ emit('next')
131
+ }
132
+ function advance() {
133
+ if (interactive.value && props.canContinue)
134
+ emit('continue')
135
+ }
136
+ function onKeydown(event: KeyboardEvent) {
137
+ if (!interactive.value || !props.expanded || event.defaultPrevented || event.repeat || event.isComposing || event.keyCode === 229 || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)
138
+ return
139
+ const target = event.target instanceof Element ? event.target : null
140
+ const editing = Boolean(target?.closest('input, textarea, [contenteditable="true"]'))
141
+ if (editing && (!target || !root.value?.contains(target)))
142
+ return
143
+ if (/^[a-c]$/i.test(event.key)) {
144
+ if (editing || props.question.type !== 'single')
145
+ return
146
+ const option = props.question.options[event.key.toLowerCase().charCodeAt(0) - 97]
147
+ if (!option)
148
+ return
149
+ event.preventDefault()
150
+ event.stopPropagation()
151
+ select(option.id)
152
+ return
153
+ }
154
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
155
+ if (editing)
156
+ return
157
+ }
158
+ else if (event.key === 'Enter') {
159
+ if (target?.closest('button, a[href]') && !target.closest('[role="checkbox"]'))
160
+ return
161
+ }
162
+ else {
163
+ return
164
+ }
165
+ event.preventDefault()
166
+ event.stopPropagation()
167
+ if (event.key === 'Enter')
168
+ advance()
169
+ else if (event.key === 'ArrowLeft')
170
+ previous()
171
+ else next()
172
+ }
173
+ </script>
174
+
175
+ <template>
176
+ <div ref="root" tabindex="-1" outline-none>
177
+ <TelaCard size="sm" class="!p-0 w-400px" overflow-hidden>
178
+ <TelaCollapsible :open="expanded" @update:open="emit('update:expanded', $event)">
179
+ <div
180
+ flex items-center justify-between gap-8px min-h-56px py-8px pl-16px pr-12px
181
+ :class="{ 'cursor-pointer': !expanded }"
182
+ @click="!expanded && emit('update:expanded', true)"
183
+ >
184
+ <div flex="~ col" gap-6px min-w-0>
185
+ <span role="status" aria-live="polite" body-10-regular text-tertiary>{{ copy.progress(current, total) }}</span>
186
+ <h5 :id="`${uid}-${question.id}-title`" heading-h5-semibold text-primary text-balance>
187
+ {{ question.title }}
188
+ </h5>
189
+ </div>
190
+ <div flex items-center shrink-0 mr--3px>
191
+ <TelaCollapsibleTrigger as-child>
192
+ <TelaIconButton
193
+ data-answer-collapse
194
+ size="sm"
195
+ icon="i-ph-caret-down"
196
+ color="secondary"
197
+ :icon-class="cn('transition-all ease-in-out', { 'rotate-180': expanded })"
198
+ :aria-label="expanded ? copy.minimize : copy.expand"
199
+ @click.stop
200
+ />
201
+ </TelaCollapsibleTrigger>
202
+ <TelaIconButton
203
+ size="sm"
204
+ icon="i-ph-x"
205
+ color="secondary"
206
+ :aria-label="copy.dismiss"
207
+ :disabled="submitting"
208
+ @click.stop="emit('dismiss')"
209
+ />
210
+ </div>
211
+ </div>
212
+ <!-- The pull-up sits on the clipping element itself; on a child it would cut the first row. -->
213
+ <TelaCollapsibleContent class="mt--4px">
214
+ <div :key="question.id" ref="questionPanel" tabindex="-1" outline-none flex="~ col">
215
+ <ul v-if="question.type === 'single'" :aria-labelledby="`${uid}-${question.id}-title`" flex="~ col" gap-4px list-none m-0 py-0 px-6px>
216
+ <li v-for="(option, optionIndex) in question.options" :key="option.id" relative block list-none m-0 p-0>
217
+ <span v-if="optionIndex > 0" aria-hidden="true" absolute top="-2px" inset-x-8px border-t-0.5px border-solid border />
218
+ <TelaButton
219
+ variant="ghost"
220
+ w-full justify-between gap-8px text-left whitespace-normal
221
+ class="!py-8px !rounded-8px ![--padding-x:10px] duration-0!"
222
+ :class="selected.includes(option.id) ? '!bg-muted' : '!bg-transparent enabled:hover:!bg-muted'"
223
+ :aria-pressed="selected.includes(option.id)"
224
+ :aria-keyshortcuts="shortcut(optionIndex) || undefined"
225
+ :data-selected="selected.includes(option.id)"
226
+ :disabled="!interactive"
227
+ @focus="trackKeyboardFocus(option.id, $event)"
228
+ @blur="keyboardFocusedOption = null"
229
+ @click="select(option.id)"
230
+ >
231
+ <span flex="~ col" min-w-0>
232
+ <TelaTooltip v-bind="optionTooltip(option)">
233
+ <span body-14-regular text-primary self-start>
234
+ {{ option.label }}<span v-if="question.recommendFirst && optionIndex === 0" text-secondary ml-4px>{{ copy.recommended }}</span>
235
+ </span>
236
+ </TelaTooltip>
237
+ </span>
238
+ <TelaKbd v-if="shortcut(optionIndex)" class="shrink-0">
239
+ {{ shortcut(optionIndex) }}
240
+ </TelaKbd>
241
+ </TelaButton>
242
+ </li>
243
+ </ul>
244
+ <div v-else-if="question.type === 'multiple'" role="group" :aria-labelledby="`${uid}-${question.id}-title`" flex="~ col" px-6px gap-4px>
245
+ <template v-for="(option, optionIndex) in question.options" :key="option.id">
246
+ <div
247
+ relative px-10px rounded-8px flex items-center justify-between gap-8px
248
+ :class="selected.includes(option.id) ? 'bg-muted' : { 'hover:bg-muted': interactive }"
249
+ :data-selected="selected.includes(option.id)" :data-interactive="interactive"
250
+ >
251
+ <span v-if="optionIndex > 0" aria-hidden="true" absolute top="-2px" inset-x-8px border-t-0.5px border-solid border />
252
+ <TelaLabel :for="`${uid}-${question.id}-${option.id}`" flex="~ col" flex-1 min-w-0 cursor-pointer py-8px>
253
+ <TelaTooltip v-bind="optionTooltip(option)">
254
+ <span body-14-regular text-primary self-start>
255
+ {{ option.label }}<span v-if="question.recommendFirst && optionIndex === 0" text-secondary ml-4px>{{ copy.recommended }}</span>
256
+ </span>
257
+ </TelaTooltip>
258
+ </TelaLabel>
259
+ <div flex items-center gap-8px shrink-0>
260
+ <TelaCheckbox
261
+ :id="`${uid}-${question.id}-${option.id}`"
262
+ :aria-describedby="option.description ? `${uid}-${question.id}-${option.id}-description` : undefined"
263
+ :model-value="selected.includes(option.id)"
264
+ :disabled="!interactive"
265
+ @focus="trackKeyboardFocus(option.id, $event)"
266
+ @blur="keyboardFocusedOption = null"
267
+ @update:model-value="toggle(option.id, $event)"
268
+ />
269
+ </div>
270
+ </div>
271
+ <span v-if="option.description" :id="`${uid}-${question.id}-${option.id}-description`" sr-only>{{ option.description }}</span>
272
+ </template>
273
+ </div>
274
+
275
+ <div flex items-center justify-between gap-8px px-16px py-8px>
276
+ <div v-if="showNavigation" ml--6px flex items-center>
277
+ <TelaIconButton size="3xs" icon="i-ph-caret-left-bold" color="secondary" :aria-label="copy.previous" :disabled="!interactive || !canPrevious" class="disabled:bg-transparent!" @click="previous" />
278
+ <TelaIconButton size="3xs" icon="i-ph-caret-right-bold" color="secondary" :aria-label="copy.next" :disabled="!interactive || !canNext" class="disabled:bg-transparent!" @click="next" />
279
+ </div>
280
+
281
+ <span v-if="question.type === 'multiple'" body-12-regular text-tertiary>{{ copy.selected(selected.length) }}</span>
282
+
283
+ <div flex items-center gap-2px ml-auto mr--4px>
284
+ <TelaButton size="sm" variant="ghost" class="rounded-[6px]!" :disabled="!interactive" @click="interactive && emit('skip')">
285
+ {{ copy.skip }}
286
+ </TelaButton>
287
+ <TelaIconButton v-if="showContinue" size="3xs" :aria-label="copy.continue" :disabled="!interactive || !canContinue" :loading="submitting" icon="i-ph-arrow-right-bold" @click="advance" />
288
+ </div>
289
+ </div>
290
+ <span v-if="submitting" role="status" body-12-regular text-secondary px-16px pt-4px pb-12px>{{ copy.submitting }}</span>
291
+ <span v-if="error" role="alert" body-12-regular text-primary px-16px pt-4px pb-12px>{{ error }}</span>
292
+ </div>
293
+ </TelaCollapsibleContent>
294
+ </TelaCollapsible>
295
+ </TelaCard>
296
+ </div>
297
+ </template>
@@ -8,9 +8,9 @@ withDefaults(defineProps<{
8
8
 
9
9
  const rootRef = ref<HTMLElement | null>(null)
10
10
 
11
- // The docked element is the prompt-input surface, or the TelaChatAnimateEnter
12
- // wrapping it (document order puts the wrapper first).
13
- const DOCK_SELECTOR = '[data-chat-animate-enter]:has([data-chat-prompt-input]), [data-chat-prompt-input]'
11
+ // The docked element is the composer stack, the prompt-input surface, or the
12
+ // TelaChatAnimateEnter wrapping it (document order puts the outermost first).
13
+ const DOCK_SELECTOR = '[data-chat-composer], [data-chat-animate-enter]:has([data-chat-prompt-input]), [data-chat-prompt-input]'
14
14
  let inputObserver: ResizeObserver | null = null
15
15
  let slotObserver: MutationObserver | null = null
16
16
  let observedInput: HTMLElement | null = null
@@ -73,11 +73,11 @@ onBeforeUnmount(() => {
73
73
  }
74
74
 
75
75
  /*
76
- * The dock: the prompt-input surface itself, or the TelaChatAnimateEnter that
77
- * wraps it. A surface inside the wrapper stays in flow so only one box is
78
- * positioned, faded and (with popLayout) pinned.
76
+ * The dock: the composer stack, the prompt-input surface itself, or the
77
+ * TelaChatAnimateEnter that wraps it. A surface inside the wrapper or composer
78
+ * stays in flow so only one box is positioned, faded and (with popLayout) pinned.
79
79
  */
80
- [data-chat-body] :is([data-chat-prompt-input]:not([data-chat-animate-enter] *), [data-chat-animate-enter]:has([data-chat-prompt-input])) {
80
+ [data-chat-body] :is([data-chat-composer], [data-chat-prompt-input]:not(:is([data-chat-animate-enter], [data-chat-composer]) *), [data-chat-animate-enter]:has([data-chat-prompt-input]):not([data-chat-composer] *)) {
81
81
  --at-apply: 'absolute bottom-0 left-0 right-0 z-2 mx-12px mb-12px';
82
82
  }
83
83
 
@@ -104,7 +104,12 @@ onBeforeUnmount(() => {
104
104
  padding-bottom: calc(var(--chat-input-height, 64px) + 32px);
105
105
  }
106
106
 
107
- [data-chat-body][data-variant='page'] :is([data-chat-prompt-input]:not([data-chat-animate-enter] *), [data-chat-animate-enter]:has([data-chat-prompt-input])) {
107
+ [data-chat-body][data-variant='page'] :is([data-chat-composer], [data-chat-prompt-input]:not(:is([data-chat-animate-enter], [data-chat-composer]) *), [data-chat-animate-enter]:has([data-chat-prompt-input]):not([data-chat-composer] *)) {
108
108
  --at-apply: 'mx-24px mb-16px';
109
109
  }
110
+ /* The entire dock/composer stack is pinned and measured together. */
111
+ [data-chat-body] [data-chat-composer] [data-chat-prompt-input] {
112
+ position: static;
113
+ margin: 0;
114
+ }
110
115
  </style>
@@ -47,7 +47,10 @@ const resolvedEmptyMessage = computed(() =>
47
47
  </template>
48
48
 
49
49
  <template v-else>
50
- <TelaComboboxEmpty class="body-14-regular! text-center text-tertiary py-8px">
50
+ <div v-if="items.length === 0" role="status" body-14-regular text-center text-tertiary py-8px>
51
+ {{ resolvedEmptyMessage }}
52
+ </div>
53
+ <TelaComboboxEmpty v-else class="body-14-regular! text-center text-tertiary py-8px">
51
54
  {{ resolvedEmptyMessage }}
52
55
  </TelaComboboxEmpty>
53
56
  <TelaComboboxItem
@@ -981,6 +981,112 @@ const suggestions = [
981
981
  </template>
982
982
  ```
983
983
 
984
+ ### Answer
985
+
986
+ A question card for when the assistant needs a decision before it can continue: single choice (pick one, `A`/`B`/`C` shortcuts) or multiple choice (checkboxes). It is fully controlled. The host owns the question sequence, the selected ids, submission and dismissal; the card only emits intent and never sends a message. Hover an option's label, or focus the option with the keyboard, to read its description. Typed answers belong in the normal prompt input, not here.
987
+
988
+ ```vue
989
+ <script setup lang="ts">
990
+ import type { TelaChatAnswerQuestion } from '@meistrari/tela-build/types'
991
+
992
+ const questions: TelaChatAnswerQuestion[] = [
993
+ {
994
+ id: 'source',
995
+ title: 'What should the agent work on?',
996
+ type: 'single',
997
+ recommendFirst: true,
998
+ options: [
999
+ { id: 'upload', label: 'Upload files', description: 'Use the documents you provide.' },
1000
+ { id: 'search', label: 'Search the workspace', description: 'Find existing information.' },
1001
+ { id: 'scratch', label: 'Start from scratch', description: 'Create a new draft.' },
1002
+ ],
1003
+ },
1004
+ {
1005
+ id: 'sections',
1006
+ title: 'Which sections should the draft include?',
1007
+ type: 'multiple',
1008
+ options: [
1009
+ { id: 'summary', label: 'Executive summary', description: 'One paragraph a reader can stop at.' },
1010
+ { id: 'risks', label: 'Risks', description: 'What could go wrong and how likely it is.' },
1011
+ { id: 'next', label: 'Next steps' },
1012
+ ],
1013
+ },
1014
+ ]
1015
+
1016
+ const index = ref(0)
1017
+ const expanded = ref(true)
1018
+ const done = ref(false)
1019
+ const answers = ref<Record<string, string[]>>({})
1020
+
1021
+ const question = computed(() => questions[index.value]!)
1022
+ const selected = computed(() => answers.value[question.value.id] ?? [])
1023
+
1024
+ function select(id: string) {
1025
+ answers.value[question.value.id] = [id]
1026
+ }
1027
+
1028
+ function toggle(id: string, checked: boolean) {
1029
+ answers.value[question.value.id] = checked
1030
+ ? [...selected.value, id]
1031
+ : selected.value.filter(value => value !== id)
1032
+ }
1033
+
1034
+ function advance() {
1035
+ if (index.value < questions.length - 1) {
1036
+ index.value++
1037
+ return
1038
+ }
1039
+
1040
+ done.value = true
1041
+ expanded.value = false
1042
+ }
1043
+
1044
+ function restart() {
1045
+ index.value = 0
1046
+ answers.value = {}
1047
+ done.value = false
1048
+ expanded.value = true
1049
+ }
1050
+ </script>
1051
+
1052
+ <template>
1053
+ <div class="flex flex-col items-start gap-12px w-full max-w-400px">
1054
+ <TelaChatAnswer
1055
+ v-model:expanded="expanded"
1056
+ class="w-full"
1057
+ :question="question"
1058
+ :selected="selected"
1059
+ :current="index + 1"
1060
+ :total="questions.length"
1061
+ :disabled="done"
1062
+ :can-continue="selected.length > 0"
1063
+ show-navigation
1064
+ :can-previous="index > 0"
1065
+ :can-next="index < questions.length - 1"
1066
+ @select="select"
1067
+ @toggle="toggle"
1068
+ @previous="index--"
1069
+ @next="index++"
1070
+ @skip="advance"
1071
+ @continue="advance"
1072
+ @dismiss="expanded = false"
1073
+ />
1074
+ <TelaButton v-if="done" variant="secondary" size="sm" @click="restart">
1075
+ Ask again
1076
+ </TelaButton>
1077
+ </div>
1078
+ </template>
1079
+ ```
1080
+
1081
+ Keyboard, while the card is expanded and enabled:
1082
+
1083
+ - **`A` / `B` / `C`** select a single-choice option. Multiple-choice rows have no letter shortcuts.
1084
+ - **`Enter`** emits `continue` (when `canContinue`); on a focused button it keeps its native behavior.
1085
+ - **`←` / `→`** emit `previous` / `next` when `canPrevious` / `canNext` allow it.
1086
+ - **`Tab`** to a checkbox opens its description, which is also linked through `aria-describedby`.
1087
+
1088
+ Shortcuts are scoped to the card. Pass `keyboard-target` to listen on a host container instead (for example the chat body, so the keys work while focus is elsewhere in the conversation); keystrokes typed into inputs, with modifiers, held down, or during IME composition are always ignored. Changing `question.id` moves focus to the new question, and collapsing returns focus to the collapse button.
1089
+
984
1090
  ### Working Indicator
985
1091
 
986
1092
  The running state of an assistant turn: a small `TelaLoadersSquare`, a label (`Working` by default) and an elapsed counter. Show it whenever something is running. A running turn is one `TelaChatMessage`: put the indicator and anything else the wait needs (a shimmering activity line, a reasoning trace) inside it, and the message stacks them 8px apart while the conversation keeps its 16px between turns. Pass `started-at` (epoch ms) and it ticks by itself; pass `elapsed` (seconds) to drive it from the host, for example from a server-reported duration.
@@ -1134,6 +1240,7 @@ import { TelaChatIconsAnthropic, TelaChatIconsGemini, TelaChatIconsOpenai } from
1134
1240
  | `TelaChatFloatingWindowTrigger` | Pill button that opens the floating widget. |
1135
1241
  | `TelaChatFloatingWindow` | Anchored popover shell for the floating variant (`open` / `@update:open`). |
1136
1242
  | `TelaChatSidepanelWindow` | Always-open, in-flow docked column for the sidepanel variant. |
1243
+ | `TelaChatAnswer` | Controlled single/multiple-choice question card; emits intent only, the host owns state and submission. |
1137
1244
  | `TelaChatWorkingIndicator` | Running-turn row: square loader, label, elapsed time. |
1138
1245
  | `TelaChatStopRunButton` | Replaces the prompt input while a turn runs; centered pill that emits `stop`. |
1139
1246
  | `TelaChatAnimateEnter` | Enter/exit preset (fade, slide, slight scale) for a surface that comes and goes; when it wraps the prompt input or stop button the body docks the wrapper. Pair with `AnimatePresence`. |
@@ -1397,6 +1504,7 @@ type FloatingWindowTriggerProps = {
1397
1504
  }
1398
1505
 
1399
1506
  type FloatingWindowProps = {
1507
+ closeOnOutsideClick?: boolean // defaults to true; false keeps the window open on outside clicks
1400
1508
  open: boolean
1401
1509
  contained?: boolean // anchor the launcher to the nearest positioned ancestor instead of the viewport
1402
1510
  triggerLabel?: string // label of the default trigger; ignored when #launcher is provided
@@ -1406,6 +1514,68 @@ type FloatingWindowProps = {
1406
1514
  // TelaChatSidepanelWindow takes no props.
1407
1515
  ```
1408
1516
 
1517
+ ### Answer
1518
+
1519
+ ```typescript
1520
+ type AnswerProps = {
1521
+ question: TelaChatAnswerQuestion
1522
+ selected?: readonly string[] // default: []; option ids, owned by the host
1523
+ expanded?: boolean // default: true; v-model:expanded
1524
+ disabled?: boolean // keeps the selection visible, blocks every answer action
1525
+ submitting?: boolean // same as disabled, plus a spinner on continue and a status line
1526
+ error?: string // default: ''; rendered as an alert under the footer
1527
+ current?: number // default: 1; display only ("1 of 3")
1528
+ total?: number // default: 1; display only, no limit is enforced
1529
+ showNavigation?: boolean // previous / next arrows in the footer
1530
+ canPrevious?: boolean
1531
+ canNext?: boolean
1532
+ showContinue?: boolean // default: true; set false when a single choice advances by itself
1533
+ canContinue?: boolean // default: true
1534
+ labels?: Partial<TelaChatAnswerLabels> // copy overrides; no i18n plugin required
1535
+ keyboardTarget?: HTMLElement | null // default: null; extra element to listen for shortcuts on
1536
+ }
1537
+
1538
+ type AnswerEmits = {
1539
+ 'select': [id: string] // single choice
1540
+ 'toggle': [id: string, checked: boolean] // multiple choice
1541
+ 'update:expanded': [value: boolean]
1542
+ 'previous': []
1543
+ 'next': []
1544
+ 'skip': []
1545
+ 'continue': []
1546
+ 'dismiss': [] // the host decides whether to remove the card
1547
+ }
1548
+
1549
+ // import type { ... } from '@meistrari/tela-build/types'
1550
+ type TelaChatAnswerQuestion = {
1551
+ id: string // changing it resets focus to the new question
1552
+ title: string
1553
+ type: 'single' | 'multiple'
1554
+ options: TelaChatAnswerOption[]
1555
+ recommendFirst?: boolean // appends "(recommended)" to the first option
1556
+ }
1557
+
1558
+ type TelaChatAnswerOption = {
1559
+ id: string
1560
+ label: string
1561
+ description?: string // shown in a tooltip to the right of the label on hover (0.5s) and keyboard focus
1562
+ }
1563
+
1564
+ type TelaChatAnswerLabels = {
1565
+ minimize: string
1566
+ expand: string
1567
+ dismiss: string
1568
+ previous: string
1569
+ next: string
1570
+ skip: string
1571
+ continue: string
1572
+ recommended: string
1573
+ submitting: string
1574
+ selected: (count: number) => string
1575
+ progress: (current: number, total: number) => string
1576
+ }
1577
+ ```
1578
+
1409
1579
  ### Working Indicator
1410
1580
 
1411
1581
  ```typescript
@@ -1464,6 +1634,8 @@ type TextShimmerProps = {
1464
1634
  - **Keep the primitives logic-free.** Fetch, stream, and persist in the host; pass results down as props and slots. No primitive owns message state.
1465
1635
  - **`contained` for user messages, `flat` for assistant messages.** Assistant output reads as page text; user input reads as a sent bubble.
1466
1636
  - **Never hand-roll the toolbar layout.** `TelaChatPromptInputTools` (attachment actions) on the left, `TelaChatPromptInputActions` with `TelaChatModelSelector` + `TelaChatPromptSubmitButton` on the right. Always the same order; never substitute a raw `<div class="flex items-center gap-4px">`.
1637
+ - **`TelaChatAnswer` never talks to the server.** Parsing the model's questions, limits on how many questions or options to ask, saved answers, retries and what happens after the last question all live in the host (or its adapter). Every event is intent only; `dismiss` does not remove the card.
1638
+ - **Dock the active answer card above the prompt input.** Put both inside one `[data-chat-composer]` wrapper with a 12px gap so `TelaChatBody` pins and measures them together. Only one card should be interactive at a time, especially when sharing a `keyboardTarget`.
1467
1639
  - **One shell per page.** Never mount `TelaChatFloatingWindow` and `TelaChatSidepanelWindow` at the same time.
1468
1640
  - **Always wrap the conversation and prompt input in `TelaChatBody`.** It owns the spacing and pins the prompt input to the bottom, and messages scroll under it through a progressive blur. `variant="widget"` (default) inside the floating/sidepanel shells, `variant="page"` on full pages. Never re-add spacing or positioning classes to `TelaChatConversation` or `TelaChatPromptInput`.
1469
1641
  - **The `#launcher` slot is optional; when provided it must be exactly one element.** It is an anchor, not a container; omit it to get the default `TelaChatFloatingWindowTrigger`.
@@ -0,0 +1,26 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { beforeEach, describe, expect, it } from 'vitest'
4
+ import { hasOpenEscapeLayer } from './escape-layer'
5
+
6
+ describe('floating chat escape layers', () => {
7
+ beforeEach(() => {
8
+ document.body.innerHTML = ''
9
+ })
10
+
11
+ it('defers dismissal while a higher-priority layer is open', () => {
12
+ document.body.innerHTML = '<div data-tela-escape-layer data-state="open"></div>'
13
+
14
+ expect(hasOpenEscapeLayer()).toBe(true)
15
+ })
16
+
17
+ it('does not defer dismissal for a layer that has closed', () => {
18
+ document.body.innerHTML = '<div data-tela-escape-layer data-state="closed"></div>'
19
+
20
+ expect(hasOpenEscapeLayer()).toBe(false)
21
+ })
22
+
23
+ it('does not defer dismissal when no higher-priority layer exists', () => {
24
+ expect(hasOpenEscapeLayer()).toBe(false)
25
+ })
26
+ })
@@ -0,0 +1,3 @@
1
+ export function hasOpenEscapeLayer(root: ParentNode = document) {
2
+ return Boolean(root.querySelector('[data-tela-escape-layer][data-state="open"]'))
3
+ }
@@ -1,13 +1,18 @@
1
1
  <script setup lang="ts">
2
+ import { hasOpenEscapeLayer } from './escape-layer'
2
3
  import { floatingWindowContextKey } from './floating-window-context'
3
4
 
4
- const props = defineProps<{
5
+ const props = withDefaults(defineProps<{
5
6
  open: boolean
7
+ /** Close on outside clicks. Disable to keep the chat open while interacting with the page. */
8
+ closeOnOutsideClick?: boolean
6
9
  /** Anchors the launcher to the nearest positioned ancestor instead of the viewport (docs/embedded previews). */
7
10
  contained?: boolean
8
11
  /** Label of the default trigger, ignored when the `launcher` slot is provided. */
9
12
  triggerLabel?: string
10
- }>()
13
+ }>(), {
14
+ closeOnOutsideClick: true,
15
+ })
11
16
 
12
17
  const emit = defineEmits<{
13
18
  (e: 'update:open', value: boolean): void
@@ -19,7 +24,7 @@ const launcherWidth = ref(0)
19
24
  const launcherHeight = ref(0)
20
25
  const sideOffset = computed(() => -launcherHeight.value)
21
26
 
22
- const morphTransition = { type: 'spring', stiffness: 420, damping: 42, mass: 1 } as const
27
+ const morphTransition = { type: 'spring', stiffness: 900, damping: 58, mass: 1 } as const
23
28
 
24
29
  const exitTransition = { type: 'spring', stiffness: 360, damping: 36, mass: 1 } as const
25
30
 
@@ -67,7 +72,7 @@ const contentAnimate = computed(() => props.open
67
72
  ? { scale: 1, opacity: 1 }
68
73
  : { scale: contentExitScale, opacity: 0 })
69
74
  const contentTransition = computed(() => props.open
70
- ? { ...morphTransition, opacity: { duration: 0.16 } }
75
+ ? { ...morphTransition, opacity: { duration: 0.12 } }
71
76
  : { ...exitTransition, opacity: { duration: 0.14, delay: 0.08 } })
72
77
 
73
78
  function measureLauncher() {
@@ -98,6 +103,49 @@ provide(floatingWindowContextKey, {
98
103
  let previousOverflow = ''
99
104
  let previousPaddingRight = ''
100
105
  let hasScrollLock = false
106
+ let deferNextDismissal = false
107
+
108
+ function handleEscapeKeyDown() {
109
+ deferNextDismissal = hasOpenEscapeLayer()
110
+
111
+ if (deferNextDismissal) {
112
+ queueMicrotask(() => {
113
+ deferNextDismissal = false
114
+ })
115
+ }
116
+ }
117
+
118
+ function handleOpenUpdate(value: boolean) {
119
+ if (!value && deferNextDismissal) {
120
+ deferNextDismissal = false
121
+ return
122
+ }
123
+
124
+ emit('update:open', value)
125
+ }
126
+
127
+ function handleInteractOutside(event: CustomEvent) {
128
+ if (!props.closeOnOutsideClick) {
129
+ event.preventDefault()
130
+ return
131
+ }
132
+
133
+ const originalEvent = (event.detail as { originalEvent?: Event } | undefined)?.originalEvent
134
+
135
+ if (originalEvent?.type !== 'pointerdown') {
136
+ event.preventDefault()
137
+
138
+ return
139
+ }
140
+
141
+ const target = event.target
142
+
143
+ if (!(target instanceof Element))
144
+ return
145
+
146
+ if (!target.isConnected || target.closest('[data-reka-popper-content-wrapper], [data-dismissable-layer], [data-tela-escape-layer]'))
147
+ event.preventDefault()
148
+ }
101
149
 
102
150
  function lockScroll() {
103
151
  if (hasScrollLock)
@@ -150,24 +198,6 @@ onBeforeUnmount(() => {
150
198
  unlockScroll()
151
199
  })
152
200
 
153
- function handleInteractOutside(event: CustomEvent) {
154
- const originalEvent = (event.detail as { originalEvent?: Event } | undefined)?.originalEvent
155
-
156
- if (originalEvent?.type !== 'pointerdown') {
157
- event.preventDefault()
158
-
159
- return
160
- }
161
-
162
- const target = event.target
163
-
164
- if (!(target instanceof Element))
165
- return
166
-
167
- if (!target.isConnected || target.closest('[data-reka-popper-content-wrapper], [data-dismissable-layer]'))
168
- event.preventDefault()
169
- }
170
-
171
201
  function focusEditor(event: Event) {
172
202
  event.preventDefault()
173
203
 
@@ -175,13 +205,18 @@ function focusEditor(event: Event) {
175
205
  const content = event.target instanceof HTMLElement
176
206
  ? event.target
177
207
  : document.querySelector('.widget-popover')
178
- const editor = content?.querySelector<HTMLElement>('[data-prompt-editor] [contenteditable="true"]')
208
+ const editor = Array.from(content?.querySelectorAll<HTMLElement>(
209
+ '[data-prompt-editor] [contenteditable="true"], [data-chat-prompt-input] input:not(:disabled), [data-chat-prompt-input] textarea:not(:disabled), [data-chat-answer] input:not(:disabled), [data-chat-answer] textarea:not(:disabled)',
210
+ ) ?? []).find(element => element.getClientRects().length > 0)
179
211
 
180
212
  if (!editor)
181
213
  return
182
214
 
183
215
  editor.focus()
184
216
 
217
+ if (!editor.isContentEditable)
218
+ return
219
+
185
220
  const selection = window.getSelection()
186
221
 
187
222
  if (!selection)
@@ -199,8 +234,8 @@ function focusEditor(event: Event) {
199
234
  </script>
200
235
 
201
236
  <template>
202
- <TelaPopover :open="open" @update:open="emit('update:open', $event)">
203
- <div ref="launcher" :class="cn('bottom-16px', contained ? 'absolute right-16px' : 'fixed right-[calc(16px+var(--scrollbar-width,0px))]')">
237
+ <TelaPopover :open="open" @update:open="handleOpenUpdate">
238
+ <div ref="launcher" :class="cn('bottom-16px z-599', contained ? 'absolute right-16px' : 'fixed right-[calc(16px+var(--scrollbar-width,0px))]')">
204
239
  <div :class="cn('transition-opacity', open ? 'opacity-0 duration-100' : 'duration-160 delay-160')">
205
240
  <TelaPopoverTrigger as-child>
206
241
  <slot name="launcher">
@@ -214,13 +249,14 @@ function focusEditor(event: Event) {
214
249
  align="end"
215
250
  :side-offset="sideOffset"
216
251
  :class="cn(
217
- 'widget-popover relative w-[min(480px,calc(100vw-32px))] p-0 z-40! transition-[height] duration-200 ease-out',
252
+ 'widget-popover relative w-[min(480px,calc(100vw-32px))] p-0 z-599! transition-[height] duration-200 ease-out',
218
253
  expanded
219
254
  ? 'h-[calc(var(--reka-popover-content-available-height)-16px)]'
220
255
  : 'h-[min(512px,calc(100dvh-120px))]',
221
256
  'bg-transparent! border-0! shadow-none! rounded-none! overflow-visible!',
222
257
  )"
223
258
  @open-auto-focus="focusEditor"
259
+ @escape-key-down="handleEscapeKeyDown"
224
260
  @interact-outside="handleInteractOutside"
225
261
  @focus-outside.prevent
226
262
  >
@@ -31,7 +31,7 @@ const emit = defineEmits<{
31
31
  const style = computed(() => {
32
32
  const base = 'cursor-pointer flex items-center justify-center p-0.5em ease-out duration-40'
33
33
  const size = ({
34
- '3xs': 'text-6px p-1 rounded-8px',
34
+ '3xs': 'text-6px p-1 rounded-6px',
35
35
  '2xs': 'text-8px p-4px rounded-4px', // 16px
36
36
  'xs': 'text-12px p-6px rounded-8px', // 26px
37
37
  'sm': 'text-16px p-6px rounded-10px', // 32px
@@ -7,17 +7,17 @@ const props = withDefaults(defineProps<{
7
7
  })
8
8
 
9
9
  const variantClasses = computed(() => (({
10
- default: 'bg-gray-200 text-gray-500',
10
+ default: 'bg-muted text-secondary',
11
11
  dark: 'bg-neutral-700/60 text-neutral-300',
12
12
  }) as Record<'default' | 'dark', string>)[props.variant])
13
13
  </script>
14
14
 
15
15
  <template>
16
16
  <code
17
- flex items-center justify-center h-16px text-12px rounded-6px
18
- :class="cn(variantClasses, icon ? 'w-20px h-20px' : 'px-4px')"
17
+ flex items-center justify-center w-16px h-16px text-10px leading-none tracking-normal rounded-5px
18
+ :class="cn(variantClasses)"
19
19
  >
20
20
  <TelaIcon v-if="icon" :name="icon" class="h-14px! w-14px!" />
21
- <slot v-else />
21
+ <span v-else class="translate-0.25px"><slot /></span>
22
22
  </code>
23
23
  </template>
@@ -145,6 +145,8 @@ type TooltipProps = {
145
145
  collisionPadding?: number | TooltipCollisionPadding
146
146
  delayDuration?: number
147
147
  disabled?: boolean
148
+ asChild?: boolean // merge the trigger into the slotted element (no wrapper button)
149
+ reference?: ReferenceElement // anchor to another element; the slot stays the trigger
148
150
  }
149
151
  ```
150
152
 
@@ -1,10 +1,11 @@
1
1
  // @vitest-environment happy-dom
2
- import { computed, defineComponent, h } from 'vue'
2
+ import { computed, defineComponent, h, ref } from 'vue'
3
3
  import { mount } from '@vue/test-utils'
4
4
  import { describe, expect, it, vi } from 'vitest'
5
5
  import Tooltip from './tooltip.vue'
6
6
 
7
7
  vi.stubGlobal('computed', computed)
8
+ vi.stubGlobal('ref', ref)
8
9
 
9
10
  const slotStub = defineComponent({
10
11
  setup(_, { slots }) {
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import type { TooltipTriggerProps } from 'reka-ui'
2
3
  import type { TooltipBodyProps } from './tooltip-body.vue'
3
4
 
4
5
  export type TooltipProps = TooltipBodyProps & {
@@ -6,9 +7,13 @@ export type TooltipProps = TooltipBodyProps & {
6
7
  delayDuration?: number
7
8
  open?: boolean
8
9
  triggerClass?: string
10
+ /** Merge the trigger into the slotted element instead of wrapping it in a button. */
11
+ asChild?: boolean
12
+ /** Anchor the tooltip to another element while the slot stays the trigger. */
13
+ reference?: TooltipTriggerProps['reference']
9
14
  }
10
15
 
11
- const props = withDefaults(defineProps<TooltipProps>(), {
16
+ withDefaults(defineProps<TooltipProps>(), {
12
17
  open: undefined,
13
18
  variant: 'single',
14
19
  })
@@ -17,21 +22,15 @@ defineEmits<{
17
22
  (e: 'clickContent', event: Event): void
18
23
  }>()
19
24
 
20
- const tooltipRootProps = computed(() => {
21
- const rootProps: any = {}
22
-
23
- if (props.open !== undefined) {
24
- rootProps.open = props.open
25
- }
26
-
27
- return rootProps
28
- })
25
+ // `open` forces the state when set; otherwise hover/focus drive it. The root is
26
+ // always controlled so an `open` that arrives after mount is still forwarded.
27
+ const interactionOpen = ref(false)
29
28
  </script>
30
29
 
31
30
  <template>
32
31
  <TelaTooltipProvider :disabled="disabled" :delay-duration="delayDuration">
33
- <TelaTooltipRoot v-bind="tooltipRootProps">
34
- <TelaTooltipTrigger :class="triggerClass" :tabindex="-1">
32
+ <TelaTooltipRoot :open="open ?? interactionOpen" @update:open="interactionOpen = $event">
33
+ <TelaTooltipTrigger :class="triggerClass" :as-child="asChild" :reference="reference" :tabindex="asChild ? undefined : -1">
35
34
  <slot />
36
35
  </TelaTooltipTrigger>
37
36
  <TelaTooltipBody
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-build",
3
- "version": "1.73.0",
3
+ "version": "1.74.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "app.config.ts",
@@ -0,0 +1,27 @@
1
+ export interface TelaChatAnswerOption {
2
+ id: string
3
+ label: string
4
+ description?: string
5
+ }
6
+
7
+ export interface TelaChatAnswerQuestion {
8
+ id: string
9
+ title: string
10
+ type: 'single' | 'multiple'
11
+ options: TelaChatAnswerOption[]
12
+ recommendFirst?: boolean
13
+ }
14
+
15
+ export interface TelaChatAnswerLabels {
16
+ minimize: string
17
+ expand: string
18
+ dismiss: string
19
+ previous: string
20
+ next: string
21
+ skip: string
22
+ continue: string
23
+ recommended: string
24
+ selected: (count: number) => string
25
+ progress: (current: number, total: number) => string
26
+ submitting: string
27
+ }
package/types/index.ts CHANGED
@@ -1,2 +1,3 @@
1
+ export * from './chat-answer'
1
2
  export * from './custom-icon'
2
3
  export * from './status'