@block-auth.io/angular 0.2.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ ElementRef,
5
+ HostListener,
6
+ computed,
7
+ effect,
8
+ inject,
9
+ input,
10
+ linkedSignal,
11
+ output,
12
+ signal,
13
+ viewChild,
14
+ } from '@angular/core';
15
+ import {
16
+ $locale,
17
+ getDefaultPhoneCountry,
18
+ getPhoneCountries,
19
+ resolvePhoneDraft,
20
+ searchPhoneCountries,
21
+ t as translate,
22
+ updatePhoneDraft,
23
+ type PhoneCountryCode,
24
+ type PhoneDraft,
25
+ type TextKey,
26
+ } from '@block-auth.io/core';
27
+ import { injectStore } from '../../../inject-store';
28
+
29
+ @Component({
30
+ selector: 'ba-phone-input',
31
+ standalone: true,
32
+ host: { style: 'display: contents' },
33
+ changeDetection: ChangeDetectionStrategy.OnPush,
34
+ template: `
35
+ <div class="bal-ba-glass-phone">
36
+ <div class="bal-ba-glass-phone-row">
37
+ <button
38
+ #countryButton
39
+ type="button"
40
+ [id]="inputId() + '-country'"
41
+ aria-haspopup="listbox"
42
+ [attr.aria-expanded]="isOpen() ? 'true' : 'false'"
43
+ [attr.aria-controls]="isOpen() ? listId() : null"
44
+ [attr.aria-label]="buttonLabel()"
45
+ class="bal-ba-glass-phone-country"
46
+ (click)="toggle()"
47
+ (keydown)="onButtonKeyDown($event)"
48
+ >
49
+ <span class="bal-ba-glass-phone-flag" aria-hidden="true">{{
50
+ selected()?.flag
51
+ }}</span>
52
+ <span aria-hidden="true">{{ selected()?.dialCode }}</span>
53
+ <svg
54
+ viewBox="0 0 10 10"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ stroke-width="1.6"
58
+ stroke-linecap="round"
59
+ stroke-linejoin="round"
60
+ aria-hidden="true"
61
+ class="bal-ba-glass-phone-chevron"
62
+ >
63
+ <path d="M2 3.5 5 6.5 8 3.5" />
64
+ </svg>
65
+ </button>
66
+ <div class="bal-ba-glass-input-wrap">
67
+ <input
68
+ #nationalInput
69
+ [id]="inputId()"
70
+ type="tel"
71
+ inputmode="tel"
72
+ autocomplete="tel-national"
73
+ [value]="draft().nationalNumber"
74
+ [attr.aria-invalid]="invalid() ? 'true' : 'false'"
75
+ [attr.aria-describedby]="describedBy()"
76
+ class="bal-ba-glass-input"
77
+ (input)="onNationalInput($event)"
78
+ (blur)="blurred.emit()"
79
+ />
80
+ <ng-content />
81
+ </div>
82
+ </div>
83
+ @if (isOpen()) {
84
+ <div class="bal-ba-glass-phone-menu">
85
+ <input
86
+ #search
87
+ type="text"
88
+ role="combobox"
89
+ autocomplete="off"
90
+ aria-expanded="true"
91
+ aria-autocomplete="list"
92
+ [id]="inputId() + '-country-search'"
93
+ [attr.aria-controls]="listId()"
94
+ [attr.aria-activedescendant]="activeId()"
95
+ [attr.aria-label]="t('searchPhoneCountry')"
96
+ [placeholder]="t('searchPhoneCountry')"
97
+ [value]="query()"
98
+ class="bal-ba-glass-input bal-ba-glass-phone-search"
99
+ (input)="onQueryInput($event)"
100
+ (keydown)="onSearchKeyDown($event)"
101
+ />
102
+ @if (filtered().length) {
103
+ <ul
104
+ #list
105
+ role="listbox"
106
+ [id]="listId()"
107
+ [attr.aria-label]="t('phoneCountry')"
108
+ class="bal-ba-glass-phone-list"
109
+ >
110
+ @for (country of filtered(); track country.iso2; let index = $index) {
111
+ <li
112
+ role="option"
113
+ [id]="optionId(country.iso2)"
114
+ [attr.aria-selected]="
115
+ country.iso2 === draft().country ? 'true' : 'false'
116
+ "
117
+ [class]="
118
+ 'bal-ba-glass-phone-option' +
119
+ (index === activeIndex()
120
+ ? ' bal-ba-glass-phone-option-active'
121
+ : '')
122
+ "
123
+ (mousedown)="$event.preventDefault()"
124
+ (mouseenter)="activeIndex.set(index)"
125
+ (click)="select(country.iso2)"
126
+ >
127
+ <span aria-hidden="true">{{ country.flag }}</span>
128
+ <span class="bal-ba-glass-phone-option-name">{{
129
+ country.name
130
+ }}</span>
131
+ <span class="bal-ba-glass-phone-option-dial">{{
132
+ country.dialCode
133
+ }}</span>
134
+ </li>
135
+ }
136
+ </ul>
137
+ } @else {
138
+ <p role="status" class="bal-ba-glass-phone-empty">
139
+ {{ t('noPhoneCountryResults') }}
140
+ </p>
141
+ }
142
+ </div>
143
+ }
144
+ </div>
145
+ `,
146
+ })
147
+ export class PhoneInputComponent {
148
+ inputId = input.required<string>();
149
+ value = input<string>('');
150
+ invalid = input(false);
151
+ describedBy = input<string | null>(null);
152
+
153
+ valueChange = output<string>();
154
+ blurred = output<void>();
155
+
156
+ private readonly locale = injectStore($locale);
157
+ protected t = (key: TextKey): string => translate(key, this.locale());
158
+
159
+ private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
160
+ private readonly countryButton =
161
+ viewChild.required<ElementRef<HTMLButtonElement>>('countryButton');
162
+ private readonly nationalInput =
163
+ viewChild.required<ElementRef<HTMLInputElement>>('nationalInput');
164
+ private readonly search = viewChild<ElementRef<HTMLInputElement>>('search');
165
+ private readonly list = viewChild<ElementRef<HTMLUListElement>>('list');
166
+ private readonly defaultCountry = getDefaultPhoneCountry();
167
+
168
+ protected readonly draft = linkedSignal<string, PhoneDraft>({
169
+ source: this.value,
170
+ computation: (value, previous) =>
171
+ resolvePhoneDraft(value, previous?.value ?? null, this.defaultCountry),
172
+ });
173
+ protected readonly isOpen = signal(false);
174
+ protected readonly query = signal('');
175
+ protected readonly activeIndex = signal(0);
176
+ protected readonly countries = computed(() =>
177
+ getPhoneCountries(this.locale()),
178
+ );
179
+ protected readonly filtered = computed(() =>
180
+ searchPhoneCountries(this.countries(), this.query()),
181
+ );
182
+ protected readonly selected = computed(() =>
183
+ this.countries().find((country) => country.iso2 === this.draft().country),
184
+ );
185
+ protected readonly listId = computed(() => `${this.inputId()}-countries`);
186
+ protected readonly activeId = computed(() => {
187
+ const active = this.filtered()[this.activeIndex()];
188
+ return active ? this.optionId(active.iso2) : null;
189
+ });
190
+ protected readonly buttonLabel = computed(() =>
191
+ `${this.t('phoneCountry')}: ${
192
+ this.selected()?.name ?? this.draft().country
193
+ } ${this.selected()?.dialCode ?? ''}`.trim(),
194
+ );
195
+
196
+ constructor() {
197
+ effect(() => {
198
+ this.search()?.nativeElement.focus();
199
+ });
200
+ effect(() => {
201
+ const id = this.activeId();
202
+ const list = this.list()?.nativeElement;
203
+ if (!id || !list) return;
204
+ list
205
+ .querySelector<HTMLElement>(`#${id}`)
206
+ ?.scrollIntoView?.({ block: 'nearest' });
207
+ });
208
+ }
209
+
210
+ @HostListener('document:mousedown', ['$event'])
211
+ onDocumentMouseDown(event: MouseEvent): void {
212
+ if (!this.isOpen()) return;
213
+ if (!this.host.nativeElement.contains(event.target as Node)) this.isOpen.set(false);
214
+ }
215
+
216
+ @HostListener('keydown', ['$event'])
217
+ onKeyDown(event: KeyboardEvent): void {
218
+ if (event.key !== 'Escape' || !this.isOpen()) return;
219
+ event.preventDefault();
220
+ event.stopPropagation();
221
+ this.close();
222
+ }
223
+
224
+ focus(): void {
225
+ this.nationalInput().nativeElement.focus();
226
+ }
227
+
228
+ protected optionId(iso2: string): string {
229
+ return `${this.inputId()}-country-${iso2}`;
230
+ }
231
+
232
+ protected toggle(): void {
233
+ if (this.isOpen()) this.close();
234
+ else this.open();
235
+ }
236
+
237
+ protected select(iso2: PhoneCountryCode): void {
238
+ this.commit(updatePhoneDraft(iso2, this.draft().nationalNumber));
239
+ this.isOpen.set(false);
240
+ this.focus();
241
+ }
242
+
243
+ protected onNationalInput(event: Event): void {
244
+ this.commit(
245
+ updatePhoneDraft(
246
+ this.draft().country,
247
+ (event.target as HTMLInputElement).value,
248
+ ),
249
+ );
250
+ }
251
+
252
+ protected onQueryInput(event: Event): void {
253
+ this.query.set((event.target as HTMLInputElement).value);
254
+ this.activeIndex.set(0);
255
+ }
256
+
257
+ protected onSearchKeyDown(event: KeyboardEvent): void {
258
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
259
+ event.preventDefault();
260
+ const total = this.filtered().length;
261
+ if (!total) return;
262
+ const delta = event.key === 'ArrowDown' ? 1 : -1;
263
+ this.activeIndex.update((index) => (index + delta + total) % total);
264
+ } else if (event.key === 'Enter') {
265
+ event.preventDefault();
266
+ const active = this.filtered()[this.activeIndex()];
267
+ if (active) this.select(active.iso2);
268
+ } else if (event.key === 'Tab') {
269
+ this.isOpen.set(false);
270
+ }
271
+ }
272
+
273
+ protected onButtonKeyDown(event: KeyboardEvent): void {
274
+ if (event.key !== 'ArrowDown' || this.isOpen()) return;
275
+ event.preventDefault();
276
+ this.open();
277
+ }
278
+
279
+ private commit(next: PhoneDraft): void {
280
+ this.draft.set(next);
281
+ if (next.value !== this.value()) this.valueChange.emit(next.value);
282
+ }
283
+
284
+ private open(): void {
285
+ this.query.set('');
286
+ this.activeIndex.set(
287
+ Math.max(
288
+ 0,
289
+ this.countries().findIndex(
290
+ (country) => country.iso2 === this.draft().country,
291
+ ),
292
+ ),
293
+ );
294
+ this.isOpen.set(true);
295
+ }
296
+
297
+ private close(): void {
298
+ this.isOpen.set(false);
299
+ this.countryButton().nativeElement.focus();
300
+ }
301
+ }