@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.
- package/.turbo/turbo-build.log +40 -37
- package/.turbo/turbo-lint.log +4 -4
- package/.turbo/turbo-test.log +102 -1241
- package/.turbo/turbo-typecheck.log +19 -19
- package/CHANGELOG.md +120 -0
- package/package.json +3 -3
- package/src/lib/components/txns/internals/permissions-required-modal.component.test.ts +9 -0
- package/src/lib/components/txns/internals/permissions-required-modal.component.ts +3 -3
- package/src/lib/components/txns/internals/phone-input.component.test.ts +119 -0
- package/src/lib/components/txns/internals/phone-input.component.ts +301 -0
- package/src/lib/components/txns/internals/solicited-data-modal.component.test.ts +561 -4
- package/src/lib/components/txns/internals/solicited-data-modal.component.ts +324 -129
- package/src/lib/components/txns/internals/txn-type.component.ts +2 -2
- package/src/lib/components/txns/internals/txn.component.test.ts +345 -0
- package/src/lib/components/txns/internals/txn.component.ts +157 -74
- package/src/lib/components/txns/txns-overview.component.test.ts +270 -2
- package/src/lib/components/txns/txns-overview.component.ts +153 -23
- package/src/lib/primitives/arrow-icon.component.test.ts +13 -0
- package/src/lib/primitives/arrow-icon.component.ts +8 -1
- package/src/public-api.ts +1 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
> @block-auth.io/angular@0.
|
|
3
|
-
> pnpm build:css
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
> @block-auth.io/angular@0.
|
|
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
|
|
16
|
-
|
|
17
|
-
> @block-auth.io/angular@0.
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"dependencies": {
|
|
5
5
|
"nanostores": "^0.10.0",
|
|
6
6
|
"tslib": "^2.6.0",
|
|
7
|
-
"@block-auth.io/
|
|
8
|
-
"@block-auth.io/
|
|
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 = '
|
|
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-
|
|
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="
|
|
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
|
+
}
|