@keepui/ui 0.2.0 → 0.4.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.
Files changed (36) hide show
  1. package/README.md +299 -131
  2. package/fesm2022/keepui-ui.mjs +1240 -13
  3. package/fesm2022/keepui-ui.mjs.map +1 -1
  4. package/lib/components/button/button.component.d.ts.map +1 -1
  5. package/lib/components/card/card.component.d.ts +1 -3
  6. package/lib/components/card/card.component.d.ts.map +1 -1
  7. package/lib/components/card/card.types.d.ts +4 -0
  8. package/lib/components/card/card.types.d.ts.map +1 -0
  9. package/lib/components/icon/icon.component.d.ts +39 -0
  10. package/lib/components/icon/icon.component.d.ts.map +1 -0
  11. package/lib/components/icon-action-button/icon-action-button.component.d.ts +52 -0
  12. package/lib/components/icon-action-button/icon-action-button.component.d.ts.map +1 -0
  13. package/lib/components/icon-action-button/icon-action-button.types.d.ts +3 -0
  14. package/lib/components/icon-action-button/icon-action-button.types.d.ts.map +1 -0
  15. package/lib/components/signal-dropdown/signal-dropdown.component.d.ts +91 -0
  16. package/lib/components/signal-dropdown/signal-dropdown.component.d.ts.map +1 -0
  17. package/lib/components/signal-dropdown/signal-dropdown.types.d.ts +12 -0
  18. package/lib/components/signal-dropdown/signal-dropdown.types.d.ts.map +1 -0
  19. package/lib/components/signal-text-input/signal-text-input.component.d.ts +101 -0
  20. package/lib/components/signal-text-input/signal-text-input.component.d.ts.map +1 -0
  21. package/lib/components/signal-text-input/signal-text-input.types.d.ts +8 -0
  22. package/lib/components/signal-text-input/signal-text-input.types.d.ts.map +1 -0
  23. package/lib/components/signal-textarea/signal-textarea.component.d.ts +70 -0
  24. package/lib/components/signal-textarea/signal-textarea.component.d.ts.map +1 -0
  25. package/lib/components/signal-textarea/signal-textarea.types.d.ts +11 -0
  26. package/lib/components/signal-textarea/signal-textarea.types.d.ts.map +1 -0
  27. package/lib/i18n/keep-ui-translations.d.ts +4 -0
  28. package/lib/i18n/keep-ui-translations.d.ts.map +1 -1
  29. package/lib/i18n/translation-keys.d.ts +4 -0
  30. package/lib/i18n/translation-keys.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/public-api.d.ts +10 -0
  33. package/public-api.d.ts.map +1 -1
  34. package/styles/index.css +11 -1
  35. package/styles/prebuilt.css +1 -1
  36. package/styles/themes.css +27 -0
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, Injectable, input, output, computed, ChangeDetectionStrategy, Component, inject, signal, makeEnvironmentProviders } from '@angular/core';
2
+ import { InjectionToken, Injectable, input, output, computed, ChangeDetectionStrategy, Component, inject, signal, DestroyRef, viewChild, model, effect, makeEnvironmentProviders } from '@angular/core';
3
3
  import { TranslocoPipe, TRANSLOCO_SCOPE, TranslocoService, provideTransloco } from '@jsverse/transloco';
4
4
  import { of } from 'rxjs';
5
5
 
@@ -137,25 +137,24 @@ class ButtonComponent {
137
137
  };
138
138
  const variantMap = {
139
139
  primary: [
140
- 'bg-ku-primary text-ku-primary-text border border-ku-primary-border',
141
- 'enabled:hover:bg-ku-primary-hover enabled:hover:border-ku-primary-border',
142
- 'enabled:active:bg-ku-primary-hover enabled:active:border-ku-primary-border',
140
+ 'bg-ku-action-primary text-white border border-ku-action-primary',
141
+ 'enabled:hover:opacity-90',
142
+ 'enabled:active:opacity-80',
143
143
  ].join(' '),
144
144
  secondary: [
145
- 'bg-ku-secondary text-ku-secondary-text border border-ku-secondary-border',
146
- 'enabled:hover:bg-ku-secondary-hover enabled:hover:border-ku-gray-border',
147
- 'disabled:text-ku-gray-text',
145
+ 'bg-ku-primary text-ku-action-primary border border-ku-action-primary',
146
+ 'enabled:hover:bg-ku-action-background transition-colors',
148
147
  ].join(' '),
149
148
  outline: [
150
- 'bg-transparent border border-ku-secondary-border text-ku-secondary-text',
151
- 'enabled:hover:border-ku-primary-border enabled:hover:text-ku-brand-text',
149
+ 'bg-ku-primary border border-ku-secondary-border text-ku-gray-text',
150
+ 'enabled:hover:text-ku-action-primary enabled:hover:border-ku-action-primary transition-colors',
152
151
  ].join(' '),
153
152
  ghost: [
154
- 'bg-transparent border border-ku-primary-border text-ku-brand-text',
155
- 'enabled:hover:bg-ku-primary/10',
153
+ 'bg-transparent border border-ku-action-primary text-ku-action-primary',
154
+ 'enabled:hover:bg-ku-action-background',
156
155
  ].join(' '),
157
156
  danger: [
158
- 'bg-ku-red-bg text-ku-red-text border border-ku-red-border',
157
+ 'bg-ku-error-primary text-white border border-ku-error-primary',
159
158
  'enabled:hover:opacity-90',
160
159
  'enabled:active:opacity-80',
161
160
  ].join(' '),
@@ -349,6 +348,189 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImpo
349
348
  }]
350
349
  }] });
351
350
 
351
+ /**
352
+ * Generic SVG sprite icon renderer.
353
+ *
354
+ * Renders a `<use href="#name">` reference pointing to an SVG symbol pre-registered
355
+ * in the DOM by the consuming application (e.g. via `IconRegistryService`).
356
+ *
357
+ * Accessibility:
358
+ * - When used decoratively (default), the SVG carries `aria-hidden="true"` automatically.
359
+ * - When used as a standalone meaningful icon, supply an `ariaLabel` — the SVG will
360
+ * receive `role="img"` and `aria-label` accordingly.
361
+ *
362
+ * ```html
363
+ * <!-- Decorative — hidden from screen readers -->
364
+ * <keepui-icon name="check-icon" [size]="20" aria-hidden="true" />
365
+ *
366
+ * <!-- Meaningful standalone icon -->
367
+ * <keepui-icon name="close-icon" ariaLabel="Cerrar" />
368
+ * ```
369
+ */
370
+ class IconComponent {
371
+ constructor() {
372
+ /** ID of the SVG symbol to render (without the `#` prefix). */
373
+ this.name = input.required();
374
+ /** Width and height of the icon in pixels. @default 24 */
375
+ this.size = input(24);
376
+ /** `viewBox` attribute forwarded to the `<svg>` element. @default '0 0 24 24' */
377
+ this.viewBox = input('0 0 24 24');
378
+ /**
379
+ * Accessible label for standalone icons.
380
+ * When provided, sets `role="img"` and `aria-label` on the SVG.
381
+ * When omitted, the SVG is marked `aria-hidden="true"` (decorative).
382
+ * @default ''
383
+ */
384
+ this.ariaLabel = input('');
385
+ this.href = computed(() => `#${this.name()}`);
386
+ }
387
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: IconComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
388
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.20", type: IconComponent, isStandalone: true, selector: "keepui-icon", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: true, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, viewBox: { classPropertyName: "viewBox", publicName: "viewBox", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "inline-flex items-center justify-center" }, ngImport: i0, template: `
389
+ <svg
390
+ class="block"
391
+ [attr.width]="size()"
392
+ [attr.height]="size()"
393
+ [attr.viewBox]="viewBox()"
394
+ [attr.aria-hidden]="ariaLabel() ? null : true"
395
+ [attr.aria-label]="ariaLabel() || null"
396
+ [attr.role]="ariaLabel() ? 'img' : null"
397
+ >
398
+ <use [attr.href]="href()" />
399
+ </svg>
400
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
401
+ }
402
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: IconComponent, decorators: [{
403
+ type: Component,
404
+ args: [{
405
+ selector: 'keepui-icon',
406
+ standalone: true,
407
+ changeDetection: ChangeDetectionStrategy.OnPush,
408
+ host: {
409
+ class: 'inline-flex items-center justify-center',
410
+ },
411
+ template: `
412
+ <svg
413
+ class="block"
414
+ [attr.width]="size()"
415
+ [attr.height]="size()"
416
+ [attr.viewBox]="viewBox()"
417
+ [attr.aria-hidden]="ariaLabel() ? null : true"
418
+ [attr.aria-label]="ariaLabel() || null"
419
+ [attr.role]="ariaLabel() ? 'img' : null"
420
+ >
421
+ <use [attr.href]="href()" />
422
+ </svg>
423
+ `,
424
+ }]
425
+ }] });
426
+
427
+ /**
428
+ * Circular icon-only action button with `default` and `danger` variants,
429
+ * loading state, and full accessibility support.
430
+ *
431
+ * Because this button renders no visible text, `ariaLabel` is **required** to
432
+ * satisfy WCAG 2.1 SC 4.1.2 (Name, Role, Value).
433
+ *
434
+ * The icon color is inherited automatically via `currentColor` from the button's
435
+ * text color, so SVG symbols defined with `stroke="currentColor"` will adapt to
436
+ * both variants and themes without extra classes.
437
+ *
438
+ * ```html
439
+ * <keepui-icon-action-button icon="edit-icon" ariaLabel="Editar" />
440
+ *
441
+ * <keepui-icon-action-button
442
+ * icon="trash-icon"
443
+ * variant="danger"
444
+ * ariaLabel="Eliminar elemento"
445
+ * [loading]="isDeleting()"
446
+ * />
447
+ * ```
448
+ */
449
+ class IconActionButtonComponent {
450
+ constructor() {
451
+ /** ID of the SVG symbol to render (without the `#` prefix). */
452
+ this.icon = input.required();
453
+ /**
454
+ * Accessible label for the button. Always required — icon-only buttons must
455
+ * have a programmatic name for screen readers (WCAG 2.1 SC 4.1.2).
456
+ */
457
+ this.ariaLabel = input.required();
458
+ /** Visual style variant. @default 'default' */
459
+ this.variant = input('default');
460
+ /** Size of the inner icon in pixels. @default 20 */
461
+ this.iconSize = input(20);
462
+ /** HTML `type` attribute of the inner `<button>`. @default 'button' */
463
+ this.type = input('button');
464
+ /** Disables the button when `true`. @default false */
465
+ this.disabled = input(false);
466
+ /**
467
+ * Shows an animated spinner and disables the button.
468
+ * Also sets `aria-busy="true"` on the element.
469
+ * @default false
470
+ */
471
+ this.loading = input(false);
472
+ this.isDisabled = computed(() => this.disabled() || this.loading());
473
+ this.buttonClass = computed(() => {
474
+ const base = [
475
+ 'inline-flex items-center justify-center shrink-0 cursor-pointer',
476
+ 'size-11 rounded-full border transition-colors',
477
+ 'focus-visible:outline-none focus-visible:ring-2',
478
+ 'focus-visible:ring-ku-primary-border focus-visible:ring-offset-2',
479
+ 'disabled:cursor-not-allowed disabled:opacity-50',
480
+ ].join(' ');
481
+ const variantMap = {
482
+ default: [
483
+ 'border-ku-secondary-border bg-ku-primary text-ku-gray-text',
484
+ 'enabled:hover:bg-ku-action-background enabled:hover:border-ku-action-primary',
485
+ 'enabled:hover:text-ku-action-primary',
486
+ ].join(' '),
487
+ danger: [
488
+ 'border-ku-secondary-border bg-ku-primary text-ku-gray-text',
489
+ 'enabled:hover:bg-ku-error-background enabled:hover:border-ku-error-primary enabled:hover:text-ku-error-primary',
490
+ 'enabled:hover:opacity-90',
491
+ ].join(' '),
492
+ };
493
+ return `${base} ${variantMap[this.variant()]}`;
494
+ });
495
+ }
496
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: IconActionButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
497
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.20", type: IconActionButtonComponent, isStandalone: true, selector: "keepui-icon-action-button", inputs: { icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: true, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: true, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, iconSize: { classPropertyName: "iconSize", publicName: "iconSize", isSignal: true, isRequired: false, transformFunction: null }, type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
498
+ <button
499
+ [attr.type]="type()"
500
+ [disabled]="isDisabled()"
501
+ [attr.aria-disabled]="isDisabled() ? true : null"
502
+ [attr.aria-busy]="loading() ? true : null"
503
+ [attr.aria-label]="ariaLabel()"
504
+ [class]="buttonClass()"
505
+ >
506
+ @if (loading()) {
507
+ <span class="keepui-iab-spinner" aria-hidden="true"></span>
508
+ } @else {
509
+ <keepui-icon [name]="icon()" [size]="iconSize()" />
510
+ }
511
+ </button>
512
+ `, isInline: true, styles: [".keepui-iab-spinner{display:inline-block;width:1.25em;height:1.25em;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:keepui-iab-spin .65s linear infinite;flex-shrink:0}@keyframes keepui-iab-spin{to{transform:rotate(360deg)}}\n"], dependencies: [{ kind: "component", type: IconComponent, selector: "keepui-icon", inputs: ["name", "size", "viewBox", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
513
+ }
514
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: IconActionButtonComponent, decorators: [{
515
+ type: Component,
516
+ args: [{ selector: 'keepui-icon-action-button', standalone: true, imports: [IconComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
517
+ <button
518
+ [attr.type]="type()"
519
+ [disabled]="isDisabled()"
520
+ [attr.aria-disabled]="isDisabled() ? true : null"
521
+ [attr.aria-busy]="loading() ? true : null"
522
+ [attr.aria-label]="ariaLabel()"
523
+ [class]="buttonClass()"
524
+ >
525
+ @if (loading()) {
526
+ <span class="keepui-iab-spinner" aria-hidden="true"></span>
527
+ } @else {
528
+ <keepui-icon [name]="icon()" [size]="iconSize()" />
529
+ }
530
+ </button>
531
+ `, styles: [".keepui-iab-spinner{display:inline-block;width:1.25em;height:1.25em;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:keepui-iab-spin .65s linear infinite;flex-shrink:0}@keyframes keepui-iab-spin{to{transform:rotate(360deg)}}\n"] }]
532
+ }] });
533
+
352
534
  /**
353
535
  * Typed constants for every translation key used in @keepui/ui.
354
536
  *
@@ -369,6 +551,10 @@ const KEEPUI_TRANSLATION_KEYS = {
369
551
  PREVIEW_ALT: 'imagePreview.previewAlt',
370
552
  ERROR_UNEXPECTED: 'imagePreview.errorUnexpected',
371
553
  },
554
+ SIGNAL_TEXT_INPUT: {
555
+ SHOW_PASSWORD: 'signalTextInput.showPassword',
556
+ HIDE_PASSWORD: 'signalTextInput.hidePassword',
557
+ },
372
558
  };
373
559
  /** Ordered list of languages available in KeepUI. */
374
560
  const KEEPUI_AVAILABLE_LANGUAGES = [
@@ -508,6 +694,1035 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImpo
508
694
  }]
509
695
  }] });
510
696
 
697
+ const DROPDOWN_ITEM_HEIGHT = 40;
698
+ const DROPDOWN_PADDING = 8;
699
+ const DROPDOWN_GAP = 8;
700
+ /**
701
+ * Signal-based accessible dropdown / select component.
702
+ *
703
+ * Fully platform-agnostic — no native API usage. The panel opens in `fixed`
704
+ * position so it is never clipped by overflow-hidden ancestors. It repositions
705
+ * itself automatically on scroll and resize.
706
+ *
707
+ * The `value` and `touched` properties are `model()` signals so the component
708
+ * integrates seamlessly with Angular signal-based forms.
709
+ *
710
+ * ```html
711
+ * <keepui-signal-dropdown
712
+ * label="País"
713
+ * placeholder="Selecciona un país"
714
+ * [options]="countries"
715
+ * [(value)]="selectedCountry"
716
+ * />
717
+ * ```
718
+ *
719
+ * @typeParam T – type of each option's `value` property. Defaults to `string`.
720
+ */
721
+ class SignalDropdownComponent {
722
+ constructor() {
723
+ this.destroyRef = inject(DestroyRef);
724
+ this.isOpen = signal(false);
725
+ this.openUpwards = signal(false);
726
+ this.panelTopPx = signal(0);
727
+ this.panelLeftPx = signal(0);
728
+ this.panelWidthPx = signal(0);
729
+ this.dropdownButton = viewChild('dropdownButton');
730
+ this.dropdownContainer = viewChild('dropdownContainer');
731
+ /** Optional label text rendered above the dropdown. */
732
+ this.label = input('');
733
+ /** Placeholder shown when no value is selected. */
734
+ this.placeholder = input('');
735
+ /** Array of options to display in the panel. */
736
+ this.options = input.required();
737
+ /** Layout width of the wrapper. @default 'full' */
738
+ this.width = input('full');
739
+ /** Marks the field as required. Adds `aria-required` and a visual asterisk. @default false */
740
+ this.required = input(false);
741
+ /** Human-readable error message shown below the dropdown. Takes precedence over `errors[0]`. */
742
+ this.errorMessage = input('');
743
+ /**
744
+ * Array of error strings. The first item is displayed when `errorMessage` is empty.
745
+ * Set together with `invalid=true` to trigger the error state.
746
+ */
747
+ this.errors = input([]);
748
+ /**
749
+ * Stable `id` used to link the `<label>` with the trigger `<button>`.
750
+ * A random suffix is generated by default.
751
+ */
752
+ this.selectId = input(`ku-dropdown-${Math.random().toString(36).slice(2, 8)}`);
753
+ /** Disables the dropdown. @default false */
754
+ this.disabled = input(false);
755
+ /**
756
+ * Forces the error visual state regardless of the `touched` model.
757
+ * Useful for external form validation. @default false
758
+ */
759
+ this.invalid = input(false);
760
+ /** Currently selected value. Use `[(value)]` for two-way binding. */
761
+ this.value = model(null);
762
+ /** Whether the control has been interacted with. Use `[(touched)]` for two-way binding. */
763
+ this.touched = model(false);
764
+ /** Emitted when the selected value changes. */
765
+ this.valueChange = output();
766
+ this.errorId = computed(() => `${this.selectId()}-error`);
767
+ this.panelStyle = computed(() => `top:${this.panelTopPx()}px;left:${this.panelLeftPx()}px;width:${this.panelWidthPx()}px`);
768
+ this.widthClass = computed(() => {
769
+ const map = {
770
+ full: 'w-full',
771
+ half: 'w-1/2',
772
+ auto: 'w-auto',
773
+ };
774
+ return map[this.width()];
775
+ });
776
+ this.selectedLabel = computed(() => {
777
+ const current = this.value();
778
+ if (current === null)
779
+ return this.placeholder();
780
+ return this.options().find(o => o.value === current)?.label ?? this.placeholder();
781
+ });
782
+ this.selectedBadges = computed(() => {
783
+ const current = this.value();
784
+ if (current === null)
785
+ return [];
786
+ return this.options().find(o => o.value === current)?.badges ?? [];
787
+ });
788
+ this.showError = computed(() => this.touched() && (this.invalid() || this.errorMessage().length > 0 || this.errors().length > 0));
789
+ this.recalculatePosition = () => {
790
+ if (this.isOpen())
791
+ this.calculateDropdownPosition();
792
+ };
793
+ effect(() => {
794
+ if (this.isOpen()) {
795
+ this.calculateDropdownPosition();
796
+ this.addViewportListeners();
797
+ }
798
+ else {
799
+ this.removeViewportListeners();
800
+ }
801
+ });
802
+ this.destroyRef.onDestroy(() => this.removeViewportListeners());
803
+ }
804
+ onDocumentClick(event) {
805
+ const container = this.dropdownContainer()?.nativeElement;
806
+ if (container && !container.contains(event.target)) {
807
+ this.isOpen.set(false);
808
+ }
809
+ }
810
+ toggleDropdown() {
811
+ if (this.disabled())
812
+ return;
813
+ this.isOpen.update(open => !open);
814
+ }
815
+ close() {
816
+ if (this.isOpen()) {
817
+ this.isOpen.set(false);
818
+ this.dropdownButton()?.nativeElement.focus();
819
+ }
820
+ }
821
+ selectOption(option) {
822
+ this.value.set(option.value);
823
+ this.isOpen.set(false);
824
+ this.dropdownButton()?.nativeElement.focus();
825
+ this.valueChange.emit(option.value);
826
+ }
827
+ onBlur() {
828
+ this.touched.set(true);
829
+ }
830
+ isSelected(optionValue) {
831
+ return this.value() === optionValue;
832
+ }
833
+ onButtonKeydown(event) {
834
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
835
+ event.preventDefault();
836
+ if (!this.isOpen()) {
837
+ this.isOpen.set(true);
838
+ }
839
+ }
840
+ }
841
+ onOptionKeydown(event, option, index) {
842
+ const panel = this.dropdownContainer()?.nativeElement;
843
+ const optionButtons = panel?.querySelectorAll('[role="option"]');
844
+ if (!optionButtons)
845
+ return;
846
+ if (event.key === 'ArrowDown') {
847
+ event.preventDefault();
848
+ optionButtons[Math.min(index + 1, optionButtons.length - 1)]?.focus();
849
+ }
850
+ else if (event.key === 'ArrowUp') {
851
+ event.preventDefault();
852
+ if (index === 0) {
853
+ this.dropdownButton()?.nativeElement.focus();
854
+ }
855
+ else {
856
+ optionButtons[index - 1]?.focus();
857
+ }
858
+ }
859
+ else if (event.key === 'Enter' || event.key === ' ') {
860
+ event.preventDefault();
861
+ this.selectOption(option);
862
+ }
863
+ }
864
+ calculateDropdownPosition() {
865
+ const button = this.dropdownButton()?.nativeElement;
866
+ if (!button)
867
+ return;
868
+ const rect = button.getBoundingClientRect();
869
+ const estimatedHeight = Math.min(this.options().length * DROPDOWN_ITEM_HEIGHT + DROPDOWN_PADDING, 256);
870
+ const spaceBelow = window.innerHeight - rect.bottom;
871
+ const spaceAbove = rect.top;
872
+ const shouldOpenUpwards = spaceBelow < estimatedHeight + DROPDOWN_GAP &&
873
+ spaceAbove > estimatedHeight + DROPDOWN_GAP;
874
+ this.openUpwards.set(shouldOpenUpwards);
875
+ this.panelWidthPx.set(rect.width);
876
+ this.panelLeftPx.set(rect.left);
877
+ this.panelTopPx.set(shouldOpenUpwards
878
+ ? rect.top - estimatedHeight - DROPDOWN_GAP
879
+ : rect.bottom + DROPDOWN_GAP);
880
+ }
881
+ addViewportListeners() {
882
+ this.removeViewportListeners();
883
+ window.addEventListener('scroll', this.recalculatePosition, true);
884
+ window.addEventListener('resize', this.recalculatePosition);
885
+ }
886
+ removeViewportListeners() {
887
+ window.removeEventListener('scroll', this.recalculatePosition, true);
888
+ window.removeEventListener('resize', this.recalculatePosition);
889
+ }
890
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: SignalDropdownComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
891
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.20", type: SignalDropdownComponent, isStandalone: true, selector: "keepui-signal-dropdown", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: true, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, errorMessage: { classPropertyName: "errorMessage", publicName: "errorMessage", isSignal: true, isRequired: false, transformFunction: null }, errors: { classPropertyName: "errors", publicName: "errors", isSignal: true, isRequired: false, transformFunction: null }, selectId: { classPropertyName: "selectId", publicName: "selectId", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, touched: { classPropertyName: "touched", publicName: "touched", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", touched: "touchedChange", valueChange: "valueChange" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "close()" }, classAttribute: "block" }, viewQueries: [{ propertyName: "dropdownButton", first: true, predicate: ["dropdownButton"], descendants: true, isSignal: true }, { propertyName: "dropdownContainer", first: true, predicate: ["dropdownContainer"], descendants: true, isSignal: true }], ngImport: i0, template: `
892
+ <div class="flex flex-col gap-1" [class]="widthClass()">
893
+
894
+ @if (label()) {
895
+ <label [for]="selectId()" class="text-sm text-ku-gray-text">
896
+ {{ label() }}
897
+ @if (required()) {
898
+ <span class="text-ku-error-primary" aria-hidden="true"> *</span>
899
+ }
900
+ </label>
901
+ }
902
+
903
+ <div class="relative" #dropdownContainer>
904
+
905
+ <button
906
+ #dropdownButton
907
+ [id]="selectId()"
908
+ type="button"
909
+ [disabled]="disabled()"
910
+ [attr.aria-disabled]="disabled() ? true : null"
911
+ [attr.aria-expanded]="isOpen()"
912
+ [attr.aria-haspopup]="'listbox'"
913
+ [attr.aria-required]="required() ? true : null"
914
+ [attr.aria-invalid]="showError() ? true : null"
915
+ [attr.aria-describedby]="showError() ? errorId() : null"
916
+ (click)="toggleDropdown()"
917
+ (blur)="onBlur()"
918
+ (keydown)="onButtonKeydown($event)"
919
+ class="w-full inline-flex items-center gap-2 bg-ku-primary border rounded-xl
920
+ px-4 py-2.5 text-sm transition-all cursor-pointer min-h-[2.75rem]
921
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ku-action-primary
922
+ focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
923
+ [class]="showError() ? 'border-ku-error-primary' : 'border-ku-secondary-border'"
924
+ >
925
+ <span
926
+ class="flex-1 text-left flex items-center justify-between gap-2 min-w-0"
927
+ [class]="value() === null ? 'text-ku-gray-text opacity-70' : 'text-ku-primary-text'"
928
+ >
929
+ <span class="truncate">{{ selectedLabel() }}</span>
930
+
931
+ @if (selectedBadges().length) {
932
+ <span class="flex flex-wrap items-center gap-1.5 shrink-0">
933
+ @for (badge of selectedBadges(); track badge) {
934
+ <span class="inline-flex items-center rounded-full bg-ku-action-background
935
+ px-2 py-0.5 text-[11px] font-medium text-ku-action-primary">
936
+ {{ badge }}
937
+ </span>
938
+ }
939
+ </span>
940
+ }
941
+ </span>
942
+
943
+ <keepui-icon
944
+ name="chevron-down-icon"
945
+ [size]="18"
946
+ aria-hidden="true"
947
+ class="text-ku-gray-text transition-transform duration-200 shrink-0"
948
+ [class.rotate-180]="isOpen()"
949
+ />
950
+ </button>
951
+
952
+ @if (isOpen()) {
953
+ <div
954
+ role="listbox"
955
+ [attr.aria-label]="label() || null"
956
+ class="fixed rounded-xl shadow-lg bg-ku-primary border border-ku-secondary-border
957
+ z-[1000] overflow-y-auto max-h-64"
958
+ [style]="panelStyle()"
959
+ >
960
+ @for (option of options(); track option.value; let i = $index) {
961
+ <button
962
+ type="button"
963
+ role="option"
964
+ [attr.aria-selected]="isSelected(option.value)"
965
+ (click)="selectOption(option)"
966
+ (keydown)="onOptionKeydown($event, option, i)"
967
+ class="w-full text-left cursor-pointer px-4 py-2 text-sm text-ku-primary-text
968
+ flex items-center gap-3 transition-colors min-h-[2.75rem]
969
+ hover:bg-ku-primary-hover focus-visible:outline-none
970
+ focus-visible:bg-ku-primary-hover"
971
+ [class]="isSelected(option.value) ? 'bg-ku-primary-hover' : ''"
972
+ >
973
+ <span class="flex-1 flex items-center justify-between gap-2 min-w-0">
974
+ <span class="truncate">{{ option.label }}</span>
975
+
976
+ @if (option.badges?.length) {
977
+ <span class="flex flex-wrap items-center gap-1.5 shrink-0">
978
+ @for (badge of option.badges ?? []; track badge) {
979
+ <span class="inline-flex items-center rounded-full bg-ku-action-background
980
+ px-2 py-0.5 text-[11px] font-medium text-ku-action-primary">
981
+ {{ badge }}
982
+ </span>
983
+ }
984
+ </span>
985
+ }
986
+ </span>
987
+
988
+ @if (isSelected(option.value)) {
989
+ <keepui-icon
990
+ name="check-icon"
991
+ [size]="16"
992
+ aria-hidden="true"
993
+ class="text-ku-action-primary shrink-0"
994
+ />
995
+ }
996
+ </button>
997
+ }
998
+ </div>
999
+ }
1000
+
1001
+ </div>
1002
+
1003
+ @if (showError()) {
1004
+ <span
1005
+ [id]="errorId()"
1006
+ class="text-sm text-ku-error-primary"
1007
+ role="alert"
1008
+ >
1009
+ @if (errorMessage()) {
1010
+ {{ errorMessage() }}
1011
+ } @else if (errors().length > 0) {
1012
+ {{ errors()[0] }}
1013
+ }
1014
+ </span>
1015
+ }
1016
+
1017
+ </div>
1018
+ `, isInline: true, dependencies: [{ kind: "component", type: IconComponent, selector: "keepui-icon", inputs: ["name", "size", "viewBox", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1019
+ }
1020
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: SignalDropdownComponent, decorators: [{
1021
+ type: Component,
1022
+ args: [{
1023
+ selector: 'keepui-signal-dropdown',
1024
+ standalone: true,
1025
+ changeDetection: ChangeDetectionStrategy.OnPush,
1026
+ imports: [IconComponent],
1027
+ host: {
1028
+ class: 'block',
1029
+ '(document:click)': 'onDocumentClick($event)',
1030
+ '(document:keydown.escape)': 'close()',
1031
+ },
1032
+ template: `
1033
+ <div class="flex flex-col gap-1" [class]="widthClass()">
1034
+
1035
+ @if (label()) {
1036
+ <label [for]="selectId()" class="text-sm text-ku-gray-text">
1037
+ {{ label() }}
1038
+ @if (required()) {
1039
+ <span class="text-ku-error-primary" aria-hidden="true"> *</span>
1040
+ }
1041
+ </label>
1042
+ }
1043
+
1044
+ <div class="relative" #dropdownContainer>
1045
+
1046
+ <button
1047
+ #dropdownButton
1048
+ [id]="selectId()"
1049
+ type="button"
1050
+ [disabled]="disabled()"
1051
+ [attr.aria-disabled]="disabled() ? true : null"
1052
+ [attr.aria-expanded]="isOpen()"
1053
+ [attr.aria-haspopup]="'listbox'"
1054
+ [attr.aria-required]="required() ? true : null"
1055
+ [attr.aria-invalid]="showError() ? true : null"
1056
+ [attr.aria-describedby]="showError() ? errorId() : null"
1057
+ (click)="toggleDropdown()"
1058
+ (blur)="onBlur()"
1059
+ (keydown)="onButtonKeydown($event)"
1060
+ class="w-full inline-flex items-center gap-2 bg-ku-primary border rounded-xl
1061
+ px-4 py-2.5 text-sm transition-all cursor-pointer min-h-[2.75rem]
1062
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ku-action-primary
1063
+ focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
1064
+ [class]="showError() ? 'border-ku-error-primary' : 'border-ku-secondary-border'"
1065
+ >
1066
+ <span
1067
+ class="flex-1 text-left flex items-center justify-between gap-2 min-w-0"
1068
+ [class]="value() === null ? 'text-ku-gray-text opacity-70' : 'text-ku-primary-text'"
1069
+ >
1070
+ <span class="truncate">{{ selectedLabel() }}</span>
1071
+
1072
+ @if (selectedBadges().length) {
1073
+ <span class="flex flex-wrap items-center gap-1.5 shrink-0">
1074
+ @for (badge of selectedBadges(); track badge) {
1075
+ <span class="inline-flex items-center rounded-full bg-ku-action-background
1076
+ px-2 py-0.5 text-[11px] font-medium text-ku-action-primary">
1077
+ {{ badge }}
1078
+ </span>
1079
+ }
1080
+ </span>
1081
+ }
1082
+ </span>
1083
+
1084
+ <keepui-icon
1085
+ name="chevron-down-icon"
1086
+ [size]="18"
1087
+ aria-hidden="true"
1088
+ class="text-ku-gray-text transition-transform duration-200 shrink-0"
1089
+ [class.rotate-180]="isOpen()"
1090
+ />
1091
+ </button>
1092
+
1093
+ @if (isOpen()) {
1094
+ <div
1095
+ role="listbox"
1096
+ [attr.aria-label]="label() || null"
1097
+ class="fixed rounded-xl shadow-lg bg-ku-primary border border-ku-secondary-border
1098
+ z-[1000] overflow-y-auto max-h-64"
1099
+ [style]="panelStyle()"
1100
+ >
1101
+ @for (option of options(); track option.value; let i = $index) {
1102
+ <button
1103
+ type="button"
1104
+ role="option"
1105
+ [attr.aria-selected]="isSelected(option.value)"
1106
+ (click)="selectOption(option)"
1107
+ (keydown)="onOptionKeydown($event, option, i)"
1108
+ class="w-full text-left cursor-pointer px-4 py-2 text-sm text-ku-primary-text
1109
+ flex items-center gap-3 transition-colors min-h-[2.75rem]
1110
+ hover:bg-ku-primary-hover focus-visible:outline-none
1111
+ focus-visible:bg-ku-primary-hover"
1112
+ [class]="isSelected(option.value) ? 'bg-ku-primary-hover' : ''"
1113
+ >
1114
+ <span class="flex-1 flex items-center justify-between gap-2 min-w-0">
1115
+ <span class="truncate">{{ option.label }}</span>
1116
+
1117
+ @if (option.badges?.length) {
1118
+ <span class="flex flex-wrap items-center gap-1.5 shrink-0">
1119
+ @for (badge of option.badges ?? []; track badge) {
1120
+ <span class="inline-flex items-center rounded-full bg-ku-action-background
1121
+ px-2 py-0.5 text-[11px] font-medium text-ku-action-primary">
1122
+ {{ badge }}
1123
+ </span>
1124
+ }
1125
+ </span>
1126
+ }
1127
+ </span>
1128
+
1129
+ @if (isSelected(option.value)) {
1130
+ <keepui-icon
1131
+ name="check-icon"
1132
+ [size]="16"
1133
+ aria-hidden="true"
1134
+ class="text-ku-action-primary shrink-0"
1135
+ />
1136
+ }
1137
+ </button>
1138
+ }
1139
+ </div>
1140
+ }
1141
+
1142
+ </div>
1143
+
1144
+ @if (showError()) {
1145
+ <span
1146
+ [id]="errorId()"
1147
+ class="text-sm text-ku-error-primary"
1148
+ role="alert"
1149
+ >
1150
+ @if (errorMessage()) {
1151
+ {{ errorMessage() }}
1152
+ } @else if (errors().length > 0) {
1153
+ {{ errors()[0] }}
1154
+ }
1155
+ </span>
1156
+ }
1157
+
1158
+ </div>
1159
+ `,
1160
+ }]
1161
+ }], ctorParameters: () => [] });
1162
+
1163
+ /**
1164
+ * Signal-based accessible text input supporting all common HTML input types,
1165
+ * leading/trailing icons, a trailing content slot, and a built-in
1166
+ * password-visibility toggle (when `type="password"`).
1167
+ *
1168
+ * The `value` and `touched` properties are `model()` signals so the component
1169
+ * integrates seamlessly with Angular signal-based forms.
1170
+ *
1171
+ * Password toggle labels are translated via Transloco (scope `'keepui'`).
1172
+ * Call `provideKeepUiI18n()` in your `app.config.ts` to activate i18n.
1173
+ *
1174
+ * ```html
1175
+ * <keepui-signal-text-input
1176
+ * label="Email"
1177
+ * type="email"
1178
+ * placeholder="usuario@ejemplo.com"
1179
+ * leadingIcon="mail-icon"
1180
+ * [(value)]="email"
1181
+ * />
1182
+ *
1183
+ * <!-- Password with toggle -->
1184
+ * <keepui-signal-text-input
1185
+ * label="Contraseña"
1186
+ * type="password"
1187
+ * [(value)]="password"
1188
+ * />
1189
+ * ```
1190
+ */
1191
+ class SignalTextInputComponent {
1192
+ constructor() {
1193
+ this.isPasswordVisible = signal(false);
1194
+ this.inputRef = viewChild('inputRef');
1195
+ /** Translation key references (typed via KEEPUI_TRANSLATION_KEYS). */
1196
+ this.keys = KEEPUI_TRANSLATION_KEYS.SIGNAL_TEXT_INPUT;
1197
+ /** Optional label text rendered above the input. */
1198
+ this.label = input('');
1199
+ /** Placeholder passed to the underlying `<input>`. */
1200
+ this.placeholder = input('');
1201
+ /** HTML `type` attribute. Use `'password'` to enable the visibility toggle. @default 'text' */
1202
+ this.type = input('text');
1203
+ /** Layout width of the wrapper. @default 'full' */
1204
+ this.width = input('full');
1205
+ /** Name of the leading icon (SVG symbol ID). Leave empty for no icon. */
1206
+ this.leadingIcon = input('');
1207
+ /** Name of the trailing icon (SVG symbol ID). Ignored when `type="password"`. */
1208
+ this.trailingIcon = input('');
1209
+ /**
1210
+ * When `true`, a slot for custom trailing content is enabled
1211
+ * (projects `[trailingSlot]` content). Overrides `trailingIcon`.
1212
+ * @default false
1213
+ */
1214
+ this.hasTrailingSlot = input(false);
1215
+ /** Marks the field as required. Adds `aria-required` and a visual asterisk. @default false */
1216
+ this.required = input(false);
1217
+ /** When `false`, hides the visual asterisk even if `required=true`. @default true */
1218
+ this.showRequiredIndicator = input(true);
1219
+ /** Human-readable error message. Takes precedence over `errors[0]`. */
1220
+ this.errorMessage = input('');
1221
+ /**
1222
+ * Array of error strings. The first item is displayed when `errorMessage` is empty.
1223
+ * Set together with `invalid=true` to trigger the error state.
1224
+ */
1225
+ this.errors = input([]);
1226
+ /**
1227
+ * Stable `id` used to link the `<label>` with the `<input>`.
1228
+ * A random suffix is generated by default.
1229
+ */
1230
+ this.inputId = input(`ku-text-input-${Math.random().toString(36).slice(2, 8)}`);
1231
+ /** Disables the input. @default false */
1232
+ this.disabled = input(false);
1233
+ /**
1234
+ * Forces the error visual state regardless of the `touched` model.
1235
+ * Useful for external form validation. @default false
1236
+ */
1237
+ this.invalid = input(false);
1238
+ /** Current text value. Use `[(value)]` for two-way binding. */
1239
+ this.value = model('');
1240
+ /** Whether the control has been interacted with. Use `[(touched)]` for two-way binding. */
1241
+ this.touched = model(false);
1242
+ this.errorId = computed(() => `${this.inputId()}-error`);
1243
+ this.isPasswordType = computed(() => this.type() === 'password');
1244
+ this.resolvedType = computed(() => {
1245
+ if (this.isPasswordType()) {
1246
+ return this.isPasswordVisible() ? 'text' : 'password';
1247
+ }
1248
+ return this.type();
1249
+ });
1250
+ this.widthClass = computed(() => {
1251
+ const map = {
1252
+ full: 'w-full',
1253
+ half: 'w-1/2',
1254
+ auto: 'w-auto',
1255
+ };
1256
+ return map[this.width()];
1257
+ });
1258
+ this.hasLeading = computed(() => this.leadingIcon().length > 0);
1259
+ this.hasTrailing = computed(() => this.trailingIcon().length > 0 && !this.isPasswordType());
1260
+ this.showError = computed(() => this.touched() &&
1261
+ (this.invalid() || this.errorMessage().length > 0 || this.errors().length > 0));
1262
+ this.isDateEmpty = computed(() => this.type() === 'date' && !this.value());
1263
+ this.inputClasses = computed(() => {
1264
+ const textColor = this.isDateEmpty()
1265
+ ? 'text-ku-gray-text'
1266
+ : 'text-ku-primary-text';
1267
+ const paddingLeft = this.hasLeading() ? 'pl-9' : 'pl-4';
1268
+ const paddingRight = this.hasTrailingSlot()
1269
+ ? 'pr-24'
1270
+ : this.hasTrailing() || this.isPasswordType()
1271
+ ? 'pr-10'
1272
+ : 'pr-4';
1273
+ const borderColor = this.showError()
1274
+ ? 'border-ku-error-primary'
1275
+ : 'border-ku-secondary-border focus-visible:border-ku-action-primary';
1276
+ const cursor = this.type() === 'date' ? 'cursor-pointer' : '';
1277
+ return `${textColor} ${paddingLeft} ${paddingRight} ${borderColor} ${cursor}`;
1278
+ });
1279
+ }
1280
+ onInput(event) {
1281
+ const target = event.target;
1282
+ this.value.set(target.value);
1283
+ }
1284
+ onBlur() {
1285
+ this.touched.set(true);
1286
+ }
1287
+ openDatePicker() {
1288
+ if (this.type() === 'date') {
1289
+ try {
1290
+ this.inputRef()?.nativeElement.showPicker();
1291
+ }
1292
+ catch { /* showPicker not supported in all browsers */ }
1293
+ }
1294
+ }
1295
+ togglePasswordVisibility() {
1296
+ this.isPasswordVisible.update(v => !v);
1297
+ }
1298
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: SignalTextInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1299
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.20", type: SignalTextInputComponent, isStandalone: true, selector: "keepui-signal-text-input", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, leadingIcon: { classPropertyName: "leadingIcon", publicName: "leadingIcon", isSignal: true, isRequired: false, transformFunction: null }, trailingIcon: { classPropertyName: "trailingIcon", publicName: "trailingIcon", isSignal: true, isRequired: false, transformFunction: null }, hasTrailingSlot: { classPropertyName: "hasTrailingSlot", publicName: "hasTrailingSlot", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, showRequiredIndicator: { classPropertyName: "showRequiredIndicator", publicName: "showRequiredIndicator", isSignal: true, isRequired: false, transformFunction: null }, errorMessage: { classPropertyName: "errorMessage", publicName: "errorMessage", isSignal: true, isRequired: false, transformFunction: null }, errors: { classPropertyName: "errors", publicName: "errors", isSignal: true, isRequired: false, transformFunction: null }, inputId: { classPropertyName: "inputId", publicName: "inputId", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, touched: { classPropertyName: "touched", publicName: "touched", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", touched: "touchedChange" }, host: { classAttribute: "block" }, providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'keepui' }], viewQueries: [{ propertyName: "inputRef", first: true, predicate: ["inputRef"], descendants: true, isSignal: true }], ngImport: i0, template: `
1300
+ <div class="flex flex-col gap-1" [class]="widthClass()">
1301
+
1302
+ @if (label()) {
1303
+ <label [for]="inputId()" class="text-sm text-ku-gray-text">
1304
+ {{ label() }}
1305
+ @if (required() && showRequiredIndicator()) {
1306
+ <span class="text-ku-error-primary" aria-hidden="true"> *</span>
1307
+ }
1308
+ </label>
1309
+ }
1310
+
1311
+ <div class="relative flex items-center">
1312
+
1313
+ @if (hasLeading()) {
1314
+ <span
1315
+ class="absolute left-3 flex items-center pointer-events-none text-ku-gray-text"
1316
+ aria-hidden="true"
1317
+ >
1318
+ <keepui-icon [name]="leadingIcon()" [size]="18" />
1319
+ </span>
1320
+ }
1321
+
1322
+ <input
1323
+ #inputRef
1324
+ [id]="inputId()"
1325
+ [type]="resolvedType()"
1326
+ [value]="value()"
1327
+ [placeholder]="placeholder()"
1328
+ [disabled]="disabled()"
1329
+ [attr.required]="required() ? true : null"
1330
+ [attr.aria-required]="required() ? true : null"
1331
+ [attr.aria-invalid]="showError() ? true : null"
1332
+ [attr.aria-describedby]="showError() ? errorId() : null"
1333
+ class="bg-ku-primary border rounded-xl py-2.5 text-sm text-ku-primary-text
1334
+ placeholder:text-ku-gray-text outline-none transition-colors w-full
1335
+ disabled:opacity-50 disabled:cursor-not-allowed min-h-[2.75rem]
1336
+ dark:[color-scheme:dark]"
1337
+ [class]="inputClasses()"
1338
+ (click)="openDatePicker()"
1339
+ (input)="onInput($event)"
1340
+ (blur)="onBlur()"
1341
+ />
1342
+
1343
+ @if (hasTrailingSlot()) {
1344
+ <div class="absolute right-1 flex items-center">
1345
+ <ng-content select="[trailingSlot]" />
1346
+ </div>
1347
+ } @else if (isPasswordType()) {
1348
+ <button
1349
+ type="button"
1350
+ (click)="togglePasswordVisibility()"
1351
+ class="absolute right-3 flex items-center justify-center text-ku-gray-text
1352
+ hover:text-ku-primary-text transition-colors cursor-pointer
1353
+ focus-visible:outline-none focus-visible:ring-2
1354
+ focus-visible:ring-ku-action-primary rounded min-h-[2.75rem] min-w-[2.75rem]"
1355
+ [attr.aria-label]="(isPasswordVisible()
1356
+ ? keys.HIDE_PASSWORD
1357
+ : keys.SHOW_PASSWORD) | transloco"
1358
+ >
1359
+ <keepui-icon
1360
+ [name]="isPasswordVisible() ? 'eye-off-icon' : 'eye-icon'"
1361
+ [size]="18"
1362
+ aria-hidden="true"
1363
+ />
1364
+ </button>
1365
+ } @else if (hasTrailing()) {
1366
+ <span
1367
+ class="absolute right-3 flex items-center pointer-events-none text-ku-gray-text"
1368
+ aria-hidden="true"
1369
+ >
1370
+ <keepui-icon [name]="trailingIcon()" [size]="18" />
1371
+ </span>
1372
+ }
1373
+
1374
+ </div>
1375
+
1376
+ @if (showError()) {
1377
+ <span
1378
+ [id]="errorId()"
1379
+ class="text-sm text-ku-error-primary"
1380
+ role="alert"
1381
+ >
1382
+ @if (errorMessage()) {
1383
+ {{ errorMessage() }}
1384
+ } @else if (errors().length > 0) {
1385
+ {{ errors()[0] }}
1386
+ }
1387
+ </span>
1388
+ }
1389
+
1390
+ </div>
1391
+ `, isInline: true, dependencies: [{ kind: "component", type: IconComponent, selector: "keepui-icon", inputs: ["name", "size", "viewBox", "ariaLabel"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1392
+ }
1393
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: SignalTextInputComponent, decorators: [{
1394
+ type: Component,
1395
+ args: [{
1396
+ selector: 'keepui-signal-text-input',
1397
+ standalone: true,
1398
+ changeDetection: ChangeDetectionStrategy.OnPush,
1399
+ imports: [IconComponent, TranslocoPipe],
1400
+ providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'keepui' }],
1401
+ host: { class: 'block' },
1402
+ template: `
1403
+ <div class="flex flex-col gap-1" [class]="widthClass()">
1404
+
1405
+ @if (label()) {
1406
+ <label [for]="inputId()" class="text-sm text-ku-gray-text">
1407
+ {{ label() }}
1408
+ @if (required() && showRequiredIndicator()) {
1409
+ <span class="text-ku-error-primary" aria-hidden="true"> *</span>
1410
+ }
1411
+ </label>
1412
+ }
1413
+
1414
+ <div class="relative flex items-center">
1415
+
1416
+ @if (hasLeading()) {
1417
+ <span
1418
+ class="absolute left-3 flex items-center pointer-events-none text-ku-gray-text"
1419
+ aria-hidden="true"
1420
+ >
1421
+ <keepui-icon [name]="leadingIcon()" [size]="18" />
1422
+ </span>
1423
+ }
1424
+
1425
+ <input
1426
+ #inputRef
1427
+ [id]="inputId()"
1428
+ [type]="resolvedType()"
1429
+ [value]="value()"
1430
+ [placeholder]="placeholder()"
1431
+ [disabled]="disabled()"
1432
+ [attr.required]="required() ? true : null"
1433
+ [attr.aria-required]="required() ? true : null"
1434
+ [attr.aria-invalid]="showError() ? true : null"
1435
+ [attr.aria-describedby]="showError() ? errorId() : null"
1436
+ class="bg-ku-primary border rounded-xl py-2.5 text-sm text-ku-primary-text
1437
+ placeholder:text-ku-gray-text outline-none transition-colors w-full
1438
+ disabled:opacity-50 disabled:cursor-not-allowed min-h-[2.75rem]
1439
+ dark:[color-scheme:dark]"
1440
+ [class]="inputClasses()"
1441
+ (click)="openDatePicker()"
1442
+ (input)="onInput($event)"
1443
+ (blur)="onBlur()"
1444
+ />
1445
+
1446
+ @if (hasTrailingSlot()) {
1447
+ <div class="absolute right-1 flex items-center">
1448
+ <ng-content select="[trailingSlot]" />
1449
+ </div>
1450
+ } @else if (isPasswordType()) {
1451
+ <button
1452
+ type="button"
1453
+ (click)="togglePasswordVisibility()"
1454
+ class="absolute right-3 flex items-center justify-center text-ku-gray-text
1455
+ hover:text-ku-primary-text transition-colors cursor-pointer
1456
+ focus-visible:outline-none focus-visible:ring-2
1457
+ focus-visible:ring-ku-action-primary rounded min-h-[2.75rem] min-w-[2.75rem]"
1458
+ [attr.aria-label]="(isPasswordVisible()
1459
+ ? keys.HIDE_PASSWORD
1460
+ : keys.SHOW_PASSWORD) | transloco"
1461
+ >
1462
+ <keepui-icon
1463
+ [name]="isPasswordVisible() ? 'eye-off-icon' : 'eye-icon'"
1464
+ [size]="18"
1465
+ aria-hidden="true"
1466
+ />
1467
+ </button>
1468
+ } @else if (hasTrailing()) {
1469
+ <span
1470
+ class="absolute right-3 flex items-center pointer-events-none text-ku-gray-text"
1471
+ aria-hidden="true"
1472
+ >
1473
+ <keepui-icon [name]="trailingIcon()" [size]="18" />
1474
+ </span>
1475
+ }
1476
+
1477
+ </div>
1478
+
1479
+ @if (showError()) {
1480
+ <span
1481
+ [id]="errorId()"
1482
+ class="text-sm text-ku-error-primary"
1483
+ role="alert"
1484
+ >
1485
+ @if (errorMessage()) {
1486
+ {{ errorMessage() }}
1487
+ } @else if (errors().length > 0) {
1488
+ {{ errors()[0] }}
1489
+ }
1490
+ </span>
1491
+ }
1492
+
1493
+ </div>
1494
+ `,
1495
+ }]
1496
+ }] });
1497
+
1498
+ /**
1499
+ * Signal-based accessible textarea component with character counter,
1500
+ * resize control, and integrated error display.
1501
+ *
1502
+ * The `value` and `touched` properties are `model()` signals so the component
1503
+ * integrates seamlessly with Angular signal-based forms.
1504
+ *
1505
+ * ```html
1506
+ * <keepui-signal-textarea
1507
+ * label="Descripción"
1508
+ * placeholder="Escribe aquí…"
1509
+ * [rows]="5"
1510
+ * [maxLength]="500"
1511
+ * [(value)]="description"
1512
+ * />
1513
+ * ```
1514
+ */
1515
+ class SignalTextareaComponent {
1516
+ constructor() {
1517
+ /** Optional label text rendered above the textarea. */
1518
+ this.label = input('');
1519
+ /** Placeholder passed to the underlying `<textarea>`. */
1520
+ this.placeholder = input('');
1521
+ /** Number of visible text rows. @default 4 */
1522
+ this.rows = input(4);
1523
+ /** Layout width of the wrapper. @default 'full' */
1524
+ this.width = input('full');
1525
+ /** CSS `resize` behaviour. @default 'none' */
1526
+ this.resize = input('none');
1527
+ /** Marks the field as required. @default false */
1528
+ this.required = input(false);
1529
+ /** Human-readable error message. Takes precedence over `errors[0]`. */
1530
+ this.errorMessage = input('');
1531
+ /**
1532
+ * Array of error strings. The first item is displayed when `errorMessage` is empty.
1533
+ * Set together with `invalid=true` to trigger the error state.
1534
+ */
1535
+ this.errors = input([]);
1536
+ /**
1537
+ * Stable `id` used to link the `<label>` with the `<textarea>`.
1538
+ * A random suffix is generated by default.
1539
+ */
1540
+ this.textareaId = input(`ku-textarea-${Math.random().toString(36).slice(2, 8)}`);
1541
+ /** Disables the textarea. @default false */
1542
+ this.disabled = input(false);
1543
+ /** Maximum number of characters allowed. Shows a character counter when set. */
1544
+ this.maxLength = input(undefined);
1545
+ /**
1546
+ * Forces the error visual state regardless of the `touched` model.
1547
+ * Useful for external form validation. @default false
1548
+ */
1549
+ this.invalid = input(false);
1550
+ /** Current text value. Use `[(value)]` for two-way binding. */
1551
+ this.value = model('');
1552
+ /** Whether the control has been interacted with. Use `[(touched)]` for two-way binding. */
1553
+ this.touched = model(false);
1554
+ this.errorId = computed(() => `${this.textareaId()}-error`);
1555
+ this.charCountId = computed(() => `${this.textareaId()}-count`);
1556
+ this.charCount = computed(() => this.value().length);
1557
+ this.showError = computed(() => this.touched() &&
1558
+ (this.invalid() || this.errorMessage().length > 0 || this.errors().length > 0));
1559
+ this.widthClass = computed(() => {
1560
+ const map = {
1561
+ full: 'w-full',
1562
+ half: 'w-1/2',
1563
+ auto: 'w-auto',
1564
+ };
1565
+ return map[this.width()];
1566
+ });
1567
+ this.resizeClass = computed(() => {
1568
+ const map = {
1569
+ none: 'resize-none',
1570
+ vertical: 'resize-y',
1571
+ horizontal: 'resize-x',
1572
+ both: 'resize',
1573
+ };
1574
+ return map[this.resize()];
1575
+ });
1576
+ this.textareaClasses = computed(() => {
1577
+ const borderColor = this.showError()
1578
+ ? 'border-ku-error-primary'
1579
+ : 'border-ku-secondary-border focus-visible:border-ku-action-primary';
1580
+ return `${this.resizeClass()} ${borderColor}`;
1581
+ });
1582
+ }
1583
+ onInput(event) {
1584
+ const target = event.target;
1585
+ this.value.set(target.value);
1586
+ }
1587
+ onBlur() {
1588
+ this.touched.set(true);
1589
+ }
1590
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: SignalTextareaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1591
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.20", type: SignalTextareaComponent, isStandalone: true, selector: "keepui-signal-textarea", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, resize: { classPropertyName: "resize", publicName: "resize", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, errorMessage: { classPropertyName: "errorMessage", publicName: "errorMessage", isSignal: true, isRequired: false, transformFunction: null }, errors: { classPropertyName: "errors", publicName: "errors", isSignal: true, isRequired: false, transformFunction: null }, textareaId: { classPropertyName: "textareaId", publicName: "textareaId", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, maxLength: { classPropertyName: "maxLength", publicName: "maxLength", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, touched: { classPropertyName: "touched", publicName: "touched", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", touched: "touchedChange" }, host: { classAttribute: "block" }, ngImport: i0, template: `
1592
+ <div class="flex flex-col gap-1" [class]="widthClass()">
1593
+
1594
+ @if (label()) {
1595
+ <label [for]="textareaId()" class="text-sm text-ku-gray-text">
1596
+ {{ label() }}
1597
+ @if (required()) {
1598
+ <span class="text-ku-error-primary" aria-hidden="true"> *</span>
1599
+ }
1600
+ </label>
1601
+ }
1602
+
1603
+ <textarea
1604
+ [id]="textareaId()"
1605
+ [value]="value()"
1606
+ [placeholder]="placeholder()"
1607
+ [rows]="rows()"
1608
+ [disabled]="disabled()"
1609
+ [attr.maxlength]="maxLength() ?? null"
1610
+ [attr.required]="required() ? true : null"
1611
+ [attr.aria-required]="required() ? true : null"
1612
+ [attr.aria-invalid]="showError() ? true : null"
1613
+ [attr.aria-describedby]="showError() ? errorId() : (maxLength() ? charCountId() : null)"
1614
+ class="bg-ku-primary border rounded-xl p-3 text-sm text-ku-primary-text
1615
+ placeholder:text-ku-gray-text outline-none transition-colors w-full
1616
+ disabled:opacity-50 disabled:cursor-not-allowed"
1617
+ [class]="textareaClasses()"
1618
+ (input)="onInput($event)"
1619
+ (blur)="onBlur()"
1620
+ ></textarea>
1621
+
1622
+ <div class="flex items-start justify-between gap-2">
1623
+
1624
+ <span
1625
+ [id]="errorId()"
1626
+ class="text-sm text-ku-error-primary"
1627
+ role="alert"
1628
+ >
1629
+ @if (showError()) {
1630
+ @if (errorMessage()) {
1631
+ {{ errorMessage() }}
1632
+ } @else if (errors().length > 0) {
1633
+ {{ errors()[0] }}
1634
+ }
1635
+ }
1636
+ </span>
1637
+
1638
+ @if (maxLength()) {
1639
+ <span
1640
+ [id]="charCountId()"
1641
+ class="text-xs text-ku-gray-text ml-auto shrink-0"
1642
+ aria-live="polite"
1643
+ aria-atomic="true"
1644
+ >
1645
+ {{ charCount() }}&thinsp;/&thinsp;{{ maxLength() }}
1646
+ </span>
1647
+ }
1648
+
1649
+ </div>
1650
+
1651
+ </div>
1652
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1653
+ }
1654
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: SignalTextareaComponent, decorators: [{
1655
+ type: Component,
1656
+ args: [{
1657
+ selector: 'keepui-signal-textarea',
1658
+ standalone: true,
1659
+ changeDetection: ChangeDetectionStrategy.OnPush,
1660
+ host: { class: 'block' },
1661
+ template: `
1662
+ <div class="flex flex-col gap-1" [class]="widthClass()">
1663
+
1664
+ @if (label()) {
1665
+ <label [for]="textareaId()" class="text-sm text-ku-gray-text">
1666
+ {{ label() }}
1667
+ @if (required()) {
1668
+ <span class="text-ku-error-primary" aria-hidden="true"> *</span>
1669
+ }
1670
+ </label>
1671
+ }
1672
+
1673
+ <textarea
1674
+ [id]="textareaId()"
1675
+ [value]="value()"
1676
+ [placeholder]="placeholder()"
1677
+ [rows]="rows()"
1678
+ [disabled]="disabled()"
1679
+ [attr.maxlength]="maxLength() ?? null"
1680
+ [attr.required]="required() ? true : null"
1681
+ [attr.aria-required]="required() ? true : null"
1682
+ [attr.aria-invalid]="showError() ? true : null"
1683
+ [attr.aria-describedby]="showError() ? errorId() : (maxLength() ? charCountId() : null)"
1684
+ class="bg-ku-primary border rounded-xl p-3 text-sm text-ku-primary-text
1685
+ placeholder:text-ku-gray-text outline-none transition-colors w-full
1686
+ disabled:opacity-50 disabled:cursor-not-allowed"
1687
+ [class]="textareaClasses()"
1688
+ (input)="onInput($event)"
1689
+ (blur)="onBlur()"
1690
+ ></textarea>
1691
+
1692
+ <div class="flex items-start justify-between gap-2">
1693
+
1694
+ <span
1695
+ [id]="errorId()"
1696
+ class="text-sm text-ku-error-primary"
1697
+ role="alert"
1698
+ >
1699
+ @if (showError()) {
1700
+ @if (errorMessage()) {
1701
+ {{ errorMessage() }}
1702
+ } @else if (errors().length > 0) {
1703
+ {{ errors()[0] }}
1704
+ }
1705
+ }
1706
+ </span>
1707
+
1708
+ @if (maxLength()) {
1709
+ <span
1710
+ [id]="charCountId()"
1711
+ class="text-xs text-ku-gray-text ml-auto shrink-0"
1712
+ aria-live="polite"
1713
+ aria-atomic="true"
1714
+ >
1715
+ {{ charCount() }}&thinsp;/&thinsp;{{ maxLength() }}
1716
+ </span>
1717
+ }
1718
+
1719
+ </div>
1720
+
1721
+ </div>
1722
+ `,
1723
+ }]
1724
+ }] });
1725
+
511
1726
  /**
512
1727
  * Registers KeepUI core providers for a **web** Angular application.
513
1728
  *
@@ -543,6 +1758,10 @@ const EN = {
543
1758
  previewAlt: 'Selected image preview',
544
1759
  errorUnexpected: 'An unexpected error occurred',
545
1760
  },
1761
+ signalTextInput: {
1762
+ showPassword: 'Show password',
1763
+ hidePassword: 'Hide password',
1764
+ },
546
1765
  };
547
1766
  const ES = {
548
1767
  imagePreview: {
@@ -551,6 +1770,10 @@ const ES = {
551
1770
  previewAlt: 'Vista previa de imagen seleccionada',
552
1771
  errorUnexpected: 'Ha ocurrido un error inesperado',
553
1772
  },
1773
+ signalTextInput: {
1774
+ showPassword: 'Mostrar contraseña',
1775
+ hidePassword: 'Ocultar contraseña',
1776
+ },
554
1777
  };
555
1778
  const DE = {
556
1779
  imagePreview: {
@@ -559,6 +1782,10 @@ const DE = {
559
1782
  previewAlt: 'Vorschau des ausgewählten Bildes',
560
1783
  errorUnexpected: 'Ein unerwarteter Fehler ist aufgetreten',
561
1784
  },
1785
+ signalTextInput: {
1786
+ showPassword: 'Passwort anzeigen',
1787
+ hidePassword: 'Passwort verbergen',
1788
+ },
562
1789
  };
563
1790
  /** Map from locale code to its translation object. */
564
1791
  const KEEPUI_TRANSLATIONS = {
@@ -723,5 +1950,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImpo
723
1950
  * Generated bundle index. Do not edit.
724
1951
  */
725
1952
 
726
- export { ButtonComponent, CardComponent, FILE_PORT, ImagePreviewComponent, KEEPUI_AVAILABLE_LANGUAGES, KEEPUI_TRANSLATIONS, KEEPUI_TRANSLATION_KEYS, KeepUiLanguageService, MockFileService, WebFileService, provideKeepUi, provideKeepUiI18n };
1953
+ export { ButtonComponent, CardComponent, FILE_PORT, IconActionButtonComponent, IconComponent, ImagePreviewComponent, KEEPUI_AVAILABLE_LANGUAGES, KEEPUI_TRANSLATIONS, KEEPUI_TRANSLATION_KEYS, KeepUiLanguageService, MockFileService, SignalDropdownComponent, SignalTextInputComponent, SignalTextareaComponent, WebFileService, provideKeepUi, provideKeepUiI18n };
727
1954
  //# sourceMappingURL=keepui-ui.mjs.map