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 @@
-
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";