@meistrari/tela-build 1.73.0 → 1.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,8 +2,37 @@
2
2
 
3
3
  Latest updates and announcements.
4
4
 
5
+ ## September 18, 2026
6
+
7
+ - [Floating chat](/templates/chat#floating) docs now show `TelaChatAnswer` docked above the prompt input inside the window.
8
+
9
+ - [Chat Answer](/templates/chat#answer) centers the card horizontally in its container and keeps it within the available width on narrow screens.
10
+
11
+ - [Floating chat](/templates/chat) adds `closeOnOutsideClick` (default `true`); set it to `false` to keep the window open while using the surrounding page.
12
+
5
13
  ## September 17, 2026
6
14
 
15
+ - [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.
16
+
17
+ - [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.
18
+ - [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.
19
+ - [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.
20
+ - [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.
21
+ - [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.
22
+
23
+ - [Chat Answer](/templates/chat#answer) exposes multiple-choice descriptions on checkbox focus and associates them with checkboxes for screen readers.
24
+
25
+ - [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.
26
+ - [Chat history](/templates/chat) shows an empty state when there are no conversations.
27
+
28
+ - [Chat Answer](/templates/chat#answer) limits single-choice shortcuts to A/B/C and leaves repeated key events untouched.
29
+
30
+ - [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.
31
+
32
+ - [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.
33
+
34
+ - [Chat Answer](/templates/chat#answer) now uses standard Tela Build typography, compact controls, keyboard badges, and utility-based spacing instead of custom scoped CSS.
35
+
7
36
  - **Dropdown menu spacing.** Items with tooltips now use the same icon-to-label gap as other items. See [Dropdown menu](/components/dropdown-menu).
8
37
 
9
38
  ## September 16, 2026
@@ -26,6 +55,11 @@ Latest updates and announcements.
26
55
  - **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
56
  - **`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
57
 
58
+ ### Chat: Answer widget
59
+
60
+ - [Chat Answer](/templates/chat#answer): choice-only questions; typed answers belong in normal chat. Multiselect uses checkboxes without letter badges or shortcuts.
61
+ - [Chat bodies](/templates/chat) can pin and measure a widget/composer stack together, leaving space between the card and input.
62
+
29
63
  ## September 15, 2026
30
64
 
31
65
  ### 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-450px" mx-auto max-w-full 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