diff --git a/package-lock.json b/package-lock.json index 06703a7..26ebac2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0", "@angular/ssr": "^22.0.4", + "@fortawesome/fontawesome-free": "^7.2.0", "bootstrap": "^5.3.8", "express": "^5.1.0", "rxjs": "~7.8.0", @@ -1616,6 +1617,15 @@ } } }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz", + "integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, "node_modules/@gar/promise-retry": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", diff --git a/package.json b/package.json index 0d0eba6..7b41eb7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0", "@angular/ssr": "^22.0.4", + "@fortawesome/fontawesome-free": "^7.2.0", "bootstrap": "^5.3.8", "express": "^5.1.0", "rxjs": "~7.8.0", diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index 584fe66..7d68cfd 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -1,10 +1,10 @@
-
+
Componentes reutilizables -

Button

+

Button + Input

- Pagina dedicada a probar variantes del componente compartido. + Pagina dedicada a probar variantes de los componentes compartidos.

@@ -14,6 +14,122 @@ Danger secondary Cancel
+ +
+ +
+ + Texto + + + + Numero + + + + Email + + + + Password + + + + Fecha + + + + Periodo + + + + Moneda + + + + Porcentaje + + + + Deshabilitado + + + + Editable + + + + Invalido + + +
+ + Archivo + + + Limpiar archivo +
+
diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss index fd8aad0..b3496c0 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss @@ -10,8 +10,8 @@ padding: 1.5rem; } -.button-demo { - width: min(100%, 48rem); +.component-demo { + width: min(100%, 64rem); border: 0; } @@ -34,3 +34,30 @@ h1 { flex-wrap: wrap; gap: 0.75rem; } + +.section-divider { + margin: 2rem 0; +} + +.input-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.file-field { + display: grid; + gap: 0.75rem; +} + +@media (max-width: 991.98px) { + .input-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 767.98px) { + .input-grid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index c9bf901..cbcab10 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -1,10 +1,35 @@ import { Component } from '@angular/core'; import { ButtonComponent } from '../../../../shared/components/button/button.component'; +import { InputComponent } from '../../../../shared/components/input/input.component'; @Component({ selector: 'app-reutilizables-test-page', - imports: [ButtonComponent], + imports: [ButtonComponent, InputComponent], templateUrl: './reutilizables-test-page.component.html', styleUrl: './reutilizables-test-page.component.scss' }) -export class ReutilizablesTestPageComponent {} +export class ReutilizablesTestPageComponent { + protected textValue = 'Auriculares'; + protected numberValue = '24'; + protected email = 'usuario@empresa.com'; + protected password = '12345678'; + protected date = '2026-06-24'; + protected amount = '125000'; + protected percent = '12'; + protected period = '2026-06'; + protected editableValue = 'Solo lectura al cargar'; + protected disabledValue = 'No editable'; + protected invalidEmail = 'correo-invalido'; + protected fileName = ''; + protected clearTrigger = 0; + protected editableDisabled = true; + + protected onFileChange(file: File | null): void { + this.fileName = file?.name ?? ''; + } + + protected clearFile(): void { + this.fileName = ''; + this.clearTrigger += 1; + } +} diff --git a/src/app/shared/components/input/input.component.html b/src/app/shared/components/input/input.component.html new file mode 100644 index 0000000..26aba06 --- /dev/null +++ b/src/app/shared/components/input/input.component.html @@ -0,0 +1,56 @@ + + +
+ @if (type() === 'currency') { + $ + } + + + + @if (type() === 'file') { + + {{ resolvedFileName() || 'Sin archivo...' }} + + } + + @if (type() === 'percent') { + % + } + + @if (type() === 'editable') { + + } +
diff --git a/src/app/shared/components/input/input.component.scss b/src/app/shared/components/input/input.component.scss new file mode 100644 index 0000000..ad3cdfc --- /dev/null +++ b/src/app/shared/components/input/input.component.scss @@ -0,0 +1,140 @@ +:host { + display: block; + margin-bottom: 0 !important; + padding-bottom: 0 !important; +} + +.input-label { + display: block; + margin-bottom: 0.375rem; + font-weight: 500; +} + +.input-control { + position: relative; +} + +.form-control { + min-height: 40px; + padding: 0.625rem 1rem; + border-radius: 0.5rem; + color: #212529; + + &:focus { + box-shadow: none; + } + + &:disabled { + opacity: 1; + } +} + +input[type='file'].form-control { + padding-right: 1rem; + color: transparent; + + &::file-selector-button { + height: 2.25rem; + margin-right: 1rem; + padding: 0 1rem; + border: 0; + border-radius: 0.375rem; + background-color: var(--bs-secondary-bg); + color: #212529; + cursor: pointer; + } +} + +.input-control-file { + .form-control { + position: relative; + } +} + +.input-file-name { + position: absolute; + top: 50%; + left: 11.5rem; + right: 1rem; + color: #495057; + font-size: 0.875rem; + line-height: 1.4; + transform: translateY(-50%); + pointer-events: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.input-file-name-empty { + color: #6c757d; +} + +.input-control-currency { + .input-prefix { + position: absolute; + left: 1rem; + top: 50%; + z-index: 1; + color: #495057; + transform: translateY(-50%); + pointer-events: none; + } + + .input-with-prefix { + padding-left: 2rem; + } +} + +.input-control-editable { + .input-icon { + position: absolute; + right: 0.875rem; + top: 50%; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + border: 0; + background: transparent; + color: #495057; + transform: translateY(-50%); + cursor: pointer; + transition: color 0.2s ease; + + &:focus { + outline: none; + } + } + + .input-icon-active { + color: #adb5bd; + + &:hover { + color: #495057; + } + } + + .input-with-icon { + padding-right: 2.5rem; + } +} + +.input-control-percent { + .input-suffix { + position: absolute; + right: 1rem; + top: 50%; + z-index: 1; + color: #495057; + transform: translateY(-50%); + pointer-events: none; + } + + .input-with-suffix { + padding-right: 2rem; + } +} diff --git a/src/app/shared/components/input/input.component.ts b/src/app/shared/components/input/input.component.ts new file mode 100644 index 0000000..de927f7 --- /dev/null +++ b/src/app/shared/components/input/input.component.ts @@ -0,0 +1,167 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + computed, + effect, + input, + output, + signal, + untracked, + viewChild +} from '@angular/core'; +import { NgClass } from '@angular/common'; + +export type AppInputType = + | 'text' + | 'number' + | 'email' + | 'password' + | 'currency' + | 'percent' + | 'date' + | 'period' + | 'editable' + | 'file'; + +const DEFAULT_TYPE: AppInputType = 'text'; +const INPUT_TYPES = new Set([ + 'text', + 'number', + 'email', + 'password', + 'currency', + 'percent', + 'date', + 'period', + 'editable', + 'file' +]); + +function normalizeType(value: AppInputType | string | undefined): AppInputType { + return value && INPUT_TYPES.has(value as AppInputType) + ? (value as AppInputType) + : DEFAULT_TYPE; +} + +@Component({ + selector: 'app-input', + imports: [NgClass], + templateUrl: './input.component.html', + styleUrl: './input.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'form-group' + } +}) +export class InputComponent { + private readonly inputElement = viewChild>('inputElement'); + private readonly selectedFileName = signal(''); + private readonly editableDisabled = signal(false); + private lastClearTrigger = 0; + + readonly invalid = input(false); + readonly placeholder = input(''); + readonly value = input(''); + readonly maxlength = input(null); + readonly accept = input(''); + readonly fileName = input(''); + readonly clearTrigger = input(0); + readonly disabled = input(false); + readonly type = input(DEFAULT_TYPE, { + transform: normalizeType + }); + + readonly disabledChange = output(); + readonly valueChange = output(); + readonly fileChange = output(); + + protected readonly id = `app-input-${globalThis.crypto.randomUUID()}`; + + protected readonly inputType = computed(() => { + const type = this.type(); + + if (type === 'currency' || type === 'percent' || type === 'editable') { + return 'text'; + } + + if (type === 'period') { + return 'month'; + } + + return type; + }); + + protected readonly inputClasses = computed(() => ({ + 'form-control': true, + 'is-invalid': this.invalid(), + 'input-with-prefix': this.type() === 'currency', + 'input-with-suffix': this.type() === 'percent', + 'input-with-icon': this.type() === 'editable' + })); + + protected readonly isInputDisabled = computed(() => + this.type() === 'editable' ? this.editableDisabled() : this.disabled() + ); + + protected readonly isEditableActive = computed( + () => this.type() === 'editable' && !this.editableDisabled() + ); + + protected readonly resolvedFileName = computed( + () => this.selectedFileName() || this.fileName() || '' + ); + + constructor() { + effect(() => { + this.editableDisabled.set(this.disabled()); + }); + + effect(() => { + if (this.type() !== 'file') { + return; + } + + const clearTrigger = this.clearTrigger(); + const value = this.value(); + const fileName = this.fileName(); + + untracked(() => { + if (clearTrigger !== this.lastClearTrigger) { + this.lastClearTrigger = clearTrigger; + this.clearFileInput(); + return; + } + + if (!value && !fileName) { + this.clearFileInput(); + } + }); + }); + } + + protected onValueChange(event: Event): void { + this.valueChange.emit((event.target as HTMLInputElement).value); + } + + protected onFileChange(event: Event): void { + const file = (event.target as HTMLInputElement).files?.[0] ?? null; + this.selectedFileName.set(file?.name ?? ''); + this.fileChange.emit(file); + } + + protected toggleEditableState(): void { + if (this.type() !== 'editable') { + return; + } + + const nextState = !this.editableDisabled(); + this.editableDisabled.set(nextState); + this.disabledChange.emit(nextState); + } + + private clearFileInput(): void { + this.inputElement()?.nativeElement && (this.inputElement()!.nativeElement.value = ''); + this.selectedFileName.set(''); + } +} diff --git a/src/styles.scss b/src/styles.scss index 9807e99..ab40d7b 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1 +1,2 @@ +@use "@fortawesome/fontawesome-free/css/all.min.css"; @use "bootstrap/scss/bootstrap";