UNPKG

@haloduck/ui

Version:
614 lines (609 loc) 291 kB
import * as i0 from '@angular/core'; import { Injectable, Input, Component, inject, ChangeDetectorRef, forwardRef, ViewChild, signal, EventEmitter, Output, ViewContainerRef, NgZone, isDevMode, ElementRef, Directive, ChangeDetectionStrategy, HostBinding } from '@angular/core'; import { signIn, confirmSignIn, resetPassword, confirmResetPassword } from 'aws-amplify/auth'; import * as i1$1 from '@angular/forms'; import { NG_VALUE_ACCESSOR, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BehaviorSubject, zip, Subject, takeUntil, tap, combineLatest, switchMap, of, Observable, distinctUntilChanged, shareReplay, map, take } from 'rxjs'; import { ulid } from 'ulid'; import * as i1 from '@angular/common'; import { CommonModule, DecimalPipe, DatePipe } from '@angular/common'; import * as i2$1 from '@jsverse/transloco'; import { provideTranslocoScope, TranslocoModule, TranslocoService } from '@jsverse/transloco'; import * as i2 from '@angular/common/http'; import * as i1$2 from '@angular/cdk/overlay'; import { Overlay } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CoreService } from '@haloduck/core'; import * as i1$4 from '@angular/router'; import { Router, NavigationEnd, RouterLink } from '@angular/router'; import * as i1$3 from '@angular/platform-browser'; import { Title } from '@angular/platform-browser'; import * as THREE from 'three'; import { STLLoader, OrbitControls } from 'three-stdlib'; import { download } from '@haloduck/util'; import { filter } from 'rxjs/operators'; class NotificationService { listNotification$ = new BehaviorSubject([]); listNotification = []; showNotification(title, body, timeout, payload = null) { if (window.FlutterApp) { window.FlutterApp.postMessage(JSON.stringify({ action: 'showNotification', title: title, body: body, payload: payload, timeout: timeout, })); } else if ('Notification' in window && Notification.permission === 'granted') { // 웹브라우저에서 실행될 때는 기본 브라우저 알림 사용 new Notification(title, { body: body }); } else { // 기본 내부 알림 시스템 사용 this._showNotification('info', body, timeout); } } _showNotification(type, message, timeout) { const id = ulid(); const notification = { id, type, message, timeout }; if (timeout && timeout > 0) { const timeoutRef = window.setTimeout(() => { this.removeNotificationById(id); }, timeout); notification.timeoutRef = timeoutRef; } this.listNotification.push(notification); this.listNotification$.next(this.listNotification); return id; } getListNotification() { return this.listNotification$.asObservable(); } removeNotificationById(id) { const notification = this.listNotification.find((n) => n.id === id); if (notification?.timeoutRef) { clearTimeout(notification.timeoutRef); } this.listNotification = this.listNotification.filter((n) => n.id !== id); this.listNotification$.next(this.listNotification); } constructor() { } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: NotificationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: NotificationService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: NotificationService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [] }); class ButtonComponent { disabled = false; variant = 'primary'; static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: ButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.1.4", type: ButtonComponent, isStandalone: true, selector: "haloduck-button", inputs: { disabled: "disabled", variant: "variant" }, ngImport: i0, template: "<button type=\"button\"\n [disabled]=\"disabled\"\n [ngClass]=\"{\n 'bg-light-primary text-light-on-primary dark:bg-dark-primary dark:text-dark-on-primary': variant === 'primary' && !disabled,\n 'bg-light-primary-light text-light-on-primary-light dark:bg-dark-primary-light dark:text-dark-on-primary-light': variant === 'secondary' && !disabled,\n 'bg-light-danger text-light-on-danger dark:bg-dark-danger dark:text-dark-on-danger': variant === 'danger' && !disabled,\n 'bg-light-background text-light-on-background dark:bg-dark-background dark:text-dark-on-background border border-light-on-background dark:border-dark-on-background' : variant === 'none' && !disabled,\n 'bg-light-inactive text-light-on-inactive dark:bg-dark-inactive dark:text-dark-on-inactive active:animate-bounce hover:cursor-not-allowed': disabled,\n 'active:scale-95 transition-transform': !disabled,\n 'hover:cursor-pointer': !disabled\n }\"\n class=\"px-4 py-1.5 rounded-lg text-sm/6 w-full text-nowrap\">\n <ng-content></ng-content>\n</button>\n", styles: [""], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: ButtonComponent, decorators: [{ type: Component, args: [{ selector: 'haloduck-button', imports: [CommonModule], template: "<button type=\"button\"\n [disabled]=\"disabled\"\n [ngClass]=\"{\n 'bg-light-primary text-light-on-primary dark:bg-dark-primary dark:text-dark-on-primary': variant === 'primary' && !disabled,\n 'bg-light-primary-light text-light-on-primary-light dark:bg-dark-primary-light dark:text-dark-on-primary-light': variant === 'secondary' && !disabled,\n 'bg-light-danger text-light-on-danger dark:bg-dark-danger dark:text-dark-on-danger': variant === 'danger' && !disabled,\n 'bg-light-background text-light-on-background dark:bg-dark-background dark:text-dark-on-background border border-light-on-background dark:border-dark-on-background' : variant === 'none' && !disabled,\n 'bg-light-inactive text-light-on-inactive dark:bg-dark-inactive dark:text-dark-on-inactive active:animate-bounce hover:cursor-not-allowed': disabled,\n 'active:scale-95 transition-transform': !disabled,\n 'hover:cursor-pointer': !disabled\n }\"\n class=\"px-4 py-1.5 rounded-lg text-sm/6 w-full text-nowrap\">\n <ng-content></ng-content>\n</button>\n" }] }], propDecorators: { disabled: [{ type: Input }], variant: [{ type: Input }] } }); class InputComponent { cdr = inject(ChangeDetectorRef); label; inputElement; placeholder = ''; type = 'text'; disabled = false; rows = 1; autofocus = false; value = ''; onChange = (value) => { }; onTouched = () => { }; writeValue(value) { this.value = value; } registerOnChange(fn) { this.onChange = fn; } registerOnTouched(fn) { this.onTouched = fn; } setDisabledState(isDisabled) { this.disabled = isDisabled; } focus() { this.inputElement?.nativeElement?.focus(); } onInput(event) { const input = event.target; this.value = input.value; this.onChange(this.value); this.onTouched(); if (this.rows > 1) { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; } } onKeydown($event) { if (this.rows === 1 && $event.key === 'Enter') { $event.preventDefault(); $event.stopPropagation(); } } ngAfterViewInit() { // hide label if no content. if (!this.label.nativeElement.innerText.trim()) { this.label.nativeElement.style.display = 'none'; } if (this.autofocus) { // Ensure change detection is complete and view is stable this.cdr.detectChanges(); setTimeout(() => { if (this.inputElement?.nativeElement instanceof HTMLElement) { this.inputElement.nativeElement.focus(); } }, 100); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: InputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.4", type: InputComponent, isStandalone: true, selector: "haloduck-input", inputs: { placeholder: "placeholder", type: "type", disabled: "disabled", rows: "rows", autofocus: "autofocus", value: "value" }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => InputComponent), multi: true, }, provideTranslocoScope('haloduck'), ], viewQueries: [{ propertyName: "label", first: true, predicate: ["label"], descendants: true }, { propertyName: "inputElement", first: true, predicate: ["input"], descendants: true }], ngImport: i0, template: "<div class=\"flex flex-col gap-2\">\n <label #label\n class=\"block text-sm/6 font-medium text-light-on-control dark:text-dark-on-control text-left\">\n <ng-content></ng-content>\n </label>\n @if (rows > 1) {\n <textarea #input\n [value]=\"value\"\n (input)=\"onInput($event)\"\n [disabled]=\"disabled\"\n [placeholder]=\"placeholder\"\n [rows]=\"rows\"\n (keydown)=\"onKeydown($event)\"\n class=\"block w-full rounded-md bg-light-control dark:bg-dark-control disabled:bg-light-control/60 dark:disabled:bg-dark-control/80 px-3 py-1.5 text-base text-light-on-control dark:text-dark-on-control disabled:cursor-not-allowed disabled:text-light-on-control/60 dark:disabled:text-dark-on-control/80 outline -outline-offset-1 outline-light-inactive dark:outline-dark-inactive placeholder:text-light-inactive dark:placeholder:text-dark-inactive focus:outline-2 focus:outline-offset-2 focus:outline-light-primary dark:focus:outline-dark-primary sm:text-sm/6\"></textarea>\n }\n @else {\n <input #input\n [type]=\"type\"\n [value]=\"value\"\n (input)=\"onInput($event)\"\n [disabled]=\"disabled\"\n [placeholder]=\"placeholder\"\n (keydown)=\"onKeydown($event)\"\n class=\"block w-full rounded-md bg-light-control dark:bg-dark-control disabled:bg-light-control/60 dark:disabled:bg-dark-control/80 px-3 py-1.5 text-base text-light-on-control dark:text-dark-on-control disabled:cursor-not-allowed disabled:text-light-on-control/60 dark:disabled:text-dark-on-control/80 outline -outline-offset-1 outline-light-inactive dark:outline-dark-inactive placeholder:text-light-inactive dark:placeholder:text-dark-inactive focus:outline-2 focus:outline-offset-2 focus:outline-light-primary dark:focus:outline-dark-primary sm:text-sm/6\">\n }\n</div>\n", styles: [""] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: InputComponent, decorators: [{ type: Component, args: [{ selector: 'haloduck-input', providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => InputComponent), multi: true, }, provideTranslocoScope('haloduck'), ], template: "<div class=\"flex flex-col gap-2\">\n <label #label\n class=\"block text-sm/6 font-medium text-light-on-control dark:text-dark-on-control text-left\">\n <ng-content></ng-content>\n </label>\n @if (rows > 1) {\n <textarea #input\n [value]=\"value\"\n (input)=\"onInput($event)\"\n [disabled]=\"disabled\"\n [placeholder]=\"placeholder\"\n [rows]=\"rows\"\n (keydown)=\"onKeydown($event)\"\n class=\"block w-full rounded-md bg-light-control dark:bg-dark-control disabled:bg-light-control/60 dark:disabled:bg-dark-control/80 px-3 py-1.5 text-base text-light-on-control dark:text-dark-on-control disabled:cursor-not-allowed disabled:text-light-on-control/60 dark:disabled:text-dark-on-control/80 outline -outline-offset-1 outline-light-inactive dark:outline-dark-inactive placeholder:text-light-inactive dark:placeholder:text-dark-inactive focus:outline-2 focus:outline-offset-2 focus:outline-light-primary dark:focus:outline-dark-primary sm:text-sm/6\"></textarea>\n }\n @else {\n <input #input\n [type]=\"type\"\n [value]=\"value\"\n (input)=\"onInput($event)\"\n [disabled]=\"disabled\"\n [placeholder]=\"placeholder\"\n (keydown)=\"onKeydown($event)\"\n class=\"block w-full rounded-md bg-light-control dark:bg-dark-control disabled:bg-light-control/60 dark:disabled:bg-dark-control/80 px-3 py-1.5 text-base text-light-on-control dark:text-dark-on-control disabled:cursor-not-allowed disabled:text-light-on-control/60 dark:disabled:text-dark-on-control/80 outline -outline-offset-1 outline-light-inactive dark:outline-dark-inactive placeholder:text-light-inactive dark:placeholder:text-dark-inactive focus:outline-2 focus:outline-offset-2 focus:outline-light-primary dark:focus:outline-dark-primary sm:text-sm/6\">\n }\n</div>\n" }] }], propDecorators: { label: [{ type: ViewChild, args: ['label'] }], inputElement: [{ type: ViewChild, args: ['input'] }], placeholder: [{ type: Input }], type: [{ type: Input }], disabled: [{ type: Input }], rows: [{ type: Input }], autofocus: [{ type: Input }], value: [{ type: Input }] } }); // auth.component.ts class AuthenticateComponent { fb; http; notificationService = inject(NotificationService); loginForm; signupForm; resetForm; newPasswordForm; otpForm; stage = 'login'; emailForReset = ''; constructor(fb, http) { this.fb = fb; this.http = http; this.loginForm = fb.group({ email: ['', [Validators.required, Validators.email]], password: ['', Validators.required], }); this.signupForm = fb.group({ email: ['', [Validators.required, Validators.email]], password: ['', Validators.required], }); this.resetForm = fb.group({ email: ['', [Validators.required, Validators.email]], }); this.newPasswordForm = fb.group({ newPassword: ['', Validators.required], }); this.otpForm = fb.group({ code: ['', Validators.required], newPassword: ['', Validators.required], }); } switchStage(stage) { this.stage = stage; } login() { signIn({ username: this.loginForm.value.email, password: this.loginForm.value.password, options: { authFlowType: 'USER_PASSWORD_AUTH', }, }) .then((res) => { // console.log('res', res); // console.log('res.nextStep.signInStep', res.nextStep.signInStep); if (res.nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED') { this.switchStage('newPassword'); } else { this.notificationService.showNotification('success', 'Successfully signed in.', 3000); } }) .catch((err) => { if (err.code === 'NotAuthorizedException') { this.switchStage('reset'); } else { this.notificationService.showNotification('error', 'Failed to sign in.'); } }); } confirmNewPassword() { confirmSignIn({ challengeResponse: this.newPasswordForm.value.newPassword, }) .then(() => { this.notificationService.showNotification('success', 'Successfully signed in.', 3000); }) .catch((err) => { if (err.code === 'NotAuthorizedException') { this.notificationService.showNotification('error', 'Failed to sign in.'); } else if (err.code === 'InvalidPasswordException') { this.notificationService.showNotification('error', 'Invalid password.'); } else { this.notificationService.showNotification('error', 'Failed to confirm new password.'); } }); } signup() { this.http.post('/api/signup', this.signupForm.value).subscribe(console.log); } reset() { resetPassword({ username: this.resetForm.value.email }) .then(() => { this.switchStage('otpVerify'); }) .catch((err) => { this.notificationService.showNotification('error', 'Failed to request verification code.'); }); } verifyOtp() { confirmResetPassword({ username: this.resetForm.value.email, confirmationCode: this.otpForm.value.code, newPassword: this.otpForm.value.newPassword, }) .then(() => { this.notificationService.showNotification('success', 'Successfully reset password.', 3000); this.switchStage('login'); }) .catch((err) => { this.notificationService.showNotification('error', 'Failed to reset password.'); }); } get passwordValue() { return this.newPasswordForm.get('newPassword')?.value || ''; } get isMinLength() { return this.passwordValue.length >= 8; } get hasUppercase() { return /[A-Z]/.test(this.passwordValue); } get hasLowercase() { return /[a-z]/.test(this.passwordValue); } get hasNumber() { return /[0-9]/.test(this.passwordValue); } get hasSpecialChar() { return /[^A-Za-z0-9]/.test(this.passwordValue); } get isPasswordValid() { return this.isMinLength && this.hasUppercase && this.hasLowercase && this.hasNumber && this.hasSpecialChar; } get passwordRules() { return [ { label: 'minimum of 8 characters', valid: this.isMinLength }, { label: 'one uppercase letter', valid: this.hasUppercase }, { label: 'one lowercase letter', valid: this.hasLowercase }, { label: 'one numeric digit', valid: this.hasNumber }, { label: 'one special character', valid: this.hasSpecialChar }, ]; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: AuthenticateComponent, deps: [{ token: i1$1.FormBuilder }, { token: i2.HttpClient }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.4", type: AuthenticateComponent, isStandalone: true, selector: "haloduck-authenticate", ngImport: i0, template: "<div class=\"flex flex-col items-center justify-center h-full\">\n <!-- auth.component.html -->\n <div class=\"w-full max-w-md mx-auto mt-20 p-8 shadow-lg rounded-2xl bg-light-background dark:bg-dark-background\">\n\n @switch (stage) {\n @case ('login') {\n <!-- \uB85C\uADF8\uC778 -->\n <form [formGroup]=\"loginForm\"\n (ngSubmit)=\"login()\"\n class=\"flex flex-col gap-4\">\n <haloduck-input data-testid=\"authenticate-login-email\"\n type=\"email\"\n formControlName=\"email\"\n placeholder=\"Email\" />\n <haloduck-input data-testid=\"authenticate-login-password\"\n type=\"password\"\n formControlName=\"password\"\n placeholder=\"Password\" />\n <haloduck-button data-testid=\"authenticate-login-submit\"\n type=\"submit\"\n variant=\"primary\"\n (click)=\"login()\">Sign In</haloduck-button>\n </form>\n }\n @case ('signup') {\n <!-- \uD68C\uC6D0\uAC00\uC785 -->\n <form [formGroup]=\"signupForm\"\n (ngSubmit)=\"signup()\"\n class=\"flex flex-col gap-4\">\n <haloduck-input data-testid=\"authenticate-signup-email\"\n type=\"email\"\n formControlName=\"email\"\n placeholder=\"Email\" />\n <haloduck-input data-testid=\"authenticate-signup-password\"\n type=\"password\"\n formControlName=\"password\"\n placeholder=\"Password\" />\n <haloduck-button data-testid=\"authenticate-signup-submit\"\n type=\"submit\"\n variant=\"primary\">Sign Up</haloduck-button>\n </form>\n }\n @case ('reset') {\n <!-- \uBE44\uBC00\uBC88\uD638 \uC7AC\uC124\uC815 \uC694\uCCAD -->\n <form [formGroup]=\"resetForm\"\n (ngSubmit)=\"reset()\"\n class=\"flex flex-col gap-4\">\n <haloduck-input data-testid=\"authenticate-reset-email\"\n type=\"email\"\n formControlName=\"email\"\n placeholder=\"Email\" />\n <haloduck-button data-testid=\"authenticate-reset-submit\"\n type=\"submit\"\n variant=\"primary\"\n (click)=\"reset()\">Send Verification Code</haloduck-button>\n </form>\n }\n @case ('otpVerify') {\n <!-- OTP \uC778\uC99D \uD6C4 \uC0C8 \uBE44\uBC00\uBC88\uD638 \uC785\uB825 -->\n <form [formGroup]=\"otpForm\"\n (ngSubmit)=\"verifyOtp()\"\n class=\"flex flex-col gap-4\">\n <haloduck-input data-testid=\"authenticate-otp-code\"\n type=\"text\"\n formControlName=\"code\"\n placeholder=\"Verification Code\" />\n <haloduck-input data-testid=\"authenticate-otp-password\"\n type=\"password\"\n formControlName=\"newPassword\"\n placeholder=\"New password\" />\n <haloduck-button data-testid=\"authenticate-otp-submit\"\n type=\"submit\"\n variant=\"primary\"\n (click)=\"verifyOtp()\">Reset Password</haloduck-button>\n </form>\n\n }\n @case ('newPassword') {\n <!-- \uC784\uC2DC \uBE44\uBC00\uBC88\uD638 \u2192 \uC0C8 \uBE44\uBC00\uBC88\uD638 \uBCC0\uACBD -->\n <form [formGroup]=\"newPasswordForm\"\n (ngSubmit)=\"confirmNewPassword()\"\n class=\"flex flex-col gap-4\">\n <haloduck-input data-testid=\"authenticate-new-password-password\"\n type=\"password\"\n formControlName=\"newPassword\"\n placeholder=\"New password\">\n </haloduck-input>\n <div class=\"text-sm flex flex-col\">\n @for (rule of passwordRules; track rule.label) {\n <span [class.text-light-secondary]=\"rule.valid && passwordValue !== ''\"\n [class.line-through]=\"rule.valid && passwordValue !== ''\"\n [class.text-light-danger]=\"!rule.valid && passwordValue !== ''\"\n [class.text-light-inactive]=\"passwordValue === ''\">\n {{ rule.valid ? '\u2714' : '\u2717' }} {{ rule.label }}\n </span>\n }\n </div>\n <haloduck-button data-testid=\"authenticate-new-password-submit\"\n type=\"submit\"\n (click)=\"confirmNewPassword()\"\n variant=\"primary\"\n [disabled]=\"!isPasswordValid\">Change Password</haloduck-button>\n </form>\n }\n }\n\n <!-- \uB2E8\uACC4 \uC804\uD658 \uD0ED -->\n <div class=\"flex justify-center mt-6 gap-4\">\n @if (stage !== 'login') {\n <haloduck-button data-testid=\"authenticate-to-signin\"\n variant=\"none\"\n (click)=\"switchStage('login')\">to Sign In</haloduck-button>\n }\n <!-- <haloduck-button class=\"text-sm text-blue-600 hover:underline\"\n (click)=\"switchStage('signup')\">\uD68C\uC6D0\uAC00\uC785</haloduck-button> -->\n @if (stage !== 'reset' && stage !== 'otpVerify' && stage !== 'newPassword') {\n <haloduck-button data-testid=\"authenticate-to-reset-password\"\n variant=\"secondary\"\n (click)=\"switchStage('reset')\">Reset Password</haloduck-button>\n }\n </div>\n\n </div>\n</div>\n", styles: [""], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: InputComponent, selector: "haloduck-input", inputs: ["placeholder", "type", "disabled", "rows", "autofocus", "value"] }, { kind: "component", type: ButtonComponent, selector: "haloduck-button", inputs: ["disabled", "variant"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: AuthenticateComponent, decorators: [{ type: Component, args: [{ selector: 'haloduck-authenticate', imports: [FormsModule, ReactiveFormsModule, InputComponent, ButtonComponent], template: "<div class=\"flex flex-col items-center justify-center h-full\">\n <!-- auth.component.html -->\n <div class=\"w-full max-w-md mx-auto mt-20 p-8 shadow-lg rounded-2xl bg-light-background dark:bg-dark-background\">\n\n @switch (stage) {\n @case ('login') {\n <!-- \uB85C\uADF8\uC778 -->\n <form [formGroup]=\"loginForm\"\n (ngSubmit)=\"login()\"\n class=\"flex flex-col gap-4\">\n <haloduck-input data-testid=\"authenticate-login-email\"\n type=\"email\"\n formControlName=\"email\"\n placeholder=\"Email\" />\n <haloduck-input data-testid=\"authenticate-login-password\"\n type=\"password\"\n formControlName=\"password\"\n placeholder=\"Password\" />\n <haloduck-button data-testid=\"authenticate-login-submit\"\n type=\"submit\"\n variant=\"primary\"\n (click)=\"login()\">Sign In</haloduck-button>\n </form>\n }\n @case ('signup') {\n <!-- \uD68C\uC6D0\uAC00\uC785 -->\n <form [formGroup]=\"signupForm\"\n (ngSubmit)=\"signup()\"\n class=\"flex flex-col gap-4\">\n <haloduck-input data-testid=\"authenticate-signup-email\"\n type=\"email\"\n formControlName=\"email\"\n placeholder=\"Email\" />\n <haloduck-input data-testid=\"authenticate-signup-password\"\n type=\"password\"\n formControlName=\"password\"\n placeholder=\"Password\" />\n <haloduck-button data-testid=\"authenticate-signup-submit\"\n type=\"submit\"\n variant=\"primary\">Sign Up</haloduck-button>\n </form>\n }\n @case ('reset') {\n <!-- \uBE44\uBC00\uBC88\uD638 \uC7AC\uC124\uC815 \uC694\uCCAD -->\n <form [formGroup]=\"resetForm\"\n (ngSubmit)=\"reset()\"\n class=\"flex flex-col gap-4\">\n <haloduck-input data-testid=\"authenticate-reset-email\"\n type=\"email\"\n formControlName=\"email\"\n placeholder=\"Email\" />\n <haloduck-button data-testid=\"authenticate-reset-submit\"\n type=\"submit\"\n variant=\"primary\"\n (click)=\"reset()\">Send Verification Code</haloduck-button>\n </form>\n }\n @case ('otpVerify') {\n <!-- OTP \uC778\uC99D \uD6C4 \uC0C8 \uBE44\uBC00\uBC88\uD638 \uC785\uB825 -->\n <form [formGroup]=\"otpForm\"\n (ngSubmit)=\"verifyOtp()\"\n class=\"flex flex-col gap-4\">\n <haloduck-input data-testid=\"authenticate-otp-code\"\n type=\"text\"\n formControlName=\"code\"\n placeholder=\"Verification Code\" />\n <haloduck-input data-testid=\"authenticate-otp-password\"\n type=\"password\"\n formControlName=\"newPassword\"\n placeholder=\"New password\" />\n <haloduck-button data-testid=\"authenticate-otp-submit\"\n type=\"submit\"\n variant=\"primary\"\n (click)=\"verifyOtp()\">Reset Password</haloduck-button>\n </form>\n\n }\n @case ('newPassword') {\n <!-- \uC784\uC2DC \uBE44\uBC00\uBC88\uD638 \u2192 \uC0C8 \uBE44\uBC00\uBC88\uD638 \uBCC0\uACBD -->\n <form [formGroup]=\"newPasswordForm\"\n (ngSubmit)=\"confirmNewPassword()\"\n class=\"flex flex-col gap-4\">\n <haloduck-input data-testid=\"authenticate-new-password-password\"\n type=\"password\"\n formControlName=\"newPassword\"\n placeholder=\"New password\">\n </haloduck-input>\n <div class=\"text-sm flex flex-col\">\n @for (rule of passwordRules; track rule.label) {\n <span [class.text-light-secondary]=\"rule.valid && passwordValue !== ''\"\n [class.line-through]=\"rule.valid && passwordValue !== ''\"\n [class.text-light-danger]=\"!rule.valid && passwordValue !== ''\"\n [class.text-light-inactive]=\"passwordValue === ''\">\n {{ rule.valid ? '\u2714' : '\u2717' }} {{ rule.label }}\n </span>\n }\n </div>\n <haloduck-button data-testid=\"authenticate-new-password-submit\"\n type=\"submit\"\n (click)=\"confirmNewPassword()\"\n variant=\"primary\"\n [disabled]=\"!isPasswordValid\">Change Password</haloduck-button>\n </form>\n }\n }\n\n <!-- \uB2E8\uACC4 \uC804\uD658 \uD0ED -->\n <div class=\"flex justify-center mt-6 gap-4\">\n @if (stage !== 'login') {\n <haloduck-button data-testid=\"authenticate-to-signin\"\n variant=\"none\"\n (click)=\"switchStage('login')\">to Sign In</haloduck-button>\n }\n <!-- <haloduck-button class=\"text-sm text-blue-600 hover:underline\"\n (click)=\"switchStage('signup')\">\uD68C\uC6D0\uAC00\uC785</haloduck-button> -->\n @if (stage !== 'reset' && stage !== 'otpVerify' && stage !== 'newPassword') {\n <haloduck-button data-testid=\"authenticate-to-reset-password\"\n variant=\"secondary\"\n (click)=\"switchStage('reset')\">Reset Password</haloduck-button>\n }\n </div>\n\n </div>\n</div>\n" }] }], ctorParameters: () => [{ type: i1$1.FormBuilder }, { type: i2.HttpClient }] }); class SelectDropdownComponent { _filteredOptions = signal([], ...(ngDevMode ? [{ debugName: "_filteredOptions" }] : [])); _options = []; _selectedOptionIds = []; manualInputValues = {}; activeManualKey = null; selectedChange = new EventEmitter(); closeDropdown = new EventEmitter(); useFilter = false; multiselect = true; atLeastOne = false; asButton = false; set options(value) { this._options = value || []; this._filteredOptions.set(this._options); this.seedManualInputs(); } get options() { return this._filteredOptions(); } set selectedOptionIds(value) { this._selectedOptionIds = value || []; this.seedManualInputs(); } get selectedOptions() { return this._selectedOptionIds; } onFilterInput(event) { const value = event.target.value.toLowerCase(); this._filteredOptions.set(this._options.filter((opt) => opt.value.toLowerCase().includes(value) || opt.isSticky === true)); } onToggleOption(option) { if (this.asButton) { this.setSelected([option.id || option.value]); return; } // Handle manual input option if (option.shouldManualInput) { const key = this.getManualKey(option); const prefix = option.manualPrefix || ''; const typed = (this.manualInputValues[key] || '').trim(); const isSelected = this.isOptionSelected(option); if (isSelected) { // Deselect current manual value if (this.multiselect) { let next; if (prefix) { next = this._selectedOptionIds.filter((id) => !id.startsWith(prefix)); } else { next = this._selectedOptionIds.filter((id) => id !== typed); } this.setSelected(next); } else { this.setSelected([]); } // Clear input text when deselected this.manualInputValues[key] = ''; // Hide input this.activeManualKey = null; } else { // open manual input and handle selection based on mode this.activeManualKey = key; // In single select mode, clear existing selections first if (!this.multiselect) { const sentinel = option.id || option.value; this.setSelected([sentinel]); } // ensure there is an entry in manualInputValues if (this.manualInputValues[key] === undefined) { this.manualInputValues[key] = ''; } // focus input on next tick setTimeout(() => { const selector = `input[data-manual-key="${key}"]`; const el = document.querySelector(selector); el?.focus(); el?.select(); }); } return; } // Non-manual options if (this.multiselect) { const id = option.id || option.value; if (option.isExclusive) { if (this._selectedOptionIds.includes(id)) { this.setSelected([]); } else { this.setSelected([id]); } } else { // remove any exclusive selections let next = this._selectedOptionIds.filter((selectedId) => !this.isIdExclusive(selectedId)); if (!next.includes(id)) { next = [id, ...next]; } else { next = next.filter((x) => x !== id); } this.setSelected(next); } } else { const id = option.id || option.value; if (this.atLeastOne) { this.setSelected([id]); } else { if (this._selectedOptionIds.includes(id)) { this.setSelected([]); } else { this.setSelected([id]); } } } } isOptionSelected(option) { const id = option.id || option.value; if (option.shouldManualInput) { const key = this.getManualKey(option); const prefix = option.manualPrefix; // Check if this manual input option is currently active const isActive = this.activeManualKey === key; if (prefix) { // selected if there exists a value with more than prefix, OR if it's currently active const hasValueWithPrefix = this._selectedOptionIds.some((selectedId) => typeof selectedId === 'string' && selectedId.startsWith(prefix) && selectedId.length > prefix.length); return hasValueWithPrefix || isActive; } // no prefix: consider selected if any selected id equals current typed value (non-empty) OR if it's active const typed = (this.manualInputValues[key] || '').trim(); const hasTypedValue = typed !== '' && this._selectedOptionIds.includes(typed); return hasTypedValue || isActive; } return this._selectedOptionIds.includes(id); } isIdExclusive(id) { for (const o of this._options) { if (!o.isExclusive) continue; const refId = o.id || o.value; if (refId === id) return true; if (o.shouldManualInput && o.manualPrefix && id?.startsWith(o.manualPrefix)) { return true; } } return false; } getManualKey(option) { return option.manualPrefix || (option.id || option.value); } seedManualInputs() { const manualOptions = this._options.filter((o) => o.shouldManualInput); for (const opt of manualOptions) { const prefix = opt.manualPrefix || ''; const key = this.getManualKey(opt); const existingWithPrefix = this._selectedOptionIds.find((id) => prefix ? id?.startsWith(prefix) : false); const value = existingWithPrefix ? existingWithPrefix.substring(prefix.length) : ''; this.manualInputValues[key] = value; } } onManualInputChange(option, value) { const key = this.getManualKey(option); this.manualInputValues[key] = value; // Dynamically reflect selection as user types const prefix = option.manualPrefix || ''; const sentinel = option.id || option.value; const trimmed = (value || '').trim(); if (this.multiselect) { if (option.isExclusive) { if (trimmed === '') { // Keep the sentinel selected to maintain the manual input option as "active" this.setSelected([sentinel]); } else { this.setSelected([`${prefix}${trimmed}`]); } } else { let next = this._selectedOptionIds.filter((id) => !this.isIdExclusive(id)); // remove same prefix or sentinel next = next.filter((id) => (prefix ? !id.startsWith(prefix) : id !== sentinel)); if (trimmed !== '') { const toAdd = `${prefix}${trimmed}`; if (!next.includes(toAdd)) { next = [toAdd, ...next]; } } else { // Keep the sentinel to maintain selection state when text is empty if (!next.includes(sentinel)) { next = [sentinel, ...next]; } } this.setSelected(next); } } else { // single select if (trimmed === '') { // Keep the sentinel selected to maintain the manual input option as "active" this.setSelected([sentinel]); } else { this.setSelected([`${prefix}${trimmed}`]); } } } onManualInputConfirm(option) { const key = this.getManualKey(option); const raw = (this.manualInputValues[key] || '').trim(); // Use same logic as onManualInputChange this.onManualInputChange(option, raw); // Close dropdown in single select mode after confirming input if (!this.multiselect) { this.closeDropdown.emit(); } } onManualInputBlur(option) { const key = this.getManualKey(option); const raw = (this.manualInputValues[key] || '').trim(); if (raw === '') { // hide input on blur when empty this.activeManualKey = null; // Also deselect the manual input option completely const prefix = option.manualPrefix || ''; const sentinel = option.id || option.value; if (this.multiselect) { let next = [...this._selectedOptionIds]; // Remove sentinel and any values with the prefix next = next.filter((id) => { if (id === sentinel) return false; if (prefix && id.startsWith(prefix)) return false; return true; }); this.setSelected(next); } else { // Single select: deselect completely this.setSelected([]); } } } emitSelectedChange() { const payload = [...this._selectedOptionIds]; setTimeout(() => this.selectedChange.emit(payload)); } setSelected(next) { const sanitized = next.filter((id) => id != null); setTimeout(() => { this._selectedOptionIds = sanitized; this.emitSelectedChange(); }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: SelectDropdownComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.4", type: SelectDropdownComponent, isStandalone: true, selector: "haloduck-select-dropdown", inputs: { useFilter: "useFilter", multiselect: "multiselect", atLeastOne: "atLeastOne", asButton: "asButton", options: "options", selectedOptionIds: "selectedOptionIds" }, outputs: { selectedChange: "selectedChange", closeDropdown: "closeDropdown" }, providers: [provideTranslocoScope('haloduck')], ngImport: i0, template: "<div id=\"dropdown\"\n class=\"max-w-full mt-2 absolute z-40 bg-light-background dark:bg-dark-background text-light-on-background dark:text-dark-on-background border border-light-inactive dark:border-dark-inactive rounded max-h-60 flex flex-col gap-2\">\n @if (useFilter && _options.length >= 5) {\n <input #inputFilter\n id=\"inputFilter\"\n type=\"text\"\n [placeholder]=\"'haloduck.ui.select.Keyword...' | transloco\"\n (input)=\"onFilterInput($event)\"\n class=\"text-light-inactive dark:text-dark-inactive rounded-md outline -outline-offset-1 outline-light-inactive dark:outline-dark-inactive focus:outline-2 focus:outline-offset-2 focus:outline-light-primary dark:focus:outline-dark-primary px-3 py-1.5 text-sm/6 bg-light-control dark:bg-dark-control m-2\" />\n }\n <div class=\"overflow-y-auto\">\n @for ( option of _filteredOptions(); track (option.id) ? option.id : option.value) {\n <div class=\"px-3 py-2 text-sm/6 hover:bg-light-secondary/60 dark:hover:bg-dark-secondary/60 flex items-center justify-start whitespace-nowrap\"\n [class.cursor-pointer]=\"!option.shouldManualInput\"\n (click)=\"onToggleOption(option)\">\n @if(!asButton) {\n @if( isOptionSelected(option)) {\n <svg class=\"w-4 h-4 text-light-primary dark:text-dark-primary inline-block mr-2\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 20 20\"\n fill=\"currentColor\"\n aria-hidden=\"true\">\n <path fill-rule=\"evenodd\"\n d=\"M16.707 5.293a1 1 0 00-1.414 0L8 12.586 4.707 9.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 000-1.414z\"\n clip-rule=\"evenodd\" />\n </svg>\n } @else {\n <div class=\"w-4 h-4 inline-block mr-2\"></div>\n }\n }\n @if(option.shouldManualInput && (isOptionSelected(option) || (activeManualKey === (option.manualPrefix ? option.manualPrefix : (option.id || option.value))))) {\n <input type=\"text\"\n [(ngModel)]=\"manualInputValues[option.manualPrefix ? option.manualPrefix : (option.id || option.value)]\"\n [attr.data-manual-key]=\"option.manualPrefix ? option.manualPrefix : (option.id || option.value)\"\n (ngModelChange)=\"onManualInputChange(option, $event)\"\n (click)=\"$event.stopPropagation()\"\n (blur)=\"onManualInputBlur(option)\"\n (keydown.enter)=\"onManualInputConfirm(option)\"\n class=\"text-light-on-control dark:text-dark-on-control rounded-md outline -outline-offset-1 outline-light-inactive dark:outline-dark-inactive focus:outline-2 focus:outline-offset-2 focus:outline-light-primary dark:focus:outline-dark-primary px-2 py-1 text-sm/6 bg-light-control dark:bg-dark-control w-full\" />\n } @else {\n {{ option.value }}\n }\n </div>\n }\n </div>\n</div>\n", styles: [""], dependencies: [{ kind: "ngmodule", type: TranslocoModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.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: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "pipe", type: i2$1.TranslocoPipe, name: "transloco" }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: SelectDropdownComponent, decorators: [{ type: Component, args: [{ selector: 'haloduck-select-dropdown', imports: [TranslocoModule, FormsModule], providers: [provideTranslocoScope('haloduck')], template: "<div id=\"dropdown\"\n class=\"max-w-full mt-2 absolute z-40 bg-light-background dark:bg-dark-background text-light-on-background dark:text-dark-on-background border border-light-inactive dark:border-dark-inactive rounded max-h-60 flex flex-col gap-2\">\n @if (useFilter && _options.length >= 5) {\n <input #inputFilter\n id=\"inputFilter\"\n type=\"text\"\n [placeholder]=\"'haloduck.ui.select.Keyword...' | transloco\"\n (input)=\"onFilterInput($event)\"\n class=\"text-light-inactive dark:text-dark-inactive rounded-md outline -outline-offset-1 outline-light-inactive dark:outline-dark-inactive focus:outline-2 focus:outline-offset-2 focus:outline-light-primary dark:focus:outline-dark-primary px-3 py-1.5 text-sm/6 bg-light-control dark:bg-dark-control m-2\" />\n }\n <div class=\"overflow-y-auto\">\n @for ( option of _filteredOptions(); track (option.id) ? option.id : option.value) {\n <div class=\"px-3 py-2 text-sm/6 hover:bg-light-secondary/60 dark:hover:bg-dark-secondary/60 flex items-center justify-start whitespace-nowrap\"\n [class.cursor-pointer]=\"!option.shouldManualInput\"\n (click)=\"onToggleOption(option)\">\n @if(!asButton) {\n @if( isOptionSelected(option)) {\n <svg class=\"w-4 h-4 text-light-primary dark:text-dark-primary inline-block mr-2\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 20 20\"\n fill=\"currentColor\"\n aria-hidden=\"true\">\n <path fill-rule=\"evenodd\"\n d=\"M16.707 5.293a1 1 0 00-1.414 0L8 12.586 4.707 9.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 000-1.414z\"\n clip-rule=\"evenodd\" />\n </svg>\n } @else {\n <div class=\"w-4 h-4 inline-block mr-2\"></div>\n }\n }\n @if(option.shouldManualInput && (isOptionSelected(option) || (activeManualKey === (option.manualPrefix ? option.manualPrefix : (option.id || option.value))))) {\n <input type=\"text\"\n [(ngModel)]=\"manualInputValues[option.manualPrefix ? option.manualPrefix : (option.id || option.value)]\"\n [attr.data-manual-key]=\"option.manualPrefix ? option.manualPrefix : (option.id || option.value)\"\n (ngModelChange)=\"onManualInputChange(option, $event