@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 +30 -0
- package/components/tela/chat/answer/answer.test.ts +251 -0
- package/components/tela/chat/answer/answer.vue +297 -0
- package/components/tela/chat/chat-body.vue +13 -8
- package/components/tela/chat/chat-widget-history-list.vue +4 -1
- package/components/tela/chat/chat.mdx +172 -0
- package/components/tela/chat/floating/escape-layer.test.ts +26 -0
- package/components/tela/chat/floating/escape-layer.ts +3 -0
- package/components/tela/chat/floating/floating-window.vue +62 -26
- package/components/tela/icon-button/icon-button.vue +1 -1
- package/components/tela/kbd/kbd.vue +4 -4
- package/components/tela/tooltip/tooltip.mdx +2 -0
- package/components/tela/tooltip/tooltip.test.ts +2 -1
- package/components/tela/tooltip/tooltip.vue +11 -12
- package/package.json +1 -1
- package/types/chat-answer.ts +27 -0
- package/types/index.ts +1 -0
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
|
|
12
|
-
// wrapping it (document order puts the
|
|
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
|
|
77
|
-
* wraps it. A surface inside the wrapper
|
|
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
|
-
<
|
|
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
|
+
})
|
|
@@ -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:
|
|
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.
|
|
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?.
|
|
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="
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
18
|
-
:class="cn(variantClasses
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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
|
@@ -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