@haloduck/ui
Version:
HaloDuck UI Library - Angular
614 lines (609 loc) • 291 kB
JavaScript
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