answer-perfect-form-component
Version:
A simple standalone Angular form question component that makes API calls on blur with AI-powered text cleanup - Updated for Angular 18.2.x compatibility
547 lines (539 loc) • 29.7 kB
JavaScript
import * as i0 from '@angular/core';
import { inject, Component, Input, ViewChild } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import * as i1 from '@angular/common';
import { CommonModule } from '@angular/common';
import * as i2 from '@angular/forms';
import { FormsModule } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
/**
* Rating enum matching the backend
*/
var Rating;
(function (Rating) {
Rating["NoRating"] = "No rating";
Rating["Weak"] = "Weak";
Rating["Sufficient"] = "Sufficient";
Rating["Strong"] = "Strong";
})(Rating || (Rating = {}));
/**
* A reusable Angular component that provides a textarea input for answering questions.
* Automatically submits answers to an AI evaluation API when the user stops typing.
* Displays AI feedback in a tooltip on hover over the rating bar.
* Streams audio of the feedback text when hovering over the rating bar (configurable).
*/
class FormQuestionComponent {
constructor() {
this.http = inject(HttpClient);
/** Whether to enable audio feedback on hover (default: true) */
this.enableAudio = true;
this.answerText = '';
this.isSubmitting = false;
this.errorMessage = '';
this.feedbackText = '';
this.rating = null;
this.isTyping = false;
this.isCleaning = false;
this.debounceTimeout = null;
this.cancelRequest$ = new Subject();
this.currentAudioUrl = null;
this.currentAudioRequest$ = new Subject();
}
ngOnInit() {
// Validate required inputs
if (!this.apiKey || !this.endpoint || !this.questionId) {
console.error('FormQuestionComponent: Missing required inputs');
}
console.log('FormQuestionComponent initialized');
console.log('enableAudio:', this.enableAudio);
}
ngAfterViewInit() {
console.log('View initialized, hoverAudio element:', this.hoverAudio);
if (this.hoverAudio && this.hoverAudio.nativeElement) {
console.log('Audio element is available and ready');
}
else {
console.warn('Audio element not found or not ready');
}
}
ngOnDestroy() {
this.clearDebounceTimeout();
this.cancelRequest$.next();
this.cancelRequest$.complete();
this.currentAudioRequest$.next();
this.currentAudioRequest$.complete();
// Clean up any existing audio blob URL
if (this.currentAudioUrl) {
URL.revokeObjectURL(this.currentAudioUrl);
this.currentAudioUrl = null;
}
}
/**
* Play streaming audio when mouse enters the rating bar
*/
onRatingBarHover() {
console.log('Rating bar hover detected');
console.log('enableAudio:', this.enableAudio);
console.log('feedbackText:', this.feedbackText);
console.log('feedbackText.trim():', this.feedbackText?.trim());
console.log('hoverAudio:', this.hoverAudio);
console.log('hoverAudio.nativeElement:', this.hoverAudio?.nativeElement);
// Wait for audio element to be available if it's not ready yet
if (!this.hoverAudio || !this.hoverAudio.nativeElement) {
console.log('Audio element not ready, waiting...');
setTimeout(() => {
this.onRatingBarHover();
}, 100);
return;
}
if (this.enableAudio &&
this.feedbackText &&
this.feedbackText.trim() &&
this.hoverAudio &&
this.hoverAudio.nativeElement) {
console.log('All conditions met, calling playFeedbackAudio');
this.playFeedbackAudio(this.feedbackText);
}
else {
console.log('Conditions not met for audio playback');
if (!this.enableAudio)
console.log('Audio disabled');
if (!this.feedbackText)
console.log('No feedback text');
if (!this.feedbackText?.trim())
console.log('Feedback text is empty');
if (!this.hoverAudio)
console.log('No hover audio element');
if (!this.hoverAudio?.nativeElement)
console.log('No hover audio native element');
}
}
/**
* Stop audio when mouse leaves the rating bar
*/
onRatingBarLeave() {
console.log('Rating bar leave detected');
if (this.enableAudio && this.hoverAudio && this.hoverAudio.nativeElement) {
// Cancel any ongoing audio request
this.currentAudioRequest$.next();
// Pause and reset audio
this.hoverAudio.nativeElement.pause();
this.hoverAudio.nativeElement.currentTime = 0;
// Clean up the current audio blob URL
if (this.currentAudioUrl) {
URL.revokeObjectURL(this.currentAudioUrl);
this.currentAudioUrl = null;
}
}
else {
console.log('Cannot stop audio - conditions not met');
}
}
/**
* Play streaming audio for the given feedback text
*/
playFeedbackAudio(feedbackText) {
console.log('playFeedbackAudio called with:', feedbackText);
console.log('endpoint:', this.endpoint);
console.log('apiKey:', this.apiKey ? 'Present' : 'Missing');
if (!this.hoverAudio || !this.hoverAudio.nativeElement) {
console.log('Audio element not available');
return;
}
const headers = new HttpHeaders({
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
});
const payload = {
textToSpeak: feedbackText,
};
console.log('Making API call to:', `${this.endpoint}/audio/stream`);
console.log('Payload:', payload);
// Create a blob URL from the streaming audio response
this.http
.post(`${this.endpoint}/audio/stream`, payload, {
headers,
responseType: 'blob',
})
.pipe(takeUntil(this.currentAudioRequest$))
.subscribe({
next: (blob) => {
console.log('Audio stream received, blob size:', blob.size);
// Clean up previous audio URL if it exists
if (this.currentAudioUrl) {
URL.revokeObjectURL(this.currentAudioUrl);
}
const audioUrl = URL.createObjectURL(blob);
this.currentAudioUrl = audioUrl;
this.hoverAudio.nativeElement.src = audioUrl;
this.hoverAudio.nativeElement.play().catch((error) => {
console.warn('Could not play feedback audio:', error);
});
},
error: (error) => {
console.warn('Could not stream feedback audio:', error);
},
});
}
onBlur() {
// Just clear typing state when focus is lost, no immediate submission
this.isTyping = false;
}
onInput() {
// Clear any existing timer and cancel ongoing API requests
this.clearDebounceTimeout();
this.cancelRequest$.next();
// Set typing state
this.isTyping = true;
// Clear feedback when user deletes all text
if (!this.answerText.trim()) {
this.feedbackText = '';
this.rating = null;
this.isTyping = false;
return;
}
// Single timer: 2 seconds after user stops typing
this.debounceTimeout = setTimeout(() => {
this.isTyping = false;
if (this.answerText.trim() && !this.isSubmitting) {
// Clear old rating before submitting for new one
this.feedbackText = '';
this.rating = null;
this.submitAnswer();
}
}, 2000);
}
clearDebounceTimeout() {
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
this.debounceTimeout = null;
}
}
submitAnswer() {
// Clear timer when submitting
this.clearDebounceTimeout();
this.isSubmitting = true;
this.errorMessage = '';
const headers = new HttpHeaders({
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
});
const payload = {
questionId: this.questionId,
answerText: this.answerText.trim(),
};
this.http
.post(`${this.endpoint}/submitAnswer`, payload, { headers })
.pipe(takeUntil(this.cancelRequest$))
.subscribe({
next: (response) => {
this.isSubmitting = false;
// Display feedback and rating from AI
if (response.feedbackText) {
this.feedbackText = response.feedbackText;
console.log('Feedback text received:', this.feedbackText);
}
if (response.rating) {
this.rating = response.rating;
console.log('Rating received:', this.rating);
}
},
error: (error) => {
this.isSubmitting = false;
// Clear any existing rating/feedback and show error in rating bar
this.feedbackText = '';
this.rating = null;
this.errorMessage = 'Connection error';
// Clear error message after 3 seconds and clear text to show branding
setTimeout(() => {
this.errorMessage = '';
this.answerText = ''; // Clear text to revert to AnswerPerfect AI branding
}, 3000);
},
});
}
cleanupText() {
if (!this.answerText.trim() || this.isCleaning) {
return;
}
this.isCleaning = true;
const headers = new HttpHeaders({
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
});
const payload = {
text: this.answerText.trim(),
};
this.http
.post(`${this.endpoint}/cleanupText`, payload, { headers })
.pipe(takeUntil(this.cancelRequest$))
.subscribe({
next: (response) => {
this.isCleaning = false;
if (response.success && response.cleanedText) {
this.answerText = response.cleanedText;
// The text change will automatically trigger onInput() via ngModel
}
},
error: (error) => {
this.isCleaning = false;
console.error('Text cleanup failed:', error);
},
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FormQuestionComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: FormQuestionComponent, isStandalone: true, selector: "app-form-question", inputs: { apiKey: "apiKey", endpoint: "endpoint", questionId: "questionId", enableAudio: "enableAudio" }, viewQueries: [{ propertyName: "hoverAudio", first: true, predicate: ["hoverAudio"], descendants: true }], ngImport: i0, template: `
<div class="form-question-container">
<!-- Hidden audio element for streaming audio -->
<audio #hoverAudio preload="none"></audio>
<div class="form-field">
<div class="textarea-container">
<textarea
id="answer-textarea"
[(ngModel)]="answerText"
(blur)="onBlur()"
(input)="onInput()"
rows="3"
placeholder="Type your answer here..."
class="answer-textarea"
>
</textarea>
<!-- Rating bar - always visible (shows branding when empty, rating when has content) -->
<div class="rating-container">
<!-- Edit icon for text cleanup -->
<button
*ngIf="!isCleaning"
class="edit-icon"
[class.disabled]="!answerText.trim()"
(click)="cleanupText()"
[title]="
answerText.trim()
? 'Fix spelling and grammar with AI'
: 'Add text to enable AI editing'
"
>
✏️
</button>
<div
*ngIf="isCleaning"
class="cleaning-spinner"
title="AI is fixing your text..."
>
✨
</div>
<div
class="feedback-bar"
(mouseenter)="onRatingBarHover()"
(mouseleave)="onRatingBarLeave()"
[ngClass]="{
outline:
!rating || rating === 'No rating' || !answerText.trim(),
weak:
rating === 'Weak' &&
answerText.trim() &&
!isTyping &&
!isSubmitting,
sufficient:
rating === 'Sufficient' &&
answerText.trim() &&
!isTyping &&
!isSubmitting,
strong:
rating === 'Strong' &&
answerText.trim() &&
!isTyping &&
!isSubmitting,
noRating:
rating === 'No rating' &&
answerText.trim() &&
!isTyping &&
!isSubmitting,
branding: !answerText.trim(),
typing: (isTyping || isSubmitting) && answerText.trim(),
error: errorMessage && answerText.trim()
}"
>
<span
class="rating-label"
*ngIf="
answerText.trim() &&
rating &&
!isTyping &&
!isSubmitting &&
!errorMessage
"
>{{ rating }}</span
>
<span
class="typing-label"
*ngIf="
answerText.trim() &&
(isTyping || isSubmitting) &&
!errorMessage
"
><span class="dot1">.</span><span class="dot2">.</span
><span class="dot3">.</span></span
>
<span
class="error-label"
*ngIf="answerText.trim() && errorMessage"
>Error</span
>
<span class="branding-label" *ngIf="!answerText.trim()"
>AnswerPerfect AI
<!-- Branding tooltip -->
<div class="branding-tooltip">
AI-powered feedback to help you write better answers
</div>
</span>
<!-- Tooltip feedback text on hover (only show if feedback exists) -->
<div
class="feedback-tooltip"
*ngIf="feedbackText && answerText.trim()"
>
{{ feedbackText }}
</div>
</div>
</div>
</div>
</div>
</div>
`, isInline: true, styles: [".form-question-container{margin-bottom:1rem}.form-field{display:flex;flex-direction:column;margin-bottom:.5rem}.textarea-container{position:relative;display:block}.answer-textarea{width:100%;padding:.75rem;border:1px solid #ccc;border-radius:4px;font-family:inherit;font-size:1rem;resize:vertical;min-height:80px}.answer-textarea:focus{outline:none;border-color:#1976d2;box-shadow:0 0 0 2px #1976d233}.rating-container{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;position:relative}.edit-icon{background:none;border:none;cursor:pointer;font-size:16px;padding:4px;border-radius:4px;transition:background-color .2s ease;display:flex;align-items:center;justify-content:center;min-width:24px;height:24px}.edit-icon:hover{background-color:#0000001a}.edit-icon.disabled{opacity:.3;cursor:not-allowed;pointer-events:none}.edit-icon.disabled:hover{background-color:transparent}.cleaning-spinner{font-size:16px;animation:spin 1s linear infinite;display:flex;align-items:center;justify-content:center;min-width:24px;height:24px}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.feedback-bar{height:20px;width:120px;background:#e0e0e0;border-radius:9px;position:relative;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .2s ease}.feedback-bar:hover{transform:scale(1.05)}.feedback-bar:before{content:\"\";position:absolute;left:0;top:0;height:100%;transition:width .6s ease-in-out;border-radius:9px}.feedback-bar.weak:before{background:linear-gradient(to right,#ff6b6b,#ff8e8e);width:33%}.feedback-bar.sufficient:before{background:linear-gradient(to right,#ffd93d,#ffe066);width:66%}.feedback-bar.strong:before{background:linear-gradient(to right,#6bcf7f,#8ed99e);width:100%}.feedback-bar.outline:before{background:transparent;width:0%}.feedback-bar.noRating:before{background:linear-gradient(to right,#9e9e9e,#bdbdbd);width:0%}.feedback-bar.error:before{background:linear-gradient(to right,#f44336,#ef5350);width:100%}.rating-label{font-weight:600;font-size:11px;text-align:center;color:#333;text-shadow:0 1px 2px rgba(255,255,255,.8);z-index:1;position:relative}.branding-label{font-weight:600;font-size:10px;text-align:center;color:#666;z-index:1;position:relative;letter-spacing:.5px;cursor:pointer}.branding-tooltip{position:absolute;bottom:100%;right:0;background:#333;color:#fff;padding:12px 16px;border-radius:8px;font-size:12px;opacity:0;visibility:hidden;transition:opacity .3s ease,visibility .3s ease;z-index:1000;margin-bottom:8px;width:280px;white-space:normal;line-height:1.4;box-shadow:0 4px 12px #0003;font-weight:500;letter-spacing:normal}.branding-tooltip:after{content:\"\";position:absolute;top:100%;right:20px;border:5px solid transparent;border-top-color:#333}.branding-label:hover .branding-tooltip{opacity:1;visibility:visible}.feedback-tooltip{position:absolute;bottom:100%;right:0;transform:translate(0);background:#333;color:#fff;padding:18px 24px;border-radius:10px;font-size:14px;opacity:0;visibility:hidden;transition:opacity .3s ease,visibility .3s ease;z-index:1000;margin-bottom:12px;width:450px;white-space:normal;line-height:1.6;box-shadow:0 6px 20px #0003}.feedback-tooltip:after{content:\"\";position:absolute;top:100%;right:20px;border:6px solid transparent;border-top-color:#333}.typing-label{font-weight:700;font-size:14px;text-align:center;color:#222;z-index:1;position:relative;display:flex;justify-content:center;gap:2px}.typing-label .dot1,.typing-label .dot2,.typing-label .dot3{animation:typing-pulse 1.2s infinite}.typing-label .dot1{animation-delay:0s}.typing-label .dot2{animation-delay:.15s}.typing-label .dot3{animation-delay:.3s}.feedback-bar.typing:before{background:#e0e0e0;width:0%}@keyframes typing-pulse{0%,70%,to{opacity:.2;transform:scale(.8)}35%{opacity:1;transform:scale(1.4)}}.error-label{font-weight:600;font-size:11px;text-align:center;color:#333;text-shadow:0 1px 2px rgba(255,255,255,.8);z-index:1;position:relative}.feedback-bar:hover .feedback-tooltip{opacity:1;visibility:visible}.error-message{color:#f44336;font-size:.875rem;margin-top:.25rem}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FormQuestionComponent, decorators: [{
type: Component,
args: [{ selector: 'app-form-question', template: `
<div class="form-question-container">
<!-- Hidden audio element for streaming audio -->
<audio #hoverAudio preload="none"></audio>
<div class="form-field">
<div class="textarea-container">
<textarea
id="answer-textarea"
[(ngModel)]="answerText"
(blur)="onBlur()"
(input)="onInput()"
rows="3"
placeholder="Type your answer here..."
class="answer-textarea"
>
</textarea>
<!-- Rating bar - always visible (shows branding when empty, rating when has content) -->
<div class="rating-container">
<!-- Edit icon for text cleanup -->
<button
*ngIf="!isCleaning"
class="edit-icon"
[class.disabled]="!answerText.trim()"
(click)="cleanupText()"
[title]="
answerText.trim()
? 'Fix spelling and grammar with AI'
: 'Add text to enable AI editing'
"
>
✏️
</button>
<div
*ngIf="isCleaning"
class="cleaning-spinner"
title="AI is fixing your text..."
>
✨
</div>
<div
class="feedback-bar"
(mouseenter)="onRatingBarHover()"
(mouseleave)="onRatingBarLeave()"
[ngClass]="{
outline:
!rating || rating === 'No rating' || !answerText.trim(),
weak:
rating === 'Weak' &&
answerText.trim() &&
!isTyping &&
!isSubmitting,
sufficient:
rating === 'Sufficient' &&
answerText.trim() &&
!isTyping &&
!isSubmitting,
strong:
rating === 'Strong' &&
answerText.trim() &&
!isTyping &&
!isSubmitting,
noRating:
rating === 'No rating' &&
answerText.trim() &&
!isTyping &&
!isSubmitting,
branding: !answerText.trim(),
typing: (isTyping || isSubmitting) && answerText.trim(),
error: errorMessage && answerText.trim()
}"
>
<span
class="rating-label"
*ngIf="
answerText.trim() &&
rating &&
!isTyping &&
!isSubmitting &&
!errorMessage
"
>{{ rating }}</span
>
<span
class="typing-label"
*ngIf="
answerText.trim() &&
(isTyping || isSubmitting) &&
!errorMessage
"
><span class="dot1">.</span><span class="dot2">.</span
><span class="dot3">.</span></span
>
<span
class="error-label"
*ngIf="answerText.trim() && errorMessage"
>Error</span
>
<span class="branding-label" *ngIf="!answerText.trim()"
>AnswerPerfect AI
<!-- Branding tooltip -->
<div class="branding-tooltip">
AI-powered feedback to help you write better answers
</div>
</span>
<!-- Tooltip feedback text on hover (only show if feedback exists) -->
<div
class="feedback-tooltip"
*ngIf="feedbackText && answerText.trim()"
>
{{ feedbackText }}
</div>
</div>
</div>
</div>
</div>
</div>
`, standalone: true, imports: [CommonModule, FormsModule], styles: [".form-question-container{margin-bottom:1rem}.form-field{display:flex;flex-direction:column;margin-bottom:.5rem}.textarea-container{position:relative;display:block}.answer-textarea{width:100%;padding:.75rem;border:1px solid #ccc;border-radius:4px;font-family:inherit;font-size:1rem;resize:vertical;min-height:80px}.answer-textarea:focus{outline:none;border-color:#1976d2;box-shadow:0 0 0 2px #1976d233}.rating-container{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;position:relative}.edit-icon{background:none;border:none;cursor:pointer;font-size:16px;padding:4px;border-radius:4px;transition:background-color .2s ease;display:flex;align-items:center;justify-content:center;min-width:24px;height:24px}.edit-icon:hover{background-color:#0000001a}.edit-icon.disabled{opacity:.3;cursor:not-allowed;pointer-events:none}.edit-icon.disabled:hover{background-color:transparent}.cleaning-spinner{font-size:16px;animation:spin 1s linear infinite;display:flex;align-items:center;justify-content:center;min-width:24px;height:24px}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.feedback-bar{height:20px;width:120px;background:#e0e0e0;border-radius:9px;position:relative;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .2s ease}.feedback-bar:hover{transform:scale(1.05)}.feedback-bar:before{content:\"\";position:absolute;left:0;top:0;height:100%;transition:width .6s ease-in-out;border-radius:9px}.feedback-bar.weak:before{background:linear-gradient(to right,#ff6b6b,#ff8e8e);width:33%}.feedback-bar.sufficient:before{background:linear-gradient(to right,#ffd93d,#ffe066);width:66%}.feedback-bar.strong:before{background:linear-gradient(to right,#6bcf7f,#8ed99e);width:100%}.feedback-bar.outline:before{background:transparent;width:0%}.feedback-bar.noRating:before{background:linear-gradient(to right,#9e9e9e,#bdbdbd);width:0%}.feedback-bar.error:before{background:linear-gradient(to right,#f44336,#ef5350);width:100%}.rating-label{font-weight:600;font-size:11px;text-align:center;color:#333;text-shadow:0 1px 2px rgba(255,255,255,.8);z-index:1;position:relative}.branding-label{font-weight:600;font-size:10px;text-align:center;color:#666;z-index:1;position:relative;letter-spacing:.5px;cursor:pointer}.branding-tooltip{position:absolute;bottom:100%;right:0;background:#333;color:#fff;padding:12px 16px;border-radius:8px;font-size:12px;opacity:0;visibility:hidden;transition:opacity .3s ease,visibility .3s ease;z-index:1000;margin-bottom:8px;width:280px;white-space:normal;line-height:1.4;box-shadow:0 4px 12px #0003;font-weight:500;letter-spacing:normal}.branding-tooltip:after{content:\"\";position:absolute;top:100%;right:20px;border:5px solid transparent;border-top-color:#333}.branding-label:hover .branding-tooltip{opacity:1;visibility:visible}.feedback-tooltip{position:absolute;bottom:100%;right:0;transform:translate(0);background:#333;color:#fff;padding:18px 24px;border-radius:10px;font-size:14px;opacity:0;visibility:hidden;transition:opacity .3s ease,visibility .3s ease;z-index:1000;margin-bottom:12px;width:450px;white-space:normal;line-height:1.6;box-shadow:0 6px 20px #0003}.feedback-tooltip:after{content:\"\";position:absolute;top:100%;right:20px;border:6px solid transparent;border-top-color:#333}.typing-label{font-weight:700;font-size:14px;text-align:center;color:#222;z-index:1;position:relative;display:flex;justify-content:center;gap:2px}.typing-label .dot1,.typing-label .dot2,.typing-label .dot3{animation:typing-pulse 1.2s infinite}.typing-label .dot1{animation-delay:0s}.typing-label .dot2{animation-delay:.15s}.typing-label .dot3{animation-delay:.3s}.feedback-bar.typing:before{background:#e0e0e0;width:0%}@keyframes typing-pulse{0%,70%,to{opacity:.2;transform:scale(.8)}35%{opacity:1;transform:scale(1.4)}}.error-label{font-weight:600;font-size:11px;text-align:center;color:#333;text-shadow:0 1px 2px rgba(255,255,255,.8);z-index:1;position:relative}.feedback-bar:hover .feedback-tooltip{opacity:1;visibility:visible}.error-message{color:#f44336;font-size:.875rem;margin-top:.25rem}\n"] }]
}], propDecorators: { apiKey: [{
type: Input
}], endpoint: [{
type: Input
}], questionId: [{
type: Input
}], enableAudio: [{
type: Input
}], hoverAudio: [{
type: ViewChild,
args: ['hoverAudio', { static: false }]
}] } });
/**
* Generated bundle index. Do not edit.
*/
export { FormQuestionComponent };
//# sourceMappingURL=answer-perfect-form-component.mjs.map