@block-auth.io/angular 0.2.9 → 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.
@@ -1,19 +1,19 @@
1
-
2
- > @block-auth.io/angular@0.2.8 pretypecheck /home/vlad/Escritorio/blockauth-code/blockauth-sdk/packages/angular
3
- > pnpm build:css
4
-
5
-
6
- > @block-auth.io/angular@0.2.8 build:css /home/vlad/Escritorio/blockauth-code/blockauth-sdk/packages/angular
7
- > tailwindcss -i ../ui-styles/src/input.css -c ./tailwind.config.cjs -o ./src/styles.generated.css --minify && node scripts/css-to-ts.mjs
8
-
9
- Browserslist: caniuse-lite is outdated. Please run:
10
- npx update-browserslist-db@latest
11
- Why you should do it regularly: https://github.com/browserslist/update-db#readme
12
-
13
- Rebuilding...
14
-
15
- Done in 1810ms.
16
-
17
- > @block-auth.io/angular@0.2.8 typecheck /home/vlad/Escritorio/blockauth-code/blockauth-sdk/packages/angular
18
- > tsc -p tsconfig.lib.json --noEmit
19
-
1
+
2
+ > @block-auth.io/angular@0.3.0 pretypecheck /home/vlad/Escritorio/blockauth-code/blockauth-sdk/packages/angular
3
+ > pnpm build:css
4
+
5
+
6
+ > @block-auth.io/angular@0.3.0 build:css /home/vlad/Escritorio/blockauth-code/blockauth-sdk/packages/angular
7
+ > tailwindcss -i ../ui-styles/src/input.css -c ./tailwind.config.cjs -o ./src/styles.generated.css --minify && node scripts/css-to-ts.mjs
8
+
9
+ Browserslist: caniuse-lite is outdated. Please run:
10
+ npx update-browserslist-db@latest
11
+ Why you should do it regularly: https://github.com/browserslist/update-db#readme
12
+
13
+ Rebuilding...
14
+
15
+ Done in 2017ms.
16
+
17
+ > @block-auth.io/angular@0.3.0 typecheck /home/vlad/Escritorio/blockauth-code/blockauth-sdk/packages/angular
18
+ > tsc -p tsconfig.lib.json --noEmit
19
+
package/CHANGELOG.md CHANGED
@@ -1,5 +1,125 @@
1
1
  # @block-auth.io/angular
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cf32d5e: El teléfono de los datos solicitados se valida con `libphonenumber-js`
8
+ (1.13.14, metadatos `min`) y se envía en `userParams['users-phone']` en formato
9
+ E.164 (`+34634343343`). `@block-auth.io/core` exporta utilidades de teléfono
10
+ independientes del framework: `getPhoneCountries(locale)` (ISO2, prefijo,
11
+ bandera emoji y nombre localizado con `Intl.DisplayNames`),
12
+ `searchPhoneCountries`, `getPhoneCountryFlag`, `getPhoneDialCode`,
13
+ `getDefaultPhoneCountry` (región del idioma del navegador o `ES`),
14
+ `parsePhoneValue`, `formatPhoneForDisplay` (internacional, o el texto tal cual
15
+ si no se entiende), `isValidPhoneValue`, `toPhoneE164`, `buildPhoneE164`,
16
+ `buildPhoneValue`, `updatePhoneDraft` y `resolvePhoneDraft`. Se acepta también
17
+ el formato antiguo `34_634343343_es`. `solicitedData` añade
18
+ `PARAM_NORMALIZERS`, `normalizeParamValue`, `PARAM_DISPLAY_FORMATTERS` y
19
+ `formatParamValue`, y no marca como cambiado el mismo número escrito en otro
20
+ formato. Nuevos textos `phoneCountry`, `searchPhoneCountry` y
21
+ `noPhoneCountryResults`.
22
+
23
+ En React, Vue y Angular el campo de teléfono de `SolicitedDataModal` es un
24
+ selector de país (bandera y prefijo, con lista de países buscable por nombre,
25
+ código o prefijo, navegable con flechas, Enter para elegir y Escape para
26
+ cerrar) más el número nacional. El país por defecto es el del valor existente,
27
+ si no la región del idioma del navegador y si no España.
28
+ Nuevos componentes internos `PhoneInput` (React y Vue) y `ba-phone-input`
29
+ (Angular). `@block-auth.io/ui-styles` añade las clases `bal-ba-glass-phone`,
30
+ `bal-ba-glass-phone-row`, `bal-ba-glass-phone-country`,
31
+ `bal-ba-glass-phone-flag`, `bal-ba-glass-phone-chevron`,
32
+ `bal-ba-glass-phone-menu`, `bal-ba-glass-phone-search`,
33
+ `bal-ba-glass-phone-list`, `bal-ba-glass-phone-option(-active|-name|-dial)` y
34
+ `bal-ba-glass-phone-empty`.
35
+
36
+ - df80cbe: Los datos solicitados de un login se piden al servidor. Nuevo
37
+ `client.txns.dataRequest(txnId)`: emite `user:txns:data` y resuelve con los
38
+ datos que pide la app (`slug`, `mode`, `status`, `value`), o `null` si el
39
+ servidor falla o no responde a tiempo. `client.txns.accept` acepta
40
+ `grantedParams` y `revokedParams` y los envía en `provider:signin`. Si el servidor rechaza la
41
+ aceptación por datos obligatorios ausentes, no autorizados o inválidos, el
42
+ login sigue abierto con `$ui.acceptTxnError` en lugar de llamar a `_onError`.
43
+ Nuevos tipos `TxnDataParam` y `TxnDataRequest`, y textos
44
+ `completeRequiredData`, `requiredDataError`, `shareData`, `saveData`,
45
+ `undoChange`, `savedToProfileHint`, `pendingChangeHint`, `notSharedHint`,
46
+ `clearField` y `acceptData`.
47
+
48
+ `TxnsOverview` usa `dataRequest` al seleccionar la transacción y, si devuelve
49
+ `null`, mantiene el cálculo con `uiConfig`. `SolicitedDataModal` muestra todos
50
+ los datos que pide la app como campos rellenos con el valor del perfil, cada
51
+ uno editable con una casilla "Ceder" (marcada por defecto en los `shared` y
52
+ `granted`, desmarcada en `consent`/`missing` salvo al escribir un valor válido
53
+ en un campo vacío), un botón "x" para limpiar el campo cuando
54
+ tiene contenido y, si el valor cambia, la decisión "Guardar" / "Deshacer".
55
+ Guardar un valor nuevo marca "Ceder"; si después se desmarca, el valor no se
56
+ envía y se indica que no se cederá. La lógica es pura y se exporta desde
57
+ `@block-auth.io/core` para los tres frameworks (`getSolicitedParams`,
58
+ `getSolicitedFields`, `getSolicitedAcceptParams`, `getSolicitedHintKey`,
59
+ `isSolicitedHintSuccess`, `isSolicitedDataComplete`, `setSolicitedValue`,
60
+ `setSolicitedShared`, `saveSolicitedValue`, `undoSolicitedValue` y las
61
+ constantes `PARAM_*`). ACEPTAR
62
+ queda deshabilitado mientras falte un obligatorio por ceder o haya un cambio
63
+ cedido sin decidir, y envía `userParams` con los valores nuevos guardados,
64
+ `grantedParams` con los valores del perfil `consent`/`granted` cedidos y
65
+ `revokedParams` con los opcionales `shared` o `granted` desmarcados. Un dato
66
+ `shared` cedido sin cambios no se envía. `SolicitedDataModal` acepta `params`,
67
+ `sharedParams`, `handleShareChange`, `savedParams`, `handleSaveParam`,
68
+ `handleUndoParam` y `errorTxt`; `Txn` acepta `grantedParams`, `revokedParams`
69
+ y `solicitedDataPending`.
70
+
71
+ En Vue, `TxnsOverview`, `Txn` y `SolicitedDataModal` replican el mismo
72
+ comportamiento: `SolicitedDataModal` acepta `params`, `sharedParams`,
73
+ `savedParams` y `errorTxt`, y emite `shareChange`, `saveParam` y `undoParam`;
74
+ `Txn` acepta `grantedParams`, `revokedParams` y `solicitedDataPending`.
75
+
76
+ En Angular, `TxnsOverviewComponent`, `TxnComponent` y
77
+ `SolicitedDataModalComponent` replican el mismo comportamiento y diseño:
78
+ `ba-solicited-data-modal`
79
+ acepta `params`, `sharedParams`, `savedParams` y `errorTxt` y emite
80
+ `shareChange` (`ShareChange`), `saveParam` y `undoParam`, y `ba-txn` acepta
81
+ `grantedParams`, `revokedParams` y `solicitedDataPending`.
82
+
83
+ En la aprobación del login, "Permisos requeridos" y "Datos solicitados" son
84
+ botones de cristal y las flechas animadas aparecen en "Datos solicitados"
85
+ siempre que quede algo pendiente para continuar, hasta que se resuelva. Al
86
+ pasar sobre ACEPTAR deshabilitado se indica qué falta: vibra la casilla de la
87
+ política si no está aceptada y, si no, vibra "Datos solicitados".
88
+ `ArrowIcon` (`ba-arrow-icon` en
89
+ Angular) acepta `visible`. `@block-auth.io/ui-styles` añade la clase
90
+ `bal-ba-head-shake` (keyframes `bal-ba-animate-head-shake`, equivalente a
91
+ `animate__headShake` de animate.css, desactivada con
92
+ `prefers-reduced-motion`).
93
+
94
+ Rediseño visual "crystal / liquid glass" de la pantalla de aprobación, los
95
+ modales de permisos y datos solicitados y el selector de tiempo de sesión, sin
96
+ cambiar tamaños ni contenido. `@block-auth.io/ui-styles` añade las clases
97
+ `bal-ba-glass-panel`, `bal-ba-glass-pop`, `bal-ba-glass-backdrop`,
98
+ `bal-ba-glass-rise`, `bal-ba-glass-stagger`, `bal-ba-glass-scroll`,
99
+ `bal-ba-glass-logo`, `bal-ba-glass-divider(-end)`, `bal-ba-glass-row`,
100
+ `bal-ba-glass-icon`, `bal-ba-glass-field`, `bal-ba-glass-label`,
101
+ `bal-ba-glass-badge(-required)`, `bal-ba-glass-input(-wrap|-clear)`,
102
+ `bal-ba-glass-check(-input|-box)`, `bal-ba-glass-decision`,
103
+ `bal-ba-glass-chip-btn(-primary)`, `bal-ba-glass-hint(-success)`,
104
+ `bal-ba-glass-error`, `bal-ba-glass-link`, `bal-ba-glass-consent(-checked)`,
105
+ `bal-ba-glass-action(-reject|-accept)`, `bal-ba-glass-time`,
106
+ `bal-ba-glass-slider` y `bal-ba-glass-btn-pending`, con animaciones cortas
107
+ desactivadas con `prefers-reduced-motion`.
108
+
109
+ ### Patch Changes
110
+
111
+ - Updated dependencies [cf32d5e]
112
+ - Updated dependencies [df80cbe]
113
+ - @block-auth.io/core@0.10.0
114
+ - @block-auth.io/ui-styles@0.5.0
115
+
116
+ ## 0.2.10
117
+
118
+ ### Patch Changes
119
+
120
+ - Updated dependencies
121
+ - @block-auth.io/core@0.9.1
122
+
3
123
  ## 0.2.9
4
124
 
5
125
  ### Patch Changes
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@block-auth.io/angular",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "dependencies": {
5
5
  "nanostores": "^0.10.0",
6
6
  "tslib": "^2.6.0",
7
- "@block-auth.io/core": "0.9.0",
8
- "@block-auth.io/ui-styles": "0.4.0"
7
+ "@block-auth.io/ui-styles": "0.5.0",
8
+ "@block-auth.io/core": "0.10.0"
9
9
  },
10
10
  "peerDependencies": {
11
11
  "@angular/core": ">=20.0.0",
@@ -19,6 +19,15 @@ describe('PermissionsRequiredModal', () => {
19
19
  ).toBeDefined();
20
20
  });
21
21
 
22
+ it('usa el panel glass con filas e iconos animados', async () => {
23
+ const { container } = await render(PermissionsRequiredModalComponent);
24
+ const panel = container.querySelector('.bal-ba-glass-panel');
25
+ expect(panel?.className).toContain('bal-ba-glass-pop');
26
+ expect(panel?.className).toContain('bal-ba-glass-stagger');
27
+ expect(container.querySelectorAll('.bal-ba-glass-row')).toHaveLength(3);
28
+ expect(container.querySelectorAll('.bal-ba-glass-icon')).toHaveLength(3);
29
+ });
30
+
22
31
  it('respeta el locale activo', async () => {
23
32
  setLocale('es');
24
33
  await render(PermissionsRequiredModalComponent);
@@ -4,7 +4,7 @@ import { injectStore } from '../../../inject-store';
4
4
 
5
5
  // Port literal de packages/react/src/components/txns/internals/PermissionsRequiredModal.tsx
6
6
  // (mapa: packages/vue/.../PermissionsRequiredModal.vue). Sin props/outputs.
7
- const iconClassNames = 'bas-w-6 bas-h-6 bas-text-[#009F6B]';
7
+ const iconClassNames = 'bal-ba-glass-icon bas-text-[#009F6B]';
8
8
 
9
9
  @Component({
10
10
  selector: 'ba-permissions-required-modal',
@@ -12,10 +12,10 @@ const iconClassNames = 'bas-w-6 bas-h-6 bas-text-[#009F6B]';
12
12
  changeDetection: ChangeDetectionStrategy.OnPush,
13
13
  template: `
14
14
  <div
15
- class="bal-ba-transition-2 bas-box-border bas-grid bas-w-[334px] bas-grid-rows-3 bas-gap-3 bas-rounded-md bas-border-2 bas-border-solid bas-border-[var(--bal-primary-100)] bas-bg-[var(--bal-primary-10)] bas-py-4 bas-pl-3 hover:bas-border-[var(--bal-primary-300)]"
15
+ class="bal-ba-glass-panel bal-ba-glass-pop bal-ba-glass-stagger bas-box-border bas-grid bas-w-[334px] bas-grid-rows-3 bas-gap-3 bas-py-4 bas-pl-3"
16
16
  >
17
17
  @for (permission of requiredPermissions(); track $index) {
18
- <div class="bas-flex bas-flex-row bas-items-center bas-gap-2">
18
+ <div class="bal-ba-glass-row">
19
19
  <span [class]="iconClassNames" aria-hidden="true">{{
20
20
  permission.icon
21
21
  }}</span>
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { Component } from '@angular/core';
3
+ import { render, screen, fireEvent } from '@testing-library/angular';
4
+ import { setLocale } from '@block-auth.io/core';
5
+ import { PhoneInputComponent } from './phone-input.component';
6
+
7
+ const changes: string[] = [];
8
+
9
+ @Component({
10
+ standalone: true,
11
+ imports: [PhoneInputComponent],
12
+ template: `<ba-phone-input inputId="phone" [value]="value" (valueChange)="onChange($event)" />
13
+ <button type="button" (click)="value = ''">reset</button>`,
14
+ })
15
+ class Host {
16
+ value = '';
17
+ onChange(next: string): void {
18
+ this.value = next;
19
+ changes.push(next);
20
+ }
21
+ }
22
+
23
+ const renderHost = async (value: string) => {
24
+ const result = await render(Host, { componentProperties: { value } });
25
+ await result.fixture.whenStable();
26
+ return result;
27
+ };
28
+
29
+ const countryButton = () => document.getElementById('phone-country') as HTMLButtonElement;
30
+ const nationalInput = () => document.getElementById('phone') as HTMLInputElement;
31
+ const search = () => document.getElementById('phone-country-search') as HTMLInputElement | null;
32
+
33
+ describe('PhoneInputComponent', () => {
34
+ afterEach(() => {
35
+ changes.length = 0;
36
+ setLocale('en');
37
+ });
38
+
39
+ it('muestra la bandera, el prefijo y el número nacional del valor', async () => {
40
+ await renderHost('+34634343343');
41
+ expect(countryButton().textContent).toContain('🇪🇸');
42
+ expect(countryButton().textContent).toContain('+34');
43
+ expect(countryButton().getAttribute('aria-label')).toBe('Country: Spain +34');
44
+ expect(nationalInput().value).toBe('634 34 33 43');
45
+ expect(nationalInput().type).toBe('tel');
46
+ });
47
+
48
+ it('acepta el formato antiguo y lo muestra separado', async () => {
49
+ await renderHost('34_634343343_es');
50
+ expect(countryButton().textContent).toContain('+34');
51
+ expect(nationalInput().value).toBe('634 34 33 43');
52
+ });
53
+
54
+ it('envía el número en E.164 al escribir el número nacional', async () => {
55
+ await renderHost('+34');
56
+ await fireEvent.input(nationalInput(), { target: { value: '634 34 33 43' } });
57
+ expect(changes[changes.length - 1]).toBe('+34634343343');
58
+ expect(nationalInput().value).toBe('634 34 33 43');
59
+ });
60
+
61
+ it('busca y selecciona un país con el teclado y cierra con Escape', async () => {
62
+ const { fixture } = await renderHost('+34634343343');
63
+ await fireEvent.click(countryButton());
64
+ await fixture.whenStable();
65
+ expect(countryButton().getAttribute('aria-expanded')).toBe('true');
66
+ expect(document.activeElement).toBe(search());
67
+ expect(screen.getByRole('listbox')).toBeDefined();
68
+ await fireEvent.input(search()!, { target: { value: 'fran' } });
69
+ expect(screen.getAllByRole('option').map((option) => option.id)).toContain('phone-country-FR');
70
+ await fireEvent.keyDown(search()!, { key: 'Escape' });
71
+ expect(search()).toBeNull();
72
+ expect(document.activeElement).toBe(countryButton());
73
+
74
+ await fireEvent.click(countryButton());
75
+ await fireEvent.input(search()!, { target: { value: '+33' } });
76
+ expect(search()!.getAttribute('aria-activedescendant')).toBe('phone-country-FR');
77
+ await fireEvent.keyDown(search()!, { key: 'Enter' });
78
+ expect(search()).toBeNull();
79
+ expect(countryButton().textContent).toContain('+33');
80
+ expect(changes[changes.length - 1]).toBe('+33634343343');
81
+ expect(document.activeElement).toBe(nationalInput());
82
+ });
83
+
84
+ it('mueve la opción activa con las flechas', async () => {
85
+ await renderHost('+34634343343');
86
+ await fireEvent.keyDown(countryButton(), { key: 'ArrowDown' });
87
+ expect(search()!.getAttribute('aria-activedescendant')).toBe('phone-country-ES');
88
+ const options = screen.getAllByRole('option');
89
+ const index = options.findIndex((o) => o.id === 'phone-country-ES');
90
+ await fireEvent.keyDown(search()!, { key: 'ArrowDown' });
91
+ expect(search()!.getAttribute('aria-activedescendant')).toBe(options[index + 1].id);
92
+ await fireEvent.keyDown(search()!, { key: 'ArrowUp' });
93
+ await fireEvent.keyDown(search()!, { key: 'ArrowUp' });
94
+ expect(search()!.getAttribute('aria-activedescendant')).toBe(options[index - 1].id);
95
+ expect(document.getElementById('phone-country-ES')!.getAttribute('aria-selected')).toBe('true');
96
+ });
97
+
98
+ it('avisa si no hay países y usa el idioma activo', async () => {
99
+ setLocale('es');
100
+ await renderHost('+4915112345678');
101
+ expect(countryButton().getAttribute('aria-label')).toBe('País: Alemania +49');
102
+ await fireEvent.click(countryButton());
103
+ expect(search()!.placeholder).toBe('Buscar país o prefijo');
104
+ await fireEvent.input(search()!, { target: { value: 'zzzz' } });
105
+ expect(screen.queryByRole('listbox')).toBeNull();
106
+ expect(screen.getByText('No hay países que coincidan')).toBeDefined();
107
+ });
108
+
109
+ it('cierra la lista al pulsar fuera y conserva el país al vaciar', async () => {
110
+ const { fixture } = await renderHost('+33612345678');
111
+ await fireEvent.click(countryButton());
112
+ await fireEvent.mouseDown(document.body);
113
+ fixture.detectChanges();
114
+ expect(search()).toBeNull();
115
+ await fireEvent.click(screen.getByText('reset'));
116
+ expect(nationalInput().value).toBe('');
117
+ expect(countryButton().textContent).toContain('+33');
118
+ });
119
+ });
@@ -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
+ }