@meistrari/tela-build 1.72.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/components/tela/avatar/label/avatar-label.mdx +2 -0
  3. package/components/tela/avatar/label/avatar-label.vue +25 -3
  4. package/components/tela/chat/answer/answer.test.ts +251 -0
  5. package/components/tela/chat/answer/answer.vue +297 -0
  6. package/components/tela/chat/chat-body.vue +13 -8
  7. package/components/tela/chat/chat-widget-history-list.vue +4 -1
  8. package/components/tela/chat/chat.mdx +172 -0
  9. package/components/tela/chat/floating/escape-layer.test.ts +26 -0
  10. package/components/tela/chat/floating/escape-layer.ts +3 -0
  11. package/components/tela/chat/floating/floating-window.vue +62 -26
  12. package/components/tela/copy-button.vue +4 -0
  13. package/components/tela/dropdown-menu/DropdownMenuItem.vue +2 -2
  14. package/components/tela/filter/filter-content.test.ts +60 -0
  15. package/components/tela/filter/filter-content.vue +3 -0
  16. package/components/tela/filter/filter-trigger.vue +2 -1
  17. package/components/tela/filter/filter.mdx +13 -1
  18. package/components/tela/filter/filter.vue +2 -1
  19. package/components/tela/filter/types.ts +1 -0
  20. package/components/tela/icon-button/icon-button.vue +1 -1
  21. package/components/tela/kbd/kbd.vue +4 -4
  22. package/components/tela/live-label.vue +36 -20
  23. package/components/tela/pane.vue +11 -3
  24. package/components/tela/timeline-waterfall/timeline-waterfall.mdx +85 -8
  25. package/components/tela/timeline-waterfall/timeline-waterfall.test.ts +917 -0
  26. package/components/tela/timeline-waterfall/timeline-waterfall.vue +905 -199
  27. package/components/tela/tooltip/tooltip-body.vue +19 -7
  28. package/components/tela/tooltip/tooltip.mdx +32 -31
  29. package/components/tela/tooltip/tooltip.test.ts +47 -0
  30. package/components/tela/tooltip/tooltip.vue +13 -12
  31. package/lib/extractors/volar-extract.ts +6 -6
  32. package/package.json +1 -1
  33. package/types/chat-answer.ts +27 -0
  34. package/types/index.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
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
+
9
+ ## September 17, 2026
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
+
32
+ - **Dropdown menu spacing.** Items with tooltips now use the same icon-to-label gap as other items. See [Dropdown menu](/components/dropdown-menu).
33
+
5
34
  ## September 16, 2026
6
35
 
7
36
  ### Square Loader
@@ -22,6 +51,11 @@ Latest updates and announcements.
22
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).
23
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.
24
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
+
25
59
  ## September 15, 2026
26
60
 
27
61
  ### Chat: Floating window
@@ -7,6 +7,8 @@ import * as AvatarLabelStories from './avatar-label.stories.ts';
7
7
 
8
8
  An inline avatar + text chip. Pairs a `TelaAvatar` with a text label so any "entity reference" (created-by, assigned-to, owner, reviewer, member, etc.) renders consistently across summary rows, list cells, and metadata strips. The component is named after its **shape**, not a role — pick the role at the call site via the `label`.
9
9
 
10
+ The avatar retains its configured size in narrow containers. The label stays on one line and truncates with an ellipsis when space is limited; hover the component to read the full label.
11
+
10
12
  ## Examples
11
13
 
12
14
  ### Basic Usage
@@ -14,15 +14,37 @@ withDefaults(defineProps<Props>(), {
14
14
  </script>
15
15
 
16
16
  <template>
17
- <div flex items-center gap-4px>
17
+ <div class="avatar-label" :title="label">
18
18
  <TelaAvatar
19
+ class="avatar-label-image"
19
20
  :size="avatarSize"
20
- :src="avatarSrc"
21
+ :image="avatarSrc"
21
22
  :alt="avatarAlt ?? label"
22
23
  rounded-full
23
24
  />
24
- <p body-12-regular text-secondary>
25
+ <p class="avatar-label-text" body-12-regular text-secondary>
25
26
  {{ label }}
26
27
  </p>
27
28
  </div>
28
29
  </template>
30
+
31
+ <style scoped>
32
+ .avatar-label {
33
+ display: flex;
34
+ align-items: center;
35
+ gap: 4px;
36
+ min-width: 0;
37
+ max-width: 100%;
38
+ }
39
+
40
+ .avatar-label-image {
41
+ flex: none;
42
+ }
43
+
44
+ .avatar-label-text {
45
+ min-width: 0;
46
+ overflow: hidden;
47
+ text-overflow: ellipsis;
48
+ white-space: nowrap;
49
+ }
50
+ </style>
@@ -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>