@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.
- package/CHANGELOG.md +34 -0
- package/components/tela/avatar/label/avatar-label.mdx +2 -0
- package/components/tela/avatar/label/avatar-label.vue +25 -3
- 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/copy-button.vue +4 -0
- package/components/tela/dropdown-menu/DropdownMenuItem.vue +2 -2
- package/components/tela/filter/filter-content.test.ts +60 -0
- package/components/tela/filter/filter-content.vue +3 -0
- package/components/tela/filter/filter-trigger.vue +2 -1
- package/components/tela/filter/filter.mdx +13 -1
- package/components/tela/filter/filter.vue +2 -1
- package/components/tela/filter/types.ts +1 -0
- package/components/tela/icon-button/icon-button.vue +1 -1
- package/components/tela/kbd/kbd.vue +4 -4
- package/components/tela/live-label.vue +36 -20
- package/components/tela/pane.vue +11 -3
- package/components/tela/timeline-waterfall/timeline-waterfall.mdx +85 -8
- package/components/tela/timeline-waterfall/timeline-waterfall.test.ts +917 -0
- package/components/tela/timeline-waterfall/timeline-waterfall.vue +905 -199
- package/components/tela/tooltip/tooltip-body.vue +19 -7
- package/components/tela/tooltip/tooltip.mdx +32 -31
- package/components/tela/tooltip/tooltip.test.ts +47 -0
- package/components/tela/tooltip/tooltip.vue +13 -12
- package/lib/extractors/volar-extract.ts +6 -6
- package/package.json +1 -1
- package/types/chat-answer.ts +27 -0
- 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
|
|
17
|
+
<div class="avatar-label" :title="label">
|
|
18
18
|
<TelaAvatar
|
|
19
|
+
class="avatar-label-image"
|
|
19
20
|
:size="avatarSize"
|
|
20
|
-
:
|
|
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>
|