@block-auth.io/angular 0.1.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 +37 -0
- package/.turbo/turbo-lint.log +4 -0
- package/.turbo/turbo-test.log +82 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +10 -0
- package/ng-package.json +6 -0
- package/package.json +50 -0
- package/scripts/css-to-ts.mjs +14 -0
- package/src/lib/__tests__/integration.test.ts +45 -0
- package/src/lib/block-auth.service.test.ts +69 -0
- package/src/lib/block-auth.service.ts +81 -0
- package/src/lib/components/btns/btn-base-provider.component.test.ts +22 -0
- package/src/lib/components/btns/btn-base-provider.component.ts +55 -0
- package/src/lib/components/btns/btn-block-auth.component.test.ts +79 -0
- package/src/lib/components/btns/btn-block-auth.component.ts +104 -0
- package/src/lib/components/btns/btn-gmail.component.test.ts +16 -0
- package/src/lib/components/btns/btn-gmail.component.ts +41 -0
- package/src/lib/components/btns/btn-google-base.component.test.ts +16 -0
- package/src/lib/components/btns/btn-google-base.component.ts +73 -0
- package/src/lib/components/btns/btn-metamask.component.test.ts +20 -0
- package/src/lib/components/btns/btn-metamask.component.ts +98 -0
- package/src/lib/components/btns/btn-microsoft.component.test.ts +16 -0
- package/src/lib/components/btns/btn-microsoft.component.ts +77 -0
- package/src/lib/components/btns/btn-phantom.component.test.ts +20 -0
- package/src/lib/components/btns/btn-phantom.component.ts +93 -0
- package/src/lib/components/btns/btn-signin.component.test.ts +55 -0
- package/src/lib/components/btns/btn-signin.component.ts +108 -0
- package/src/lib/components/btns/btn-wallet-connect.component.test.ts +20 -0
- package/src/lib/components/btns/btn-wallet-connect.component.ts +109 -0
- package/src/lib/components/btns/index.ts +11 -0
- package/src/lib/components/contents/content-signin.component.test.ts +33 -0
- package/src/lib/components/contents/content-signin.component.ts +68 -0
- package/src/lib/components/contents/content-signup.component.test.ts +78 -0
- package/src/lib/components/contents/content-signup.component.ts +225 -0
- package/src/lib/components/dds/change-account.component.test.ts +66 -0
- package/src/lib/components/dds/change-account.component.ts +112 -0
- package/src/lib/components/dds/dropdown-profile-btn.component.test.ts +66 -0
- package/src/lib/components/dds/dropdown-profile-btn.component.ts +185 -0
- package/src/lib/components/dds/dropdown-profile-item.component.test.ts +75 -0
- package/src/lib/components/dds/dropdown-profile-item.component.ts +74 -0
- package/src/lib/components/dds/dropdown-profile.component.test.ts +98 -0
- package/src/lib/components/dds/dropdown-profile.component.ts +258 -0
- package/src/lib/components/flow-block-auth.component.test.ts +93 -0
- package/src/lib/components/flow-block-auth.component.ts +226 -0
- package/src/lib/components/modals/modal-overlay.component.test.ts +81 -0
- package/src/lib/components/modals/modal-overlay.component.ts +43 -0
- package/src/lib/components/modals/modal-restoration-expired.component.test.ts +71 -0
- package/src/lib/components/modals/modal-restoration-expired.component.ts +83 -0
- package/src/lib/components/modals/modal-restore.component.test.ts +74 -0
- package/src/lib/components/modals/modal-restore.component.ts +104 -0
- package/src/lib/components/modals/modal-signin.component.test.ts +66 -0
- package/src/lib/components/modals/modal-signin.component.ts +44 -0
- package/src/lib/components/modals/modal-signup.component.test.ts +56 -0
- package/src/lib/components/modals/modal-signup.component.ts +40 -0
- package/src/lib/components/sections/footer-section.component.test.ts +19 -0
- package/src/lib/components/sections/footer-section.component.ts +49 -0
- package/src/lib/components/sections/internals/providers-buttons.component.test.ts +27 -0
- package/src/lib/components/sections/internals/providers-buttons.component.ts +290 -0
- package/src/lib/components/sections/internals/providers-filters.component.test.ts +25 -0
- package/src/lib/components/sections/internals/providers-filters.component.ts +139 -0
- package/src/lib/components/sections/internals/selected-button.component.test.ts +36 -0
- package/src/lib/components/sections/internals/selected-button.component.ts +92 -0
- package/src/lib/components/sections/login-section.component.test.ts +49 -0
- package/src/lib/components/sections/login-section.component.ts +120 -0
- package/src/lib/components/sections/providers-section.component.test.ts +22 -0
- package/src/lib/components/sections/providers-section.component.ts +187 -0
- package/src/lib/components/txns/internals/permissions-required-modal.component.test.ts +14 -0
- package/src/lib/components/txns/internals/permissions-required-modal.component.ts +35 -0
- package/src/lib/components/txns/internals/solicited-data-modal.component.test.ts +72 -0
- package/src/lib/components/txns/internals/solicited-data-modal.component.ts +187 -0
- package/src/lib/components/txns/internals/txn-error.component.test.ts +100 -0
- package/src/lib/components/txns/internals/txn-error.component.ts +121 -0
- package/src/lib/components/txns/internals/txn-type.component.test.ts +52 -0
- package/src/lib/components/txns/internals/txn-type.component.ts +51 -0
- package/src/lib/components/txns/internals/txn.component.test.ts +188 -0
- package/src/lib/components/txns/internals/txn.component.ts +298 -0
- package/src/lib/components/txns/txns-overview.component.test.ts +107 -0
- package/src/lib/components/txns/txns-overview.component.ts +229 -0
- package/src/lib/icons/ai-outline-disconnect.component.ts +21 -0
- package/src/lib/icons/bi-logo-gmail.component.ts +21 -0
- package/src/lib/icons/cg-profile.component.ts +31 -0
- package/src/lib/icons/check-icon.component.ts +27 -0
- package/src/lib/icons/ci-clock2.component.ts +31 -0
- package/src/lib/icons/fa-arrow-right.component.ts +21 -0
- package/src/lib/icons/fa-times.component.ts +21 -0
- package/src/lib/icons/fa-undo.component.ts +21 -0
- package/src/lib/icons/icons.test.ts +25 -0
- package/src/lib/icons/index.ts +22 -0
- package/src/lib/icons/io-ios-arrow-back.component.ts +21 -0
- package/src/lib/icons/io-ios-arrow-forward.component.ts +21 -0
- package/src/lib/icons/io-md-arrow-back.component.ts +19 -0
- package/src/lib/icons/io-md-arrow-forward.component.ts +19 -0
- package/src/lib/icons/pi-warning-fill.component.ts +21 -0
- package/src/lib/icons/tb-arrows-exchange2.component.ts +30 -0
- package/src/lib/icons/tb-copy-check-filled.component.ts +21 -0
- package/src/lib/icons/tb-copy.component.ts +34 -0
- package/src/lib/icons/ti-vendor-microsoft.component.ts +21 -0
- package/src/lib/inject-store.test.ts +26 -0
- package/src/lib/inject-store.ts +16 -0
- package/src/lib/primitives/arrow-icon.component.test.ts +12 -0
- package/src/lib/primitives/arrow-icon.component.ts +38 -0
- package/src/lib/primitives/btn-arrow-dir.component.test.ts +22 -0
- package/src/lib/primitives/btn-arrow-dir.component.ts +65 -0
- package/src/lib/primitives/btn-base.component.test.ts +38 -0
- package/src/lib/primitives/btn-base.component.ts +140 -0
- package/src/lib/primitives/divider-base.component.test.ts +12 -0
- package/src/lib/primitives/divider-base.component.ts +74 -0
- package/src/lib/primitives/dropdown-arrow.component.test.ts +12 -0
- package/src/lib/primitives/dropdown-arrow.component.ts +46 -0
- package/src/lib/primitives/dropdown-base.component.test.ts +49 -0
- package/src/lib/primitives/dropdown-base.component.ts +329 -0
- package/src/lib/primitives/helpers.ts +151 -0
- package/src/lib/primitives/horizontal-loader.component.test.ts +19 -0
- package/src/lib/primitives/horizontal-loader.component.ts +70 -0
- package/src/lib/primitives/icon-base.component.test.ts +19 -0
- package/src/lib/primitives/icon-base.component.ts +58 -0
- package/src/lib/primitives/img-loader.component.test.ts +12 -0
- package/src/lib/primitives/img-loader.component.ts +118 -0
- package/src/lib/primitives/index.ts +29 -0
- package/src/lib/primitives/input-base.component.test.ts +20 -0
- package/src/lib/primitives/input-base.component.ts +210 -0
- package/src/lib/primitives/lifetime-slider-base.component.test.ts +88 -0
- package/src/lib/primitives/lifetime-slider-base.component.ts +75 -0
- package/src/lib/primitives/oval-spinner.component.test.ts +9 -0
- package/src/lib/primitives/oval-spinner.component.ts +158 -0
- package/src/lib/primitives/pickers/dropdown-picker-animated-base.component.ts +220 -0
- package/src/lib/primitives/pickers/picker-button.component.ts +74 -0
- package/src/lib/primitives/pickers/time-picker-dropdown-selector.component.ts +188 -0
- package/src/lib/primitives/pickers/time-picker.component.ts +124 -0
- package/src/lib/primitives/pickers/time-unit-selector.component.ts +82 -0
- package/src/lib/primitives/progressbar.component.test.ts +18 -0
- package/src/lib/primitives/progressbar.component.ts +93 -0
- package/src/lib/primitives/providersStyles.ts +268 -0
- package/src/lib/primitives/slider-base.component.ts +56 -0
- package/src/lib/provide-block-auth.test.ts +43 -0
- package/src/lib/provide-block-auth.ts +43 -0
- package/src/lib/runtime/block-auth-runtime.directive.test.ts +89 -0
- package/src/lib/runtime/block-auth-runtime.directive.ts +68 -0
- package/src/lib/teleport-body.directive.test.ts +29 -0
- package/src/lib/teleport-body.directive.ts +21 -0
- package/src/lib/testing.ts +15 -0
- package/src/lib/transitions.css +23 -0
- package/src/public-api.ts +85 -0
- package/src/vitest.setup.ts +8 -0
- package/tailwind.config.cjs +6 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +9 -0
- package/vite.config.mts +44 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
ElementRef,
|
|
5
|
+
PLATFORM_ID,
|
|
6
|
+
computed,
|
|
7
|
+
effect,
|
|
8
|
+
inject,
|
|
9
|
+
input,
|
|
10
|
+
output,
|
|
11
|
+
viewChild,
|
|
12
|
+
} from '@angular/core';
|
|
13
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
14
|
+
import { DropdownArrowComponent } from './dropdown-arrow.component';
|
|
15
|
+
import { CheckIconComponent } from '../icons';
|
|
16
|
+
|
|
17
|
+
export interface DropdownItem {
|
|
18
|
+
name: string;
|
|
19
|
+
value: string;
|
|
20
|
+
icon_url?: string;
|
|
21
|
+
isOptionSelected?: boolean;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const defaultItems: DropdownItem[] = [
|
|
26
|
+
{ name: 'Select', value: 'service_default', icon_url: 'https://picsum.photos/200/300', isOptionSelected: true },
|
|
27
|
+
{ name: 'Block-Auth', value: 'blockauth', icon_url: 'https://dashboard.block-auth.io/img/blockauth_logo.png', isOptionSelected: false },
|
|
28
|
+
{ name: 'Auditech', value: 'auditech', icon_url: 'https://auditech.es/wp-content/uploads/2020/05/Auditech-Iso-Mobile.png', isOptionSelected: false },
|
|
29
|
+
{ name: 'Microsoft', value: 'microsoft', icon_url: 'https://avatars.githubusercontent.com/u/6154722?s=200&v=4', isOptionSelected: false },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// - `setIsOpen` (callback) -> emit `isOpenChange` (dos-vías idiomática Angular).
|
|
33
|
+
// - `onChangeSelectedItem` -> emit `changeSelectedItem`.
|
|
34
|
+
// - `callbackWhenClickOutside` -> emit `clickOutside`.
|
|
35
|
+
// - `containerWithActiveClickOutsideRef` (React ref) -> input `container?: HTMLElement | null`.
|
|
36
|
+
// Semántica 1:1 de DropdownBase.tsx (187-214): sin `container` no se añade
|
|
37
|
+
// listener (click-outside desactivado); con `container` el listener va sobre
|
|
38
|
+
// `container` (no `document`) solo cuando `isOpen`; "fuera" se calcula vs el
|
|
39
|
+
// contenedor propio del dropdown; al cerrar (o destruir) se quita, EXCEPTO si
|
|
40
|
+
// `isInsideModal` (leak deliberado del original). Se replica con `effect()`
|
|
41
|
+
// sobre `isOpen()` (equivalente al useEffect(...,[isOpen]) de react).
|
|
42
|
+
// - SSR: la lógica de click-outside solo corre en navegador (isPlatformBrowser).
|
|
43
|
+
// - Animación open/close: `bas-pop` (transitions.css). El CheckIcon del item
|
|
44
|
+
// seleccionado usa el icono compartido `ba-check-icon` (heroicons
|
|
45
|
+
// CheckIcon 20/solid, Task A6).
|
|
46
|
+
@Component({
|
|
47
|
+
selector: 'ba-dropdown-base',
|
|
48
|
+
standalone: true,
|
|
49
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
50
|
+
imports: [DropdownArrowComponent, CheckIconComponent],
|
|
51
|
+
template: `
|
|
52
|
+
<div
|
|
53
|
+
[class]="
|
|
54
|
+
'bas-box-border bas-group bas-relative bas-items-center ' + displayClassNames() + ' ' + classNames() + ' ' +
|
|
55
|
+
getHeight(showTitle(), size()) + ' ' + (showTitle() ? containerGridRows() : '')
|
|
56
|
+
"
|
|
57
|
+
[style.width]="width()"
|
|
58
|
+
[style.minWidth]="minWidth()"
|
|
59
|
+
[style.maxWidth]="maxWidth()"
|
|
60
|
+
>
|
|
61
|
+
@if (showTitle()) {
|
|
62
|
+
<p
|
|
63
|
+
[class]="
|
|
64
|
+
'bal-ba-transition-2 bas-m-0 bas-h-[18px] bas-text-sm ' +
|
|
65
|
+
(blueStyles()
|
|
66
|
+
? 'bas-text-[var(--bal-primary-700)] group-hover:bas-text-[var(--bal-primary-900)]'
|
|
67
|
+
: 'bas-text-gray-700 group-hover:bas-text-gray-900')
|
|
68
|
+
"
|
|
69
|
+
>{{ title() }}</p>
|
|
70
|
+
}
|
|
71
|
+
@if (showFloatingLabel()) {
|
|
72
|
+
<span
|
|
73
|
+
[class]="
|
|
74
|
+
'bal-ba-transition-2 bas-group bas-absolute -bas-top-3 bas-left-2 bas-z-50 bas-flex bas-items-center bas-justify-center bas-px-1 bas-py-0.5 ' +
|
|
75
|
+
floatingLabelBgColor() + ' ' +
|
|
76
|
+
(blueStyles()
|
|
77
|
+
? 'bas-text-[var(--bal-primary-700)] group-hover:bas-text-[var(--bal-primary-900)]'
|
|
78
|
+
: 'bas-text-gray-700 group-hover:bas-text-gray-900')
|
|
79
|
+
"
|
|
80
|
+
>
|
|
81
|
+
<p [class]="'bal-ba-transition-2 bas-m-0 ' + (size() !== 'xxs' ? 'bas-text-xs' : 'bas-text-xxs')">
|
|
82
|
+
{{ floatingLabelTxt() }}
|
|
83
|
+
</p>
|
|
84
|
+
</span>
|
|
85
|
+
}
|
|
86
|
+
<div #ddBaseContainer [class]="'bas-flex bas-w-full bas-flex-col bas-items-center bas-justify-start ' + getHeight(false, size())">
|
|
87
|
+
<button
|
|
88
|
+
[id]="btnId()"
|
|
89
|
+
[disabled]="disabled()"
|
|
90
|
+
[class]="
|
|
91
|
+
'bal-ba-transition-2 bal-ba-outline-none bas-relative bas-box-border bas-flex bas-w-full bas-cursor-pointer bas-flex-row bas-justify-between bas-gap-2 bas-border-2 bas-border-solid bas-bg-transparent bas-px-2 disabled:bas-cursor-not-allowed ' +
|
|
92
|
+
getBtnHeight(size()) + ' ' + borderRadiusClassNames() + ' ' +
|
|
93
|
+
(blueStyles() || isOpen()
|
|
94
|
+
? 'bas-border-[var(--bal-primary-100)] group-hover:bas-border-[var(--bal-primary-300)]'
|
|
95
|
+
: 'bas-border-gray-300 group-hover:bas-border-gray-400')
|
|
96
|
+
"
|
|
97
|
+
(click)="onBtnClicked($event)"
|
|
98
|
+
>
|
|
99
|
+
<div class="bas-group bas-flex bas-h-full bas-w-full bas-flex-row bas-items-center bas-gap-1.5">
|
|
100
|
+
@if (showStatusIndicator()) {
|
|
101
|
+
<span [class]="'bas-h-2 bas-w-2 bas-rounded-full ' + (!isSelectedDefault() ? 'bas-bg-green-500' : 'bas-bg-sky-300')"></span>
|
|
102
|
+
}
|
|
103
|
+
@if (showImg() && selectedItem().icon_url) {
|
|
104
|
+
<img [src]="selectedItem().icon_url" [alt]="'Image of ' + selectedItem()[titleKeyName()]" class="bas-h-5 bas-w-5 bas-flex-shrink-0" />
|
|
105
|
+
}
|
|
106
|
+
<p
|
|
107
|
+
[class]="
|
|
108
|
+
'bal-ba-transition-2 bas-m-0 ' + (size() !== 'xxs' ? 'bas-text-base' : 'bas-text-sm') + ' ' +
|
|
109
|
+
(isOpen() || blueStyles()
|
|
110
|
+
? (!isSelectedDefault()
|
|
111
|
+
? 'bas-text-[var(--bal-primary-800)] group-hover:bas-text-[var(--bal-primary-900)]'
|
|
112
|
+
: 'bas-text-[var(--bal-primary-500)] group-hover:bas-text-[var(--bal-primary-600)]')
|
|
113
|
+
: (!isSelectedDefault()
|
|
114
|
+
? 'bas-text-gray-800 group-hover:bas-text-gray-900'
|
|
115
|
+
: 'bas-text-gray-500 group-hover:bas-text-gray-600'))
|
|
116
|
+
"
|
|
117
|
+
>{{ selectedItem()[titleKeyName()] }}</p>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="bas-h-full bas-w-fit bas-justify-center">
|
|
120
|
+
<ba-dropdown-arrow
|
|
121
|
+
id="web-type-arrow"
|
|
122
|
+
[isOpen]="isOpen()"
|
|
123
|
+
sizeClassNames="bas-h-3 bas-w-3"
|
|
124
|
+
[color]="blueStyles() || isOpen() ? 'var(--bal-primary-700)' : 'gray'"
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
</button>
|
|
128
|
+
@if (isOpen()) {
|
|
129
|
+
<div
|
|
130
|
+
class="ba-pop bal-ba-transition-2 bal-ba-shadow-base bal-ba-hover-shadow-md bas-absolute bas-top-full bas-z-50 bas-mt-1 bas-w-full bas-overflow-hidden bas-rounded-md bas-border bas-border-solid bas-border-[var(--bal-primary-100)] bas-bg-white hover:bas-border-[var(--bal-primary-300)]"
|
|
131
|
+
[style.minHeight.px]="listMinHeight()"
|
|
132
|
+
>
|
|
133
|
+
<ul
|
|
134
|
+
[class]="
|
|
135
|
+
'bal-ba-transition-2 bal-ba-scroll-base bas-m-0 bas-box-border bas-flex bas-h-full bas-w-full bas-flex-col bas-items-center bas-justify-start bas-p-0 bas-text-base bas-text-[var(--bal-primary-700)] hover:bas-text-[var(--bal-primary-900)] bas-overflow-y-auto'
|
|
136
|
+
"
|
|
137
|
+
[style.maxHeight.px]="listMinHeight()"
|
|
138
|
+
>
|
|
139
|
+
@for (item of items(); track item[keyName()]; let index = $index) {
|
|
140
|
+
<li
|
|
141
|
+
[id]="'dropdown-base-item-' + item[keyName()]"
|
|
142
|
+
[class]="
|
|
143
|
+
'bal-ba-outline-none bas-group bas-box-border bas-flex bas-w-full bas-flex-row bas-items-center bas-justify-between bas-border-solid bas-border-[var(--bal-primary-90)] bas-px-2 bas-py-1 ' +
|
|
144
|
+
getItemBorderStyles(index, items().length) + ' ' +
|
|
145
|
+
(isItemDefault(item) ? 'bas-cursor-default' : 'bas-cursor-pointer') + ' ' +
|
|
146
|
+
(isSelected(item) ? 'bas-bg-[#e7f2f7] hover:bas-bg-[#dbecf3]' : 'hover:bas-bg-[#f3f8fb]') + ' ' +
|
|
147
|
+
(size() !== 'xxs' ? 'bas-min-h-[40px]' : 'bas-min-h-[34px]')
|
|
148
|
+
"
|
|
149
|
+
(click)="onItemClicked(item)"
|
|
150
|
+
>
|
|
151
|
+
<div class="bas-group bas-flex bas-h-full bas-w-fit bas-flex-row bas-items-center bas-gap-1.5">
|
|
152
|
+
@if (showStatusIndicator()) {
|
|
153
|
+
<span
|
|
154
|
+
[class]="
|
|
155
|
+
'bas-h-2 bas-w-2 bas-rounded-full ' +
|
|
156
|
+
(!isItemDefault(item) ? (isSelected(item) ? 'bas-bg-green-500' : 'bas-bg-slate-300') : 'bas-bg-sky-300')
|
|
157
|
+
"
|
|
158
|
+
></span>
|
|
159
|
+
}
|
|
160
|
+
@if (showImg() && item.icon_url) {
|
|
161
|
+
<img [src]="item.icon_url" [alt]="'Image of ' + item[titleKeyName()]" class="bas-h-5 bas-w-5 bas-flex-shrink-0" />
|
|
162
|
+
}
|
|
163
|
+
<p [class]="getOptionStyles(item, size())">{{ item[titleKeyName()] }}</p>
|
|
164
|
+
</div>
|
|
165
|
+
@if (showCheckWhenActive() && isSelected(item) && !isItemDefault(item)) {
|
|
166
|
+
<span class="bal-ba-transition-2 bas-flex bas-items-center bas-text-[#004e98] group-hover:bas-text-[#00296b]">
|
|
167
|
+
<ba-check-icon class="bas-h-4 bas-w-4" />
|
|
168
|
+
</span>
|
|
169
|
+
}
|
|
170
|
+
</li>
|
|
171
|
+
}
|
|
172
|
+
</ul>
|
|
173
|
+
</div>
|
|
174
|
+
}
|
|
175
|
+
</div>
|
|
176
|
+
@if (showTitle()) {
|
|
177
|
+
<p class="bal-ba-error-text-invisible bas-m-0 bas-h-[18px]">white space</p>
|
|
178
|
+
}
|
|
179
|
+
</div>
|
|
180
|
+
`,
|
|
181
|
+
styles: [
|
|
182
|
+
`
|
|
183
|
+
.ba-pop {
|
|
184
|
+
animation: ba-pop-in 0.15s ease;
|
|
185
|
+
transform-origin: top;
|
|
186
|
+
}
|
|
187
|
+
@keyframes ba-pop-in {
|
|
188
|
+
from { opacity: 0; transform: scale(0.95); }
|
|
189
|
+
to { opacity: 1; transform: scale(1); }
|
|
190
|
+
}
|
|
191
|
+
`,
|
|
192
|
+
],
|
|
193
|
+
})
|
|
194
|
+
export class DropdownBaseComponent {
|
|
195
|
+
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
196
|
+
private readonly ddBaseContainer = viewChild<ElementRef<HTMLDivElement>>('ddBaseContainer');
|
|
197
|
+
|
|
198
|
+
btnId = input('dropdown-base-button');
|
|
199
|
+
isOpen = input(false);
|
|
200
|
+
title = input('Select an item');
|
|
201
|
+
showTitle = input(false);
|
|
202
|
+
showFloatingLabel = input(false);
|
|
203
|
+
floatingLabelTxt = input('Select an item');
|
|
204
|
+
floatingLabelBgColor = input('bas-bg-white');
|
|
205
|
+
size = input<'xxs' | 'xs' | 'tiny' | 'small' | 'base' | 'large'>('base');
|
|
206
|
+
items = input<DropdownItem[]>(defaultItems);
|
|
207
|
+
selectedItem = input<DropdownItem>(defaultItems[0]);
|
|
208
|
+
keyName = input('value');
|
|
209
|
+
titleKeyName = input('name');
|
|
210
|
+
width = input<number | string>('100%');
|
|
211
|
+
minWidth = input<number | string>(0);
|
|
212
|
+
maxWidth = input<number | string>('100%');
|
|
213
|
+
dropdownHeight = input<number | null>(null);
|
|
214
|
+
classNames = input('');
|
|
215
|
+
displayClassNames = input('bas-grid');
|
|
216
|
+
borderRadiusClassNames = input('bas-rounded-lg');
|
|
217
|
+
container = input<HTMLElement | null>(null);
|
|
218
|
+
isInsideModal = input(false);
|
|
219
|
+
showStatusIndicator = input(true);
|
|
220
|
+
showImg = input(false);
|
|
221
|
+
showCheckWhenActive = input(true);
|
|
222
|
+
manageActiveWithIsOptionSelected = input(false);
|
|
223
|
+
blueStyles = input(false);
|
|
224
|
+
disabled = input(false);
|
|
225
|
+
|
|
226
|
+
isOpenChange = output<boolean>();
|
|
227
|
+
changeSelectedItem = output<DropdownItem>();
|
|
228
|
+
clickOutside = output<void>();
|
|
229
|
+
|
|
230
|
+
private readonly handleClickOutside = (e: Event): void => {
|
|
231
|
+
const el = this.ddBaseContainer()?.nativeElement;
|
|
232
|
+
if (!el) return;
|
|
233
|
+
if (!el.contains(e.target as Node)) this.clickOutside.emit();
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
constructor() {
|
|
237
|
+
// Port 1:1 del useEffect(...,[isOpen]) de DropdownBase.tsx (187-214),
|
|
238
|
+
// solo en navegador (SSR-safe): añade el listener sobre `container` al
|
|
239
|
+
// abrir; lo quita al cerrar/destruir salvo `isInsideModal`.
|
|
240
|
+
effect((onCleanup) => {
|
|
241
|
+
if (!this.isBrowser) return;
|
|
242
|
+
const container = this.container();
|
|
243
|
+
if (!container) return;
|
|
244
|
+
const open = this.isOpen();
|
|
245
|
+
const removeEventListeners = () => {
|
|
246
|
+
if (this.isInsideModal()) return;
|
|
247
|
+
container.removeEventListener('click', this.handleClickOutside);
|
|
248
|
+
};
|
|
249
|
+
if (open) container.addEventListener('click', this.handleClickOutside);
|
|
250
|
+
else removeEventListeners();
|
|
251
|
+
onCleanup(removeEventListeners);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
onBtnClicked(e: MouseEvent): void {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
this.isOpenChange.emit(!this.isOpen());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
onItemClicked(item: DropdownItem): void {
|
|
261
|
+
this.changeSelectedItem.emit(item);
|
|
262
|
+
this.isOpenChange.emit(false);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
isSelected = (item: DropdownItem): boolean =>
|
|
266
|
+
this.manageActiveWithIsOptionSelected()
|
|
267
|
+
? !!item.isOptionSelected
|
|
268
|
+
: this.selectedItem()[this.keyName()] === item[this.keyName()];
|
|
269
|
+
|
|
270
|
+
isSelectedDefault = computed(() => `${this.selectedItem()[this.keyName()]}`.includes('default'));
|
|
271
|
+
isItemDefault = (item: DropdownItem): boolean => `${item[this.keyName()]}`.includes('default');
|
|
272
|
+
|
|
273
|
+
listMinHeight = computed(
|
|
274
|
+
() => this.dropdownHeight() || this.items().length * (this.size() !== 'xxs' ? 40 : 34) + 2,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
containerGridRows = computed(
|
|
278
|
+
() =>
|
|
279
|
+
({
|
|
280
|
+
xxs: 'bas-grid-rows-[18px_34px_18px]',
|
|
281
|
+
xs: 'bas-grid-rows-[18px_36px_18px]',
|
|
282
|
+
tiny: 'bas-grid-rows-[18px_38px_18px]',
|
|
283
|
+
small: 'bas-grid-rows-[18px_42px_18px]',
|
|
284
|
+
base: 'bas-grid-rows-[18px_46px_18px]',
|
|
285
|
+
large: 'bas-grid-rows-[18px_54px_18px]',
|
|
286
|
+
})[this.size()],
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
getHeight(showTitle: boolean, size: string): string {
|
|
290
|
+
return {
|
|
291
|
+
xxs: showTitle ? 'bas-h-[70px]' : 'bas-h-[34px]',
|
|
292
|
+
xs: showTitle ? 'bas-h-[72px]' : 'bas-h-[36px]',
|
|
293
|
+
tiny: showTitle ? 'bas-h-[74px]' : 'bas-h-[38px]',
|
|
294
|
+
small: showTitle ? 'bas-h-[78px]' : 'bas-h-[42px]',
|
|
295
|
+
base: showTitle ? 'bas-h-[82px]' : 'bas-h-[46px]',
|
|
296
|
+
large: showTitle ? 'bas-h-[90px]' : 'bas-h-[54px]',
|
|
297
|
+
}[size]!;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
getBtnHeight(size: string): string {
|
|
301
|
+
return {
|
|
302
|
+
xxs: 'bas-min-h-[34px]',
|
|
303
|
+
xs: 'bas-min-h-[36px]',
|
|
304
|
+
tiny: 'bas-min-h-[38px]',
|
|
305
|
+
small: 'bas-min-h-[42px]',
|
|
306
|
+
base: 'bas-min-h-[46px]',
|
|
307
|
+
large: 'bas-min-h-[54px]',
|
|
308
|
+
}[size]!;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
getOptionStyles(item: DropdownItem, size: string): string {
|
|
312
|
+
const classes = 'bas-m-0 bas-block bal-ba-transition-2';
|
|
313
|
+
if (`${item[this.keyName()]}`.includes('default')) return `${classes} bas-text-[#004e98] bas-text-sm`;
|
|
314
|
+
return `${classes} ${size !== 'xxs' ? 'bas-text-base' : 'bas-text-sm'} ${
|
|
315
|
+
this.isSelected(item)
|
|
316
|
+
? 'bas-font-medium bas-text-[#00296b] group-hover:bas-text-[#002055]'
|
|
317
|
+
: 'bas-text-[#004e98] group-hover:bas-text-[#00296b]'
|
|
318
|
+
}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
getItemBorderStyles(index: number, itemsLength: number): string {
|
|
322
|
+
let classNames = '';
|
|
323
|
+
if (index < itemsLength - 1) classNames += ' bas-border-x-0 bas-border-b bas-border-t-0 ';
|
|
324
|
+
else classNames += ' bas-border-0 ';
|
|
325
|
+
if (index === 0) classNames += ' bas-rounded-t-lg ';
|
|
326
|
+
else if (index === itemsLength - 1) classNames += ' bas-rounded-b-lg ';
|
|
327
|
+
return classNames;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Port literal de packages/react/src/primitives/helpers.ts: helpers de UI
|
|
2
|
+
// puros en TS, sin dependencias de React ni de framer-motion, por lo que se
|
|
3
|
+
// copian tal cual (solo cambia el comentario de cabecera). Las variantes de
|
|
4
|
+
// framer-motion (`dropdownContainerVariantsFromTopRight`, `liItemVariants`,
|
|
5
|
+
// etc.) ya no las usa ningún primitivo Vue (DropdownBase anima con
|
|
6
|
+
// `<Transition>` + CSS), pero se conservan por si algún componente futuro
|
|
7
|
+
// las necesita como referencia/objeto de datos plano (no importan
|
|
8
|
+
// framer-motion, son solo objetos JS).
|
|
9
|
+
//
|
|
10
|
+
// `imgUrlByService` ya vive en `@block-auth.io/core` utils — se reexporta
|
|
11
|
+
// aquí para que los primitivos puedan importar todo desde un mismo sitio.
|
|
12
|
+
import { utils } from '@block-auth.io/core';
|
|
13
|
+
|
|
14
|
+
export const imgUrlByService = utils.imgUrlByService;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Variantes framer-motion usadas por el `DropdownBase` original (el dropdown
|
|
18
|
+
* se revelaba desde la esquina superior derecha vía un círculo de
|
|
19
|
+
* clip-path animado). Portado literalmente desde `helperUI.dropdownContainerVariantsFromTopRight`.
|
|
20
|
+
* No se usa en el `DropdownBase.vue` (que anima con `<Transition name="bas-pop">`),
|
|
21
|
+
* se conserva por paridad de API con el paquete react.
|
|
22
|
+
*/
|
|
23
|
+
export function dropdownContainerVariantsFromTopRight(
|
|
24
|
+
height = 1000,
|
|
25
|
+
left: number | string = 'auto',
|
|
26
|
+
) {
|
|
27
|
+
return {
|
|
28
|
+
open: {
|
|
29
|
+
clipPath: `circle(${height * 2 + 200}px at 100% 0%)`,
|
|
30
|
+
left,
|
|
31
|
+
transition: {
|
|
32
|
+
type: 'spring',
|
|
33
|
+
stiffness: 20,
|
|
34
|
+
restDelta: 2,
|
|
35
|
+
duration: 1,
|
|
36
|
+
delayChildren: 0.3,
|
|
37
|
+
staggerChildren: 0.05,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
closed: {
|
|
41
|
+
clipPath: 'circle(0px at 100% 0%)',
|
|
42
|
+
left,
|
|
43
|
+
transition: {
|
|
44
|
+
delay: 0,
|
|
45
|
+
type: 'spring',
|
|
46
|
+
stiffness: 400,
|
|
47
|
+
damping: 40,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Variantes de stagger por item de la lista del dropdown. Portado
|
|
55
|
+
* literalmente desde `helperUI.liItemVariants`. No usado en `DropdownBase.vue`.
|
|
56
|
+
*/
|
|
57
|
+
export function liItemVariants(openOpacity = 1) {
|
|
58
|
+
return {
|
|
59
|
+
open: {
|
|
60
|
+
opacity: openOpacity,
|
|
61
|
+
y: 0,
|
|
62
|
+
transition: { duration: 0.2 },
|
|
63
|
+
},
|
|
64
|
+
closed: { opacity: 0, y: 20, transition: { duration: 0.2 } },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ------------------------------------------------------------------ *
|
|
69
|
+
* Helpers de tiempo + variantes de dropdown de pickers — absorbidos del
|
|
70
|
+
* `helperUI` original. Los usan `pickers/` y `LifetimeSliderBase`, ambos
|
|
71
|
+
* fuera de alcance en esta tarea (excluidos por el plan), pero se conservan
|
|
72
|
+
* (son TS puro, sin coste) por si un futuro port de esos componentes los
|
|
73
|
+
* necesita.
|
|
74
|
+
* ------------------------------------------------------------------ */
|
|
75
|
+
|
|
76
|
+
/** Descompone segundos totales en `{ hours, minutes, seconds }`. */
|
|
77
|
+
export function convertSecondsToTime(totalSeconds: number) {
|
|
78
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
79
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
80
|
+
const seconds = totalSeconds % 60;
|
|
81
|
+
return { hours, minutes, seconds };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Rellena con cero un número menor a 10 (`5` → `"05"`). */
|
|
85
|
+
export function formatNumberToString(num: number) {
|
|
86
|
+
return num < 10 ? `0${num}` : `${num}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Parsea un string `HH:MM:SS` a segundos totales. */
|
|
90
|
+
export function getSecFromTimeValue(timeValue: string) {
|
|
91
|
+
const [h, m, s] = timeValue.split(':');
|
|
92
|
+
return parseInt(h) * 3600 + parseInt(m) * 60 + parseInt(s);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Segundos totales → `HH:MM:SS` (port de `getTimeBySec`). */
|
|
96
|
+
export function getTimeBySec(totalSeconds: number) {
|
|
97
|
+
const { hours, minutes, seconds } = convertSecondsToTime(totalSeconds);
|
|
98
|
+
return [hours, minutes, seconds].map(formatNumberToString).join(':');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Revelado del dropdown del picker (hacia abajo) — círculo de clip-path
|
|
103
|
+
* desde arriba al centro. Port literal de `dropdownContainerVariantsTopCenterCircle`.
|
|
104
|
+
*/
|
|
105
|
+
export function dropdownContainerVariantsTopCenterCircle(height = 1000) {
|
|
106
|
+
return {
|
|
107
|
+
open: {
|
|
108
|
+
clipPath: `circle(${height * 2 + 200}px at 50% 0%)`,
|
|
109
|
+
top: 0,
|
|
110
|
+
transition: {
|
|
111
|
+
type: 'spring',
|
|
112
|
+
stiffness: 20,
|
|
113
|
+
restDelta: 2,
|
|
114
|
+
duration: 1,
|
|
115
|
+
delayChildren: 0.3,
|
|
116
|
+
staggerChildren: 0.05,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
closed: {
|
|
120
|
+
clipPath: 'circle(0px at 50% 0%)',
|
|
121
|
+
top: '-100%',
|
|
122
|
+
transition: { delay: 0, type: 'spring', stiffness: 400, damping: 40 },
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Revelado del dropdown del picker (hacia arriba) — círculo de clip-path
|
|
129
|
+
* desde abajo al centro. Port literal de `dropdownContainerUpwardsVariants`.
|
|
130
|
+
*/
|
|
131
|
+
export function dropdownContainerUpwardsVariants(top = -288, height = 1000) {
|
|
132
|
+
return {
|
|
133
|
+
open: {
|
|
134
|
+
clipPath: `circle(${height * 2 + 200}px at 50% 100%)`,
|
|
135
|
+
top,
|
|
136
|
+
transition: {
|
|
137
|
+
type: 'tween',
|
|
138
|
+
stiffness: 20,
|
|
139
|
+
restDelta: 2,
|
|
140
|
+
duration: 2,
|
|
141
|
+
delayChildren: 0.3,
|
|
142
|
+
staggerChildren: 0.05,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
closed: {
|
|
146
|
+
clipPath: 'circle(0px at 50% 100%)',
|
|
147
|
+
top,
|
|
148
|
+
transition: { delay: 0, type: 'tween', stiffness: 400, damping: 40 },
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/angular';
|
|
3
|
+
import { HorizontalLoaderComponent } from './horizontal-loader.component';
|
|
4
|
+
describe('HorizontalLoaderComponent', () => {
|
|
5
|
+
it('renderiza un svg', async () => {
|
|
6
|
+
const { container } = await render(HorizontalLoaderComponent);
|
|
7
|
+
expect(container.querySelector('svg')).toBeTruthy();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Regresión: el host debe ser `display: contents` para que el <div> absoluto
|
|
11
|
+
// sea hijo directo del flex del botón (como en React). Sin esto el loader
|
|
12
|
+
// aparece descentrado dentro del botón en estado de carga.
|
|
13
|
+
it('el host es transparente al layout (display: contents)', async () => {
|
|
14
|
+
const { container } = await render(HorizontalLoaderComponent);
|
|
15
|
+
// testing-library monta el componente sobre su propio host (el `container`),
|
|
16
|
+
// donde Angular aplica el binding `host` del @Component.
|
|
17
|
+
expect(container.style.display).toBe('contents');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
// Sin react-loader-spinner:
|
|
4
|
+
// se dibujan 3 puntos SVG con una animación de opacidad escalonada
|
|
5
|
+
// (definida en `styles`), reproduciendo el look del `ThreeDots` original.
|
|
6
|
+
@Component({
|
|
7
|
+
selector: 'ba-horizontal-loader',
|
|
8
|
+
standalone: true,
|
|
9
|
+
// El host debe ser transparente al layout para que el <div> absoluto sea hijo
|
|
10
|
+
// directo del flex del botón (como en React, donde HorizontalLoader renderiza
|
|
11
|
+
// el <div> directamente). Sin esto el host `<ba-horizontal-loader>` es un
|
|
12
|
+
// flex-item de ancho ~0 colocado por `justify-center`, y la posición estática
|
|
13
|
+
// del <div> `bas-absolute` (sin left/top) se corre al centro; con `bas-w-full`
|
|
14
|
+
// se desborda a la derecha y el loader aparece descentrado.
|
|
15
|
+
host: { style: 'display: contents' },
|
|
16
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
17
|
+
template: `
|
|
18
|
+
<div
|
|
19
|
+
[class]="
|
|
20
|
+
'bal-ba-backdrop-blur-base bal-ba-backdrop-blur-sm bas-absolute bas-flex bas-h-full bas-w-full bas-items-center bas-justify-center ' +
|
|
21
|
+
classNames()
|
|
22
|
+
"
|
|
23
|
+
>
|
|
24
|
+
<svg
|
|
25
|
+
class="bas-three-dots"
|
|
26
|
+
[attr.height]="loaderHeight()"
|
|
27
|
+
[attr.width]="loaderWidth()"
|
|
28
|
+
viewBox="0 0 80 40"
|
|
29
|
+
role="img"
|
|
30
|
+
aria-label="three-dots-loading"
|
|
31
|
+
>
|
|
32
|
+
<circle class="bas-dot bas-dot-1" cx="15" cy="20" r="9" [attr.fill]="loaderColor()" />
|
|
33
|
+
<circle class="bas-dot bas-dot-2" cx="40" cy="20" r="9" [attr.fill]="loaderColor()" />
|
|
34
|
+
<circle class="bas-dot bas-dot-3" cx="65" cy="20" r="9" [attr.fill]="loaderColor()" />
|
|
35
|
+
</svg>
|
|
36
|
+
</div>
|
|
37
|
+
`,
|
|
38
|
+
styles: [
|
|
39
|
+
`
|
|
40
|
+
@keyframes bas-three-dots-fade {
|
|
41
|
+
0%,
|
|
42
|
+
80%,
|
|
43
|
+
100% {
|
|
44
|
+
opacity: 0.25;
|
|
45
|
+
}
|
|
46
|
+
40% {
|
|
47
|
+
opacity: 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
.bas-dot {
|
|
51
|
+
animation: bas-three-dots-fade 1.2s ease-in-out infinite;
|
|
52
|
+
}
|
|
53
|
+
.bas-dot-1 {
|
|
54
|
+
animation-delay: -0.32s;
|
|
55
|
+
}
|
|
56
|
+
.bas-dot-2 {
|
|
57
|
+
animation-delay: -0.16s;
|
|
58
|
+
}
|
|
59
|
+
.bas-dot-3 {
|
|
60
|
+
animation-delay: 0s;
|
|
61
|
+
}
|
|
62
|
+
`,
|
|
63
|
+
],
|
|
64
|
+
})
|
|
65
|
+
export class HorizontalLoaderComponent {
|
|
66
|
+
loaderColor = input('#61a5c2');
|
|
67
|
+
loaderHeight = input(40);
|
|
68
|
+
loaderWidth = input(80);
|
|
69
|
+
classNames = input('bas-rounded-md');
|
|
70
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Component } from '@angular/core';
|
|
3
|
+
import { render, screen } from '@testing-library/angular';
|
|
4
|
+
import { IconBaseComponent } from './icon-base.component';
|
|
5
|
+
|
|
6
|
+
describe('IconBaseComponent', () => {
|
|
7
|
+
it('renderiza el contenido proyectado cuando lo hay', async () => {
|
|
8
|
+
@Component({ standalone: true, imports: [IconBaseComponent], template: `<ba-icon-base><span data-testid="slot">x</span></ba-icon-base>` })
|
|
9
|
+
class Host {}
|
|
10
|
+
await render(Host);
|
|
11
|
+
expect(screen.getByTestId('slot')).toBeTruthy();
|
|
12
|
+
});
|
|
13
|
+
it('renderiza un <img> desde iconUrl cuando no hay contenido proyectado', async () => {
|
|
14
|
+
@Component({ standalone: true, imports: [IconBaseComponent], template: `<ba-icon-base iconUrl="https://x/y.png" />` })
|
|
15
|
+
class Host {}
|
|
16
|
+
const { container } = await render(Host);
|
|
17
|
+
expect(container.querySelector('img')?.getAttribute('src')).toBe('https://x/y.png');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AfterViewChecked,
|
|
3
|
+
ChangeDetectionStrategy,
|
|
4
|
+
Component,
|
|
5
|
+
ElementRef,
|
|
6
|
+
input,
|
|
7
|
+
signal,
|
|
8
|
+
viewChild,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
|
|
11
|
+
// Renderiza el contenido proyectado
|
|
12
|
+
// (equivalente al slot `icon` de Vue / ReactNode `icon` de React) si hay
|
|
13
|
+
// contenido real, si no un <img> desde `iconUrl`, si no nada.
|
|
14
|
+
//
|
|
15
|
+
// Vue resuelve esto con `useSlots().default({}).length > 0` (comprobación en
|
|
16
|
+
// tiempo de render de si el slot forwardeado produjo vnodes reales). Angular
|
|
17
|
+
// no tiene un equivalente directo para <ng-content>, así que se envuelve la
|
|
18
|
+
// proyección en un <span #iconSlot> propio del template (no del content) y
|
|
19
|
+
// se comprueba en cada ciclo de content-check si ese wrapper terminó con
|
|
20
|
+
// nodos hijos reales — mismo resultado observable (fallback a `iconUrl` solo
|
|
21
|
+
// cuando no se proyectó nada).
|
|
22
|
+
@Component({
|
|
23
|
+
selector: 'ba-icon-base',
|
|
24
|
+
standalone: true,
|
|
25
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
26
|
+
template: `
|
|
27
|
+
<span #iconSlot style="display: contents"><ng-content /></span>
|
|
28
|
+
@if (!hasIconContent() && iconUrl()) {
|
|
29
|
+
<img
|
|
30
|
+
[src]="iconUrl()"
|
|
31
|
+
[width]="iconWidth()"
|
|
32
|
+
[height]="iconHeight()"
|
|
33
|
+
[class]="'bas-flex-shrink-0 bas-object-contain ' + iconClassNames()"
|
|
34
|
+
[style.width.px]="iconWidth()"
|
|
35
|
+
[style.height.px]="iconHeight()"
|
|
36
|
+
alt=""
|
|
37
|
+
/>
|
|
38
|
+
}
|
|
39
|
+
`,
|
|
40
|
+
})
|
|
41
|
+
export class IconBaseComponent implements AfterViewChecked {
|
|
42
|
+
iconUrl = input<string | undefined>(undefined);
|
|
43
|
+
iconWidth = input(24);
|
|
44
|
+
iconHeight = input(24);
|
|
45
|
+
iconClassNames = input('bas-rounded-full');
|
|
46
|
+
|
|
47
|
+
// Signal query (no `@ViewChild` — los decoradores de campo clásicos no se
|
|
48
|
+
// soportan en el modo JIT del harness de test AnalogJS/Vitest).
|
|
49
|
+
private readonly iconSlot = viewChild<ElementRef<HTMLElement>>('iconSlot');
|
|
50
|
+
|
|
51
|
+
hasIconContent = signal(false);
|
|
52
|
+
|
|
53
|
+
ngAfterViewChecked(): void {
|
|
54
|
+
const el = this.iconSlot()?.nativeElement ?? null;
|
|
55
|
+
const hasContent = !!el && el.childNodes.length > 0;
|
|
56
|
+
if (hasContent !== this.hasIconContent()) this.hasIconContent.set(hasContent);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Component } from '@angular/core';
|
|
3
|
+
import { render } from '@testing-library/angular';
|
|
4
|
+
import { ImgLoaderComponent } from './img-loader.component';
|
|
5
|
+
describe('ImgLoaderComponent', () => {
|
|
6
|
+
it('renderiza la imagen con el imgSrc dado', async () => {
|
|
7
|
+
@Component({ standalone: true, imports: [ImgLoaderComponent], template: `<ba-img-loader imgSrc="https://x/y.png" imgAlt="a" />` })
|
|
8
|
+
class Host {}
|
|
9
|
+
const { container } = await render(Host);
|
|
10
|
+
expect(container.querySelector('img')?.getAttribute('src')).toBe('https://x/y.png');
|
|
11
|
+
});
|
|
12
|
+
});
|