@meistrari/tela-build 1.65.1 → 1.66.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.
@@ -726,7 +726,9 @@ const status = computed(() => streaming.value ? 'streaming' : 'ready')
726
726
 
727
727
  ### Message Feedback
728
728
 
729
- Thumbs under an assistant message. Persist the `@submit` payload and feed the saved vote back via `rating` to keep it controlled.
729
+ Thumbs under an assistant message. Feed the saved vote back via `rating` to keep it controlled.
730
+
731
+ Pass `submit` when the vote is persisted remotely: the form disables its controls while the promise is in flight and only clears the draft on `{ ok: true }`. On `{ ok: false, message? }` it stays open with the chips and comment intact and swaps the confirm button to `retryLabel`. A handler that stalls past `submitTimeoutMs` (15s by default) is treated as a failure, so the form can never lock the user out. Without `submit` the component falls back to the `@submit` emit and confirms immediately.
730
732
 
731
733
  ```vue
732
734
  <script setup lang="ts">
@@ -738,8 +740,11 @@ const message = ref({
738
740
  rating: null as FeedbackRating | null,
739
741
  })
740
742
 
741
- function saveFeedback(payload: { rating: FeedbackRating, reasons: string[], comment: string }) {
743
+ async function saveFeedback(payload: { rating: FeedbackRating, reasons: string[], comment: string }) {
744
+ await new Promise(resolve => setTimeout(resolve, 600))
742
745
  message.value.rating = payload.rating
746
+
747
+ return { ok: true as const }
743
748
  }
744
749
  </script>
745
750
 
@@ -756,7 +761,7 @@ function saveFeedback(payload: { rating: FeedbackRating, reasons: string[], comm
756
761
  :positive-reasons="['Accurate', 'Well written', 'Helpful']"
757
762
  :negative-reasons="['Incorrect', 'Off topic', 'Too long']"
758
763
  require-reason
759
- @submit="saveFeedback($event)"
764
+ :submit="saveFeedback"
760
765
  />
761
766
  </div>
762
767
  </template>
@@ -1005,7 +1010,7 @@ import { TelaChatIconsAnthropic, TelaChatIconsGemini, TelaChatIconsOpenai } from
1005
1010
  | `TelaChatMessage` | Aligns one message by author (`from="user" \| "assistant"`). |
1006
1011
  | `TelaChatMessageContent` | Message surface: `contained` (bubble) or `flat` (bare text). |
1007
1012
  | `TelaChatMessageAvatar` | Avatar displayed alongside a message. |
1008
- | `TelaChatMessageFeedback` | Thumbs-up/Thumbs-down with reason chips and comment; emits `submit`. |
1013
+ | `TelaChatMessageFeedback` | Thumbs-up/Thumbs-down with reason chips and comment; persists via `submit` or emits `submit`. |
1009
1014
  | `TelaChatPromptInput` | Input shell (border, focus surface, `locked` state). |
1010
1015
  | `TelaChatPromptInputTextarea` | Contenteditable editor with auto-grow, max-height and scroll fog. Host owns the content state. |
1011
1016
  | `TelaChatPromptInputToolbar` | Bottom row of the input: tools on the left, model selector + submit on the right. |
@@ -1093,13 +1098,21 @@ type MessageAvatarProps = {
1093
1098
 
1094
1099
  type FeedbackRating = 'positive' | 'negative'
1095
1100
 
1101
+ type FeedbackPayload = { rating: FeedbackRating, reasons: string[], comment: string }
1102
+ type FeedbackSubmitResult = { ok: true } | { ok: false, message?: string }
1103
+
1096
1104
  type MessageFeedbackProps = {
1097
1105
  rating?: FeedbackRating | null
1098
1106
  positiveReasons?: string[]
1099
1107
  negativeReasons?: string[]
1100
1108
  requireReason?: boolean
1109
+ submit?: (payload: FeedbackPayload) => Promise<FeedbackSubmitResult>
1110
+ submitTimeoutMs?: number // default: 15_000
1111
+ submittingLabel?: string // default: 'Submitting…'
1112
+ retryLabel?: string // default: 'Try again'
1113
+ submitErrorMessage?: string // default: 'Could not submit feedback. Try again.'
1101
1114
  }
1102
- // emits: submit(payload: { rating: FeedbackRating, reasons: string[], comment: string })
1115
+ // emits: submit(payload: FeedbackPayload) — only when the `submit` prop is absent
1103
1116
  ```
1104
1117
 
1105
1118
  ### Prompt Input
@@ -0,0 +1,226 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ canSubmitNegativeFeedback,
5
+ closeMessageFeedback,
6
+ createMessageFeedbackState,
7
+ openMessageFeedback,
8
+ submitMessageFeedback,
9
+ toggleMessageFeedbackReason,
10
+ } from './message-feedback-state'
11
+
12
+ describe('message feedback submission', () => {
13
+ it('keeps the popover and draft while persistence is pending', async () => {
14
+ const state = createMessageFeedbackState()
15
+ openMessageFeedback(state, 'negative')
16
+ toggleMessageFeedbackReason(state, 'Resposta incorreta')
17
+ state.comment = 'A fonte citada não sustenta a conclusão.'
18
+
19
+ let finish: ((result: { ok: true }) => void) | undefined
20
+ const pending = submitMessageFeedback(
21
+ state,
22
+ 'negative',
23
+ async () => await new Promise(resolve => finish = resolve),
24
+ 'Não foi possível enviar.',
25
+ )
26
+
27
+ expect(state).toMatchObject({
28
+ openRating: 'negative',
29
+ selectedReasons: ['Resposta incorreta'],
30
+ comment: 'A fonte citada não sustenta a conclusão.',
31
+ submitting: true,
32
+ localRating: null,
33
+ })
34
+
35
+ closeMessageFeedback(state)
36
+ expect(state.openRating).toBe('negative')
37
+
38
+ finish?.({ ok: true })
39
+ await pending
40
+ })
41
+
42
+ it('preserves the draft and leaves the rating unchanged when persistence fails', async () => {
43
+ const state = createMessageFeedbackState()
44
+ openMessageFeedback(state, 'negative')
45
+ toggleMessageFeedbackReason(state, 'Resposta incorreta')
46
+ state.comment = 'A fonte citada não sustenta a conclusão.'
47
+
48
+ const saved = await submitMessageFeedback(
49
+ state,
50
+ 'negative',
51
+ async () => ({ ok: false, message: 'Sua sessão expirou. Entre novamente e tente enviar o feedback.' }),
52
+ 'Não foi possível enviar.',
53
+ )
54
+
55
+ expect(saved).toBe(false)
56
+ expect(state).toMatchObject({
57
+ openRating: 'negative',
58
+ selectedReasons: ['Resposta incorreta'],
59
+ comment: 'A fonte citada não sustenta a conclusão.',
60
+ submitting: false,
61
+ localRating: null,
62
+ error: 'Sua sessão expirou. Entre novamente e tente enviar o feedback.',
63
+ })
64
+ })
65
+
66
+ it('keeps the draft on an unexpected exception and succeeds on retry', async () => {
67
+ const state = createMessageFeedbackState()
68
+ openMessageFeedback(state, 'negative')
69
+ toggleMessageFeedbackReason(state, 'Outro')
70
+ state.comment = 'Detalhes que não podem ser perdidos.'
71
+
72
+ await submitMessageFeedback(
73
+ state,
74
+ 'negative',
75
+ async () => { throw new Error('network failure') },
76
+ 'Não foi possível enviar o feedback. Tente novamente.',
77
+ )
78
+
79
+ expect(state.error).toBe('Não foi possível enviar o feedback. Tente novamente.')
80
+ expect(state.comment).toBe('Detalhes que não podem ser perdidos.')
81
+
82
+ const saved = await submitMessageFeedback(
83
+ state,
84
+ 'negative',
85
+ async payload => payload.comment === 'Detalhes que não podem ser perdidos.' ? { ok: true } : { ok: false },
86
+ 'Não foi possível enviar o feedback. Tente novamente.',
87
+ )
88
+
89
+ expect(saved).toBe(true)
90
+ expect(state).toMatchObject({
91
+ openRating: null,
92
+ selectedReasons: [],
93
+ comment: '',
94
+ submitting: false,
95
+ localRating: 'negative',
96
+ error: null,
97
+ })
98
+ })
99
+ })
100
+
101
+ describe('message feedback drafts', () => {
102
+ it('drops the previous draft when the other rating is opened', () => {
103
+ const state = createMessageFeedbackState()
104
+ openMessageFeedback(state, 'positive')
105
+ toggleMessageFeedbackReason(state, 'Preciso')
106
+ state.comment = 'Rascunho do voto positivo.'
107
+
108
+ openMessageFeedback(state, 'negative')
109
+
110
+ expect(state).toMatchObject({
111
+ openRating: 'negative',
112
+ selectedReasons: [],
113
+ comment: '',
114
+ })
115
+ })
116
+
117
+ it('keeps the draft when the already open rating is reopened', () => {
118
+ const state = createMessageFeedbackState()
119
+ openMessageFeedback(state, 'negative')
120
+ toggleMessageFeedbackReason(state, 'Resposta incorreta')
121
+
122
+ openMessageFeedback(state, 'negative')
123
+
124
+ expect(state.selectedReasons).toEqual(['Resposta incorreta'])
125
+ })
126
+
127
+ it('toggles a reason off on the second selection', () => {
128
+ const state = createMessageFeedbackState()
129
+ toggleMessageFeedbackReason(state, 'Resposta incorreta')
130
+ toggleMessageFeedbackReason(state, 'Fora do tema')
131
+ toggleMessageFeedbackReason(state, 'Resposta incorreta')
132
+
133
+ expect(state.selectedReasons).toEqual(['Fora do tema'])
134
+ })
135
+
136
+ it('clears the draft and the error when the popover is closed', () => {
137
+ const state = createMessageFeedbackState()
138
+ openMessageFeedback(state, 'negative')
139
+ toggleMessageFeedbackReason(state, 'Resposta incorreta')
140
+ state.error = 'Falha anterior.'
141
+
142
+ closeMessageFeedback(state)
143
+
144
+ expect(state).toMatchObject({
145
+ openRating: null,
146
+ selectedReasons: [],
147
+ comment: '',
148
+ error: null,
149
+ })
150
+ })
151
+ })
152
+
153
+ describe('negative feedback validation', () => {
154
+ it('accepts a bare thumbs-down when no reason is required', () => {
155
+ const state = createMessageFeedbackState()
156
+
157
+ expect(canSubmitNegativeFeedback(state, false, [])).toBe(true)
158
+ })
159
+
160
+ it('requires a chip when chips are configured', () => {
161
+ const state = createMessageFeedbackState()
162
+ const reasons = ['Resposta incorreta']
163
+
164
+ expect(canSubmitNegativeFeedback(state, true, reasons)).toBe(false)
165
+ toggleMessageFeedbackReason(state, 'Resposta incorreta')
166
+ expect(canSubmitNegativeFeedback(state, true, reasons)).toBe(true)
167
+ })
168
+
169
+ it('falls back to a non-blank comment when no chip is configured', () => {
170
+ const state = createMessageFeedbackState()
171
+
172
+ state.comment = ' '
173
+ expect(canSubmitNegativeFeedback(state, true, [])).toBe(false)
174
+
175
+ state.comment = 'A resposta ignorou a pergunta.'
176
+ expect(canSubmitNegativeFeedback(state, true, [])).toBe(true)
177
+ })
178
+ })
179
+
180
+ describe('message feedback timeout', () => {
181
+ it('gives up on a handler that never settles and offers a retry', async () => {
182
+ const state = createMessageFeedbackState()
183
+ openMessageFeedback(state, 'negative')
184
+ toggleMessageFeedbackReason(state, 'Resposta incorreta')
185
+
186
+ const saved = await submitMessageFeedback(
187
+ state,
188
+ 'negative',
189
+ async () => await new Promise<never>(() => {}),
190
+ 'Não foi possível enviar o feedback. Tente novamente.',
191
+ 10,
192
+ )
193
+
194
+ expect(saved).toBe(false)
195
+ expect(state).toMatchObject({
196
+ openRating: 'negative',
197
+ selectedReasons: ['Resposta incorreta'],
198
+ submitting: false,
199
+ localRating: null,
200
+ error: 'Não foi possível enviar o feedback. Tente novamente.',
201
+ })
202
+ })
203
+
204
+ it('ignores a second submission while the first is in flight', async () => {
205
+ const state = createMessageFeedbackState()
206
+ openMessageFeedback(state, 'positive')
207
+
208
+ let calls = 0
209
+ let finish: ((result: { ok: true }) => void) | undefined
210
+ const pending = submitMessageFeedback(
211
+ state,
212
+ 'positive',
213
+ async () => {
214
+ calls += 1
215
+ return await new Promise<{ ok: true }>(resolve => finish = resolve)
216
+ },
217
+ 'Não foi possível enviar.',
218
+ )
219
+
220
+ expect(await submitMessageFeedback(state, 'positive', async () => ({ ok: true }), 'Não foi possível enviar.')).toBe(false)
221
+
222
+ finish?.({ ok: true })
223
+ expect(await pending).toBe(true)
224
+ expect(calls).toBe(1)
225
+ })
226
+ })
@@ -0,0 +1,123 @@
1
+ export type FeedbackRating = 'positive' | 'negative'
2
+
3
+ export type FeedbackPayload = {
4
+ rating: FeedbackRating
5
+ reasons: string[]
6
+ comment: string
7
+ }
8
+
9
+ export type FeedbackSubmitResult
10
+ = | { ok: true }
11
+ | { ok: false, message?: string }
12
+
13
+ export type FeedbackSubmitHandler = (payload: FeedbackPayload) => Promise<FeedbackSubmitResult>
14
+
15
+ export type MessageFeedbackState = {
16
+ localRating: FeedbackRating | null
17
+ openRating: FeedbackRating | null
18
+ selectedReasons: string[]
19
+ comment: string
20
+ submitting: boolean
21
+ error: string | null
22
+ }
23
+
24
+ export function createMessageFeedbackState(): MessageFeedbackState {
25
+ return {
26
+ localRating: null,
27
+ openRating: null,
28
+ selectedReasons: [],
29
+ comment: '',
30
+ submitting: false,
31
+ error: null,
32
+ }
33
+ }
34
+
35
+ function resetDraft(state: MessageFeedbackState): void {
36
+ state.selectedReasons = []
37
+ state.comment = ''
38
+ state.error = null
39
+ }
40
+
41
+ export function openMessageFeedback(state: MessageFeedbackState, rating: FeedbackRating): void {
42
+ if (state.submitting || state.openRating === rating)
43
+ return
44
+
45
+ resetDraft(state)
46
+ state.openRating = rating
47
+ }
48
+
49
+ export function closeMessageFeedback(state: MessageFeedbackState): void {
50
+ if (state.submitting)
51
+ return
52
+
53
+ state.openRating = null
54
+ resetDraft(state)
55
+ }
56
+
57
+ export function toggleMessageFeedbackReason(state: MessageFeedbackState, reason: string): void {
58
+ state.selectedReasons = state.selectedReasons.includes(reason)
59
+ ? state.selectedReasons.filter(item => item !== reason)
60
+ : [...state.selectedReasons, reason]
61
+ }
62
+
63
+ export function canSubmitNegativeFeedback(
64
+ state: MessageFeedbackState,
65
+ requireReason: boolean,
66
+ negativeReasons: readonly string[],
67
+ ): boolean {
68
+ if (!requireReason)
69
+ return true
70
+ if (negativeReasons.length > 0)
71
+ return state.selectedReasons.length > 0
72
+ return state.comment.trim().length > 0
73
+ }
74
+
75
+ export const DEFAULT_SUBMIT_TIMEOUT_MS = 15_000
76
+
77
+ export async function submitMessageFeedback(
78
+ state: MessageFeedbackState,
79
+ rating: FeedbackRating,
80
+ submit: FeedbackSubmitHandler,
81
+ fallbackErrorMessage: string,
82
+ timeoutMs: number = DEFAULT_SUBMIT_TIMEOUT_MS,
83
+ ): Promise<boolean> {
84
+ if (state.submitting)
85
+ return false
86
+
87
+ const payload: FeedbackPayload = {
88
+ rating,
89
+ reasons: [...state.selectedReasons],
90
+ comment: state.comment.trim(),
91
+ }
92
+
93
+ state.submitting = true
94
+ state.error = null
95
+
96
+ // Without this race a handler that never settles leaves the popover pinned
97
+ // open with every control disabled and no way for the user to escape.
98
+ let timer: ReturnType<typeof setTimeout> | undefined
99
+ const timeout = new Promise<FeedbackSubmitResult>((resolve) => {
100
+ timer = setTimeout(() => resolve({ ok: false }), timeoutMs)
101
+ })
102
+
103
+ try {
104
+ const result = await Promise.race([submit(payload), timeout])
105
+ if (!result.ok) {
106
+ state.error = result.message || fallbackErrorMessage
107
+ return false
108
+ }
109
+
110
+ state.localRating = rating
111
+ state.openRating = null
112
+ resetDraft(state)
113
+ return true
114
+ }
115
+ catch {
116
+ state.error = fallbackErrorMessage
117
+ return false
118
+ }
119
+ finally {
120
+ clearTimeout(timer)
121
+ state.submitting = false
122
+ }
123
+ }
@@ -1,5 +1,13 @@
1
1
  <script setup lang="ts">
2
- type FeedbackRating = 'positive' | 'negative'
2
+ import type { FeedbackPayload, FeedbackRating, FeedbackSubmitHandler } from './message-feedback-state'
3
+ import {
4
+ canSubmitNegativeFeedback,
5
+ closeMessageFeedback,
6
+ createMessageFeedbackState,
7
+ openMessageFeedback,
8
+ submitMessageFeedback,
9
+ toggleMessageFeedbackReason,
10
+ } from './message-feedback-state'
3
11
 
4
12
  const props = withDefaults(defineProps<{
5
13
  /**
@@ -14,6 +22,10 @@ const props = withDefaults(defineProps<{
14
22
  negativeReasons?: string[]
15
23
  /** Blocks a bare thumbs-down: requires a chip (or a comment when no chips exist). */
16
24
  requireReason?: boolean
25
+ /** Persists a vote: `{ ok: true }` confirms and closes the form, while `{ ok: false, message? }` preserves the draft for retry. */
26
+ submit?: FeedbackSubmitHandler
27
+ /** How long to wait on `submit` before giving up and offering a retry. Defaults to 15s. */
28
+ submitTimeoutMs?: number
17
29
  positiveTitle?: string
18
30
  positiveDescription?: string
19
31
  positiveCommentPlaceholder?: string
@@ -25,6 +37,9 @@ const props = withDefaults(defineProps<{
25
37
  helpfulLabel?: string
26
38
  notHelpfulLabel?: string
27
39
  commentLabel?: string
40
+ submittingLabel?: string
41
+ retryLabel?: string
42
+ submitErrorMessage?: string
28
43
  }>(), {
29
44
  positiveTitle: 'What helped?',
30
45
  positiveDescription: 'Marking what worked refines future responses.',
@@ -37,239 +52,255 @@ const props = withDefaults(defineProps<{
37
52
  helpfulLabel: 'Helpful response',
38
53
  notHelpfulLabel: 'Not helpful',
39
54
  commentLabel: 'Comment',
55
+ submittingLabel: 'Submitting…',
56
+ retryLabel: 'Try again',
57
+ submitErrorMessage: 'Could not submit feedback. Try again.',
40
58
  })
41
59
 
42
60
  const emit = defineEmits<{
43
- (e: 'submit', payload: { rating: FeedbackRating, reasons: string[], comment: string }): void
61
+ (e: 'submit', payload: FeedbackPayload): void
44
62
  }>()
45
63
 
46
64
  const positiveReasons = computed(() => props.positiveReasons ?? [])
47
65
  const negativeReasons = computed(() => props.negativeReasons ?? [])
48
66
 
49
- const localRating = ref<FeedbackRating | null>(null)
50
- const currentRating = computed(() => props.rating !== undefined ? props.rating : localRating.value)
67
+ const state = reactive(createMessageFeedbackState())
68
+ const currentRating = computed(() => props.rating !== undefined ? props.rating : state.localRating)
51
69
 
52
- const openRating = ref<FeedbackRating | null>(null)
53
70
  const positiveOpen = computed({
54
- get: () => openRating.value === 'positive',
71
+ get: () => state.openRating === 'positive',
55
72
  set: (open: boolean) => {
56
- openRating.value = open ? 'positive' : null
73
+ if (open)
74
+ openMessageFeedback(state, 'positive')
75
+ else
76
+ closeMessageFeedback(state)
57
77
  },
58
78
  })
59
79
  const negativeOpen = computed({
60
- get: () => openRating.value === 'negative',
80
+ get: () => state.openRating === 'negative',
61
81
  set: (open: boolean) => {
62
- openRating.value = open ? 'negative' : null
82
+ if (open)
83
+ openMessageFeedback(state, 'negative')
84
+ else
85
+ closeMessageFeedback(state)
63
86
  },
64
87
  })
65
88
 
66
- const selectedReasons = ref<string[]>([])
67
- const comment = ref('')
68
-
69
- // Cleared on close as well as open, so a cancelled popover's reasons/comment
70
- // never leak into a later direct submit (e.g. the bare thumbs-up button).
71
- watch(openRating, () => {
72
- selectedReasons.value = []
73
- comment.value = ''
74
- })
75
-
76
89
  // A required-reason negative vote is never bare: a chip when chips are
77
90
  // configured, otherwise a non-empty comment carries the justification.
78
- const canSubmitNegative = computed(() => {
79
- if (!props.requireReason)
80
- return true
81
- if (negativeReasons.value.length > 0)
82
- return selectedReasons.value.length > 0
83
- return comment.value.trim().length > 0
84
- })
91
+ const canSubmitNegative = computed(() => canSubmitNegativeFeedback(
92
+ state,
93
+ props.requireReason ?? false,
94
+ negativeReasons.value,
95
+ ))
85
96
 
86
- function submitRating(next: FeedbackRating) {
87
- emit('submit', {
88
- rating: next,
89
- reasons: [...selectedReasons.value],
90
- comment: comment.value.trim(),
97
+ async function submitRating(next: FeedbackRating) {
98
+ const submit: FeedbackSubmitHandler = props.submit ?? (async (payload: FeedbackPayload) => {
99
+ emit('submit', payload)
100
+ return { ok: true }
91
101
  })
92
- localRating.value = next
93
- openRating.value = null
102
+ await submitMessageFeedback(state, next, submit, props.submitErrorMessage, props.submitTimeoutMs)
94
103
  }
95
104
 
96
105
  function toggleReason(reason: string) {
97
- selectedReasons.value = selectedReasons.value.includes(reason)
98
- ? selectedReasons.value.filter(item => item !== reason)
99
- : [...selectedReasons.value, reason]
106
+ toggleMessageFeedbackReason(state, reason)
100
107
  }
101
108
  </script>
102
109
 
103
110
  <template>
104
- <div flex items-center gap-2px ml--5px>
105
- <button
106
- v-if="positiveReasons.length === 0"
107
- type="button"
108
- h-28px w-28px
109
- flex items-center justify-center
110
- rounded-8px
111
- duration-80
112
- cursor-pointer
113
- :class="currentRating === 'positive' ? 'bg-green-100 text-green-700' : 'text-icon-secondary hover:bg-subtle hover:text-icon'"
114
- :aria-label="helpfulLabel"
115
- @click="submitRating('positive')"
116
- >
117
- <TelaIcon
118
- :name="currentRating === 'positive' ? 'i-ph-thumbs-up-fill' : 'i-ph-thumbs-up'"
119
- size="14px"
120
- />
121
- </button>
122
- <TelaPopover v-else v-model:open="positiveOpen">
123
- <TelaPopoverTrigger as-child>
124
- <button
125
- type="button"
126
- h-28px w-28px
127
- flex items-center justify-center
128
- rounded-8px
129
- duration-80
130
- cursor-pointer
131
- :class="currentRating === 'positive' ? 'bg-green-100 text-green-700' : 'text-icon-tertiary hover:bg-subtle hover:text-icon data-[state=open]:bg-subtle data-[state=open]:text-icon'"
132
- :aria-label="helpfulLabel"
133
- >
134
- <TelaIcon
135
- :name="currentRating === 'positive' ? 'i-ph-thumbs-up-fill' : 'i-ph-thumbs-up'"
136
- size="18px"
137
- />
138
- </button>
139
- </TelaPopoverTrigger>
140
- <TelaPopoverContent
141
- align="start"
142
- :side-offset="4"
143
- class="!w-auto !rounded-12px !border-neutral-200 !bg-white !p-0 !text-neutral-900 !shadow-lg"
111
+ <div flex="~ col" items-start gap-4px ml--5px>
112
+ <div flex items-center gap-2px>
113
+ <button
114
+ v-if="positiveReasons.length === 0"
115
+ type="button"
116
+ h-28px w-28px
117
+ flex items-center justify-center
118
+ rounded-8px
119
+ duration-80
120
+ cursor-pointer
121
+ :disabled="state.submitting"
122
+ :class="currentRating === 'positive' ? 'bg-green-100 text-green-700' : 'text-icon-secondary hover:bg-subtle hover:text-icon'"
123
+ :aria-label="helpfulLabel"
124
+ @click="submitRating('positive')"
144
125
  >
145
- <div w-288px p-12px flex="~ col" gap-10px>
146
- <div flex="~ col" gap-4px>
147
- <h5 heading-h5-semibold text-primary>
148
- {{ positiveTitle }}
149
- </h5>
150
- <span body-12-regular text-secondary>{{ positiveDescription }}</span>
151
- </div>
152
- <div flex flex-wrap gap-6px>
153
- <button
154
- v-for="reason in positiveReasons"
155
- :key="reason"
156
- type="button"
157
- h-26px px-10px
158
- rounded-full
159
- b=".5px solid"
160
- text-12px leading-16px
161
- transition-colors
162
- cursor-pointer
163
- :class="selectedReasons.includes(reason)
164
- ? 'bg-green-100 text-green-700 border-transparent font-medium'
165
- : 'bg-white text-neutral-600 border-neutral-300 hover:bg-neutral-50'"
166
- @click="toggleReason(reason)"
167
- >
168
- {{ reason }}
169
- </button>
170
- </div>
171
- <TelaTextarea
172
- v-model="comment"
173
- rows="2"
174
- :placeholder="positiveCommentPlaceholder"
175
- :aria-label="commentLabel"
176
- class="min-h-56px!"
177
- @keydown.escape.prevent.stop="positiveOpen = false"
178
- />
179
- <div flex justify-end gap-6px>
180
- <TelaButton
181
- type="button"
182
- variant="secondary"
183
- @click="positiveOpen = false"
184
- >
185
- {{ cancelLabel }}
186
- </TelaButton>
187
- <TelaButton
188
- type="button"
189
- @click="submitRating('positive')"
190
- >
191
- {{ submitLabel }}
192
- </TelaButton>
126
+ <TelaIcon
127
+ :name="currentRating === 'positive' ? 'i-ph-thumbs-up-fill' : 'i-ph-thumbs-up'"
128
+ size="14px"
129
+ />
130
+ </button>
131
+ <TelaPopover v-else v-model:open="positiveOpen">
132
+ <TelaPopoverTrigger as-child>
133
+ <button
134
+ type="button"
135
+ h-28px w-28px
136
+ flex items-center justify-center
137
+ rounded-8px
138
+ duration-80
139
+ cursor-pointer
140
+ :disabled="state.submitting"
141
+ :class="currentRating === 'positive' ? 'bg-green-100 text-green-700' : 'text-icon-tertiary hover:bg-subtle hover:text-icon data-[state=open]:bg-subtle data-[state=open]:text-icon'"
142
+ :aria-label="helpfulLabel"
143
+ >
144
+ <TelaIcon
145
+ :name="currentRating === 'positive' ? 'i-ph-thumbs-up-fill' : 'i-ph-thumbs-up'"
146
+ size="18px"
147
+ />
148
+ </button>
149
+ </TelaPopoverTrigger>
150
+ <TelaPopoverContent
151
+ align="start"
152
+ :side-offset="4"
153
+ class="!w-auto !rounded-12px !border-neutral-200 !bg-white !p-0 !text-neutral-900 !shadow-lg"
154
+ >
155
+ <div w-288px p-12px flex="~ col" gap-10px>
156
+ <div flex="~ col" gap-4px>
157
+ <h5 heading-h5-semibold text-primary>
158
+ {{ positiveTitle }}
159
+ </h5>
160
+ <span body-12-regular text-secondary>{{ positiveDescription }}</span>
161
+ </div>
162
+ <div flex flex-wrap gap-6px>
163
+ <button
164
+ v-for="reason in positiveReasons"
165
+ :key="reason"
166
+ type="button"
167
+ h-26px px-10px
168
+ rounded-full
169
+ b=".5px solid"
170
+ text-12px leading-16px
171
+ transition-colors
172
+ cursor-pointer
173
+ :disabled="state.submitting"
174
+ :class="state.selectedReasons.includes(reason)
175
+ ? 'bg-green-100 text-green-700 border-transparent font-medium'
176
+ : 'bg-white text-neutral-600 border-neutral-300 hover:bg-neutral-50'"
177
+ @click="toggleReason(reason)"
178
+ >
179
+ {{ reason }}
180
+ </button>
181
+ </div>
182
+ <TelaTextarea
183
+ v-model="state.comment"
184
+ :disabled="state.submitting"
185
+ rows="2"
186
+ :placeholder="positiveCommentPlaceholder"
187
+ :aria-label="commentLabel"
188
+ class="min-h-56px!"
189
+ @keydown.escape.prevent.stop="positiveOpen = false"
190
+ />
191
+ <span v-if="state.error" role="alert" body-12-medium text-red-700>
192
+ {{ state.error }}
193
+ </span>
194
+ <div flex justify-end gap-6px>
195
+ <TelaButton
196
+ type="button"
197
+ variant="secondary"
198
+ :disabled="state.submitting"
199
+ @click="positiveOpen = false"
200
+ >
201
+ {{ cancelLabel }}
202
+ </TelaButton>
203
+ <TelaButton
204
+ type="button"
205
+ :loading="state.submitting"
206
+ :aria-label="state.submitting ? submittingLabel : undefined"
207
+ @click="submitRating('positive')"
208
+ >
209
+ {{ state.error ? retryLabel : submitLabel }}
210
+ </TelaButton>
211
+ </div>
193
212
  </div>
194
- </div>
195
- </TelaPopoverContent>
196
- </TelaPopover>
213
+ </TelaPopoverContent>
214
+ </TelaPopover>
197
215
 
198
- <TelaPopover v-model:open="negativeOpen">
199
- <TelaPopoverTrigger as-child>
200
- <button
201
- type="button"
202
- h-28px w-28px
203
- flex items-center justify-center
204
- rounded-8px
205
- transition-colors
206
- cursor-pointer
207
- :class="currentRating === 'negative' ? 'bg-red-100 text-red-700' : 'text-icon-tertiary hover:bg-subtle hover:text-icon data-[state=open]:bg-subtle data-[state=open]:text-icon'"
208
- :aria-label="notHelpfulLabel"
216
+ <TelaPopover v-model:open="negativeOpen">
217
+ <TelaPopoverTrigger as-child>
218
+ <button
219
+ type="button"
220
+ h-28px w-28px
221
+ flex items-center justify-center
222
+ rounded-8px
223
+ transition-colors
224
+ cursor-pointer
225
+ :disabled="state.submitting"
226
+ :class="currentRating === 'negative' ? 'bg-red-100 text-red-700' : 'text-icon-tertiary hover:bg-subtle hover:text-icon data-[state=open]:bg-subtle data-[state=open]:text-icon'"
227
+ :aria-label="notHelpfulLabel"
228
+ >
229
+ <TelaIcon
230
+ :name="currentRating === 'negative' ? 'i-ph-thumbs-down-fill' : 'i-ph-thumbs-down'"
231
+ size="18px"
232
+ />
233
+ </button>
234
+ </TelaPopoverTrigger>
235
+ <TelaPopoverContent
236
+ align="start"
237
+ :side-offset="4"
238
+ class="!w-auto !rounded-12px !border-neutral-200 !bg-white !p-0 !text-neutral-900 !shadow-lg"
209
239
  >
210
- <TelaIcon
211
- :name="currentRating === 'negative' ? 'i-ph-thumbs-down-fill' : 'i-ph-thumbs-down'"
212
- size="18px"
213
- />
214
- </button>
215
- </TelaPopoverTrigger>
216
- <TelaPopoverContent
217
- align="start"
218
- :side-offset="4"
219
- class="!w-auto !rounded-12px !border-neutral-200 !bg-white !p-0 !text-neutral-900 !shadow-lg"
220
- >
221
- <div w-288px p-12px flex="~ col" gap-10px>
222
- <div flex="~ col" gap-4px>
223
- <h5 heading-h5-semibold text-primary>
224
- {{ negativeTitle }}
225
- </h5>
226
- <span body-12-regular text-secondary>{{ negativeDescription }}</span>
227
- </div>
228
- <div v-if="negativeReasons.length > 0" flex flex-wrap gap-6px>
229
- <button
230
- v-for="reason in negativeReasons"
231
- :key="reason"
232
- type="button"
233
- h-26px px-10px
234
- rounded-full
235
- b=".5px solid"
236
- text-12px leading-16px
237
- transition-colors
238
- cursor-pointer
239
- :class="selectedReasons.includes(reason)
240
- ? 'bg-blue-100 text-blue-900 border-transparent font-medium'
241
- : 'bg-white text-neutral-600 border-neutral-300 hover:bg-neutral-50'"
242
- @click="toggleReason(reason)"
243
- >
244
- {{ reason }}
245
- </button>
246
- </div>
247
- <TelaTextarea
248
- v-model="comment"
249
- rows="2"
250
- :placeholder="negativeCommentPlaceholder"
251
- :aria-label="commentLabel"
252
- class="min-h-56px!"
253
- @keydown.escape.prevent.stop="negativeOpen = false"
254
- />
255
- <div flex justify-end gap-6px>
256
- <TelaButton
257
- type="button"
258
- variant="secondary"
259
- @click="negativeOpen = false"
260
- >
261
- {{ cancelLabel }}
262
- </TelaButton>
263
- <TelaButton
264
- type="button"
265
- :disabled="!canSubmitNegative"
266
- @click="submitRating('negative')"
267
- >
268
- {{ submitLabel }}
269
- </TelaButton>
240
+ <div w-288px p-12px flex="~ col" gap-10px>
241
+ <div flex="~ col" gap-4px>
242
+ <h5 heading-h5-semibold text-primary>
243
+ {{ negativeTitle }}
244
+ </h5>
245
+ <span body-12-regular text-secondary>{{ negativeDescription }}</span>
246
+ </div>
247
+ <div v-if="negativeReasons.length > 0" flex flex-wrap gap-6px>
248
+ <button
249
+ v-for="reason in negativeReasons"
250
+ :key="reason"
251
+ type="button"
252
+ h-26px px-10px
253
+ rounded-full
254
+ b=".5px solid"
255
+ text-12px leading-16px
256
+ transition-colors
257
+ cursor-pointer
258
+ :disabled="state.submitting"
259
+ :class="state.selectedReasons.includes(reason)
260
+ ? 'bg-blue-100 text-blue-900 border-transparent font-medium'
261
+ : 'bg-white text-neutral-600 border-neutral-300 hover:bg-neutral-50'"
262
+ @click="toggleReason(reason)"
263
+ >
264
+ {{ reason }}
265
+ </button>
266
+ </div>
267
+ <TelaTextarea
268
+ v-model="state.comment"
269
+ :disabled="state.submitting"
270
+ rows="2"
271
+ :placeholder="negativeCommentPlaceholder"
272
+ :aria-label="commentLabel"
273
+ class="min-h-56px!"
274
+ @keydown.escape.prevent.stop="negativeOpen = false"
275
+ />
276
+ <span v-if="state.error" role="alert" body-12-medium text-red-700>
277
+ {{ state.error }}
278
+ </span>
279
+ <div flex justify-end gap-6px>
280
+ <TelaButton
281
+ type="button"
282
+ variant="secondary"
283
+ :disabled="state.submitting"
284
+ @click="negativeOpen = false"
285
+ >
286
+ {{ cancelLabel }}
287
+ </TelaButton>
288
+ <TelaButton
289
+ type="button"
290
+ :loading="state.submitting"
291
+ :disabled="!canSubmitNegative || state.submitting"
292
+ :aria-label="state.submitting ? submittingLabel : undefined"
293
+ @click="submitRating('negative')"
294
+ >
295
+ {{ state.error ? retryLabel : submitLabel }}
296
+ </TelaButton>
297
+ </div>
270
298
  </div>
271
- </div>
272
- </TelaPopoverContent>
273
- </TelaPopover>
299
+ </TelaPopoverContent>
300
+ </TelaPopover>
301
+ </div>
302
+ <span v-if="state.error && state.openRating === null" role="alert" max-w-288px body-12-medium text-red-700>
303
+ {{ state.error }}
304
+ </span>
274
305
  </div>
275
306
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-build",
3
- "version": "1.65.1",
3
+ "version": "1.66.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "app.config.ts",