From 7bfba5d3d163377c0786b01b9067253f03ed2a0b Mon Sep 17 00:00:00 2001 From: Valentin Gavran Date: Fri, 25 Nov 2022 11:39:16 +0100 Subject: [PATCH 1/4] refactor(Input): better support the handling of overflowing content BREAKING CHANGE: The internal CSS class `.anglify-input` was renamed to `.anglify-input-container`. --- libs/anglify/src/components/input/_dense.scss | 2 +- libs/anglify/src/components/input/_error.scss | 4 +- .../anglify/src/components/input/_filled.scss | 10 +-- .../src/components/input/_outlined.scss | 8 +- .../src/components/input/input.component.html | 4 +- .../src/components/input/input.component.scss | 81 +++++++++---------- .../components/select/select.component.scss | 2 +- .../text-field/text-field.component.scss | 1 + 8 files changed, 54 insertions(+), 58 deletions(-) diff --git a/libs/anglify/src/components/input/_dense.scss b/libs/anglify/src/components/input/_dense.scss index bf1769ad..1fed816c 100644 --- a/libs/anglify/src/components/input/_dense.scss +++ b/libs/anglify/src/components/input/_dense.scss @@ -2,7 +2,7 @@ @mixin dense { &.anglify-input-dense { - .anglify-input { + .anglify-input-container { min-height: var(--anglify-input-min-height-dense); } } diff --git a/libs/anglify/src/components/input/_error.scss b/libs/anglify/src/components/input/_error.scss index de269944..e2267e66 100644 --- a/libs/anglify/src/components/input/_error.scss +++ b/libs/anglify/src/components/input/_error.scss @@ -3,7 +3,7 @@ @mixin error { &.anglify-input-outlined { &.anglify-input-error:not(.anglify-input-disabled) { - .anglify-input { + .anglify-input-container { --anglify-notched-outline-border-color: #{$input-error-color}; label { @@ -20,7 +20,7 @@ &.anglify-input-filled { &.anglify-input-error:not(.anglify-input-disabled) { /* stylelint-disable-next-line no-descending-specificity */ - .anglify-input { + .anglify-input-container { label { color: $input-error-color; } diff --git a/libs/anglify/src/components/input/_filled.scss b/libs/anglify/src/components/input/_filled.scss index 46513628..ed2e8be3 100644 --- a/libs/anglify/src/components/input/_filled.scss +++ b/libs/anglify/src/components/input/_filled.scss @@ -1,7 +1,7 @@ @use 'variables' as *; @mixin anglify-input-filled { - .anglify-input { + .anglify-input-container { position: relative; overflow: hidden; background: $input-filled-background; @@ -51,7 +51,7 @@ &.anglify-input-floating, &.anglify-input-always-floating-label, &.anglify-input-focused { - .anglify-input { + .anglify-input-container { label { top: 0; width: calc( @@ -63,7 +63,7 @@ } &.anglify-input-disabled { - .anglify-input { + .anglify-input-container { label { color: $input-filled-label-color-disabled; } @@ -75,7 +75,7 @@ } &.anglify-input-focused:not(.anglify-input-disabled) { - .anglify-input { + .anglify-input-container { &::after { width: 100%; } @@ -89,7 +89,7 @@ // Two separate not selectors needed /* stylelint-disable-next-line selector-not-notation */ &:not(.anglify-input-focused):not(.anglify-input-disabled) { - .anglify-input { + .anglify-input-container { &:hover { --state-container-color: var(--color-state-inactive-hover); diff --git a/libs/anglify/src/components/input/_outlined.scss b/libs/anglify/src/components/input/_outlined.scss index 82414c4f..bc0bfd83 100644 --- a/libs/anglify/src/components/input/_outlined.scss +++ b/libs/anglify/src/components/input/_outlined.scss @@ -2,7 +2,7 @@ @use 'notched-outline' as *; @mixin anglify-input-outlined { - .anglify-input { + .anglify-input-container { position: relative; --anglify-notched-outline-border-color: #{$input-outlined-border-color-inactive}; @@ -41,7 +41,7 @@ &.anglify-input-floating, &.anglify-input-always-floating-label, &.anglify-input-focused { - .anglify-input { + .anglify-input-container { --anglify-notched-outline-notch-width: 0; label { @@ -58,7 +58,7 @@ } &.anglify-input-focused { - .anglify-input { + .anglify-input-container { --anglify-notched-outline-border-color: #{$input-outlined-border-color-focused}; --anglify-notched-outline-border-width: #{$input-outlined-border-width-focused}; @@ -69,7 +69,7 @@ } &.anglify-input-disabled { - .anglify-input { + .anglify-input-container { --anglify-notched-outline-border-color: #{$input-outlined-border-color-disabled}; --anglify-notched-outline-border-width: #{$input-outlined-border-width-inactive}; diff --git a/libs/anglify/src/components/input/input.component.html b/libs/anglify/src/components/input/input.component.html index dcb62366..cda340ba 100644 --- a/libs/anglify/src/components/input/input.component.html +++ b/libs/anglify/src/components/input/input.component.html @@ -2,14 +2,12 @@
-
+
- -
diff --git a/libs/anglify/src/components/input/input.component.scss b/libs/anglify/src/components/input/input.component.scss index 4ad25599..1eabd5b5 100644 --- a/libs/anglify/src/components/input/input.component.scss +++ b/libs/anglify/src/components/input/input.component.scss @@ -11,52 +11,60 @@ grid-template-columns: auto 1fr auto; .anglify-input-prepend { - display: flex; - align-items: center; + flex: 1 0 auto; grid-area: prepend; margin-inline-end: $input-gap; + } + + .anglify-input-append { + flex: 1 0 auto; + grid-area: append; + margin-inline-start: $input-gap; + } + + .anglify-input-prepend-inner, + .anglify-input-append-inner { + flex-grow: 0; + flex-shrink: 0; + } + + .anglify-input-prepend, + .anglify-input-append, + .anglify-input-prepend-inner, + .anglify-input-append-inner { + overflow: hidden; + align-self: center; &:empty { display: none; } } - .anglify-input { + .anglify-input-content { + min-width: 0; + flex: 1; + } + + .anglify-input-border { + position: absolute; + display: flex; + width: 100%; + height: 100%; + pointer-events: none; + user-select: none; + } + + .anglify-input-container { position: relative; display: flex; + min-width: 0; min-height: $input-min-height; box-sizing: border-box; + flex: 0 1 auto; border-radius: $input-border-radius; cursor: text; grid-area: input; - &-prepend-inner, - &-append-inner { - display: flex; - align-items: center; - justify-content: center; - - &:empty { - display: none; - } - } - - &-content { - display: flex; - flex-grow: 1; - flex-wrap: wrap; - align-items: center; - } - - &-border { - position: absolute; - display: flex; - width: 100%; - height: 100%; - pointer-events: none; - user-select: none; - } - label { position: relative; display: block; @@ -77,17 +85,6 @@ } } - .anglify-input-append { - display: flex; - align-items: center; - grid-area: append; - margin-inline-start: $input-gap; - - &:empty { - display: none; - } - } - &.anglify-input-filled { @include anglify-input-filled; } @@ -108,7 +105,7 @@ } &.anglify-input-disabled { - .anglify-input { + .anglify-input-container { cursor: auto; } } diff --git a/libs/anglify/src/components/select/select.component.scss b/libs/anglify/src/components/select/select.component.scss index 926c2183..17059af3 100644 --- a/libs/anglify/src/components/select/select.component.scss +++ b/libs/anglify/src/components/select/select.component.scss @@ -5,7 +5,7 @@ display: block; ::ng-deep { - .anglify-input { + .anglify-input-container { cursor: pointer !important; } diff --git a/libs/anglify/src/components/text-field/text-field.component.scss b/libs/anglify/src/components/text-field/text-field.component.scss index 2c6ec929..63de1a5e 100644 --- a/libs/anglify/src/components/text-field/text-field.component.scss +++ b/libs/anglify/src/components/text-field/text-field.component.scss @@ -12,6 +12,7 @@ anglify-input { ::ng-deep input { + width: 100%; min-width: var(--anglify-input-min-width); height: var(--anglify-input-min-height); min-height: var(--anglify-input-min-height); From 43853e7962685f4903eb42e510cd5f543f6c6885 Mon Sep 17 00:00:00 2001 From: Valentin Gavran Date: Fri, 25 Nov 2022 14:49:27 +0100 Subject: [PATCH 2/4] refactor: handle always floating label inside wrapper components BREAKING CHANGE: the simple input element (used by TextFields etc.) no longer has the alwaysFloatingLabel property. --- .../anglify/src/components/input/_filled.scss | 1 - .../src/components/input/_outlined.scss | 1 - .../src/components/input/input.component.ts | 4 -- .../text-area/text-area.component.html | 7 +++- .../text-area/text-area.component.scss | 3 +- .../text-area/text-area.component.ts | 37 +------------------ .../text-field/text-field.component.html | 7 +++- .../text-field/text-field.component.scss | 3 +- .../text-field/text-field.component.ts | 37 +------------------ 9 files changed, 14 insertions(+), 86 deletions(-) diff --git a/libs/anglify/src/components/input/_filled.scss b/libs/anglify/src/components/input/_filled.scss index ed2e8be3..15173d5e 100644 --- a/libs/anglify/src/components/input/_filled.scss +++ b/libs/anglify/src/components/input/_filled.scss @@ -49,7 +49,6 @@ } &.anglify-input-floating, - &.anglify-input-always-floating-label, &.anglify-input-focused { .anglify-input-container { label { diff --git a/libs/anglify/src/components/input/_outlined.scss b/libs/anglify/src/components/input/_outlined.scss index bc0bfd83..600007cf 100644 --- a/libs/anglify/src/components/input/_outlined.scss +++ b/libs/anglify/src/components/input/_outlined.scss @@ -39,7 +39,6 @@ } &.anglify-input-floating, - &.anglify-input-always-floating-label, &.anglify-input-focused { .anglify-input-container { --anglify-notched-outline-notch-width: 0; diff --git a/libs/anglify/src/components/input/input.component.ts b/libs/anglify/src/components/input/input.component.ts index 8c4789cf..3e695f11 100644 --- a/libs/anglify/src/components/input/input.component.ts +++ b/libs/anglify/src/components/input/input.component.ts @@ -91,10 +91,6 @@ export class InputComponent implements OnInit, AfterViewInit { @Input() public hideDetails = false; - @HostBinding('class.anglify-input-always-floating-label') - @Input() - public alwaysFloatingLabel = false; - @HostBinding('class.anglify-input-dense') @Input() public dense = false; diff --git a/libs/anglify/src/components/text-area/text-area.component.html b/libs/anglify/src/components/text-area/text-area.component.html index 614007f1..0def4af4 100644 --- a/libs/anglify/src/components/text-area/text-area.component.html +++ b/libs/anglify/src/components/text-area/text-area.component.html @@ -3,13 +3,16 @@ #anglifyInput [hint]="hint" [persistentHint]="persistentHint" - [alwaysFloatingLabel]="alwaysFloatingLabel" + [floating]="alwaysFloatingLabel || ((this.input?.floating$ | async) ?? false)" [hideDetails]="hideDetails" [counter]="counter" [length]="(input?.length$ | async) || 0" [maxLength]="(input?.maxLength$ | async) || undefined" - [error]="error" + [error]="(input?.invalid$ | async) || error" [inputId]="input?.id" + [disabled]="(input?.disabled$ | async) || false" + [focused]="(input?.focused$ | async) || false" + (onInputClick)="input?.elementRef?.nativeElement?.focus()" > diff --git a/libs/anglify/src/components/text-area/text-area.component.scss b/libs/anglify/src/components/text-area/text-area.component.scss index fb591044..06962d1a 100644 --- a/libs/anglify/src/components/text-area/text-area.component.scss +++ b/libs/anglify/src/components/text-area/text-area.component.scss @@ -37,8 +37,7 @@ } } - &.anglify-input-floating, - &.anglify-input-always-floating-label { + &.anglify-input-floating { ::ng-deep textarea::placeholder { color: var(--color-on-surface-medium-emphasis); } diff --git a/libs/anglify/src/components/text-area/text-area.component.ts b/libs/anglify/src/components/text-area/text-area.component.ts index a01bc0e6..e3923242 100644 --- a/libs/anglify/src/components/text-area/text-area.component.ts +++ b/libs/anglify/src/components/text-area/text-area.component.ts @@ -9,9 +9,7 @@ import { QueryList, Self, ViewChild, - type AfterViewInit, } from '@angular/core'; -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { SlotDirective } from '../../directives/slot/slot.directive'; import { SlotOutletDirective } from '../../directives/slot-outlet/slot-outlet.directive'; import { createSettingsProvider } from '../../factories/settings.factory'; @@ -22,7 +20,6 @@ import { InputAppearance } from '../input/input.interface'; import { DEFAULT_TEXT_AREA_SETTINGS, TEXT_AREA_SETTINGS } from './text-area-settings.token'; import { EntireTextAreaSettings } from './text-area.interface'; -@UntilDestroy() @Component({ selector: 'anglify-text-area', standalone: true, @@ -32,7 +29,7 @@ import { EntireTextAreaSettings } from './text-area.interface'; imports: [InputComponent, AsyncPipe, SlotDirective, SlotOutletDirective, FindSlotPipe], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TextAreaComponent implements AfterViewInit { +export class TextAreaComponent { @ContentChildren(SlotDirective) public readonly slots?: QueryList; @ContentChild(InputDirective) public readonly input?: InputDirective; @@ -80,36 +77,4 @@ export class TextAreaComponent implements AfterViewInit { @Input() public error?: string; public constructor(@Self() @Inject('anglifyTextAreaSettings') public settings: EntireTextAreaSettings) {} - - public ngAfterViewInit() { - this.anglifyInput.onInputClick.pipe(untilDestroyed(this)).subscribe(() => this.input?.elementRef.nativeElement.focus()); - - if (this.input) { - this.input.disabled$.pipe(untilDestroyed(this)).subscribe(disabled => - setTimeout(() => { - this.anglifyInput.disabled = disabled; - }, 0) - ); - - this.input.focused$.pipe(untilDestroyed(this)).subscribe(focused => - setTimeout(() => { - this.anglifyInput.focused = focused; - }, 0) - ); - - this.input.floating$.pipe(untilDestroyed(this)).subscribe(floating => - setTimeout(() => { - this.anglifyInput.floating = floating; - }, 0) - ); - - this.input.invalid$.pipe(untilDestroyed(this)).subscribe(invalid => - setTimeout(() => { - this.anglifyInput.error = invalid; - }, 0) - ); - } else { - console.warn('An textarea that has an anglifyInput directive must be added to the anglify-textarea component for it to work'); - } - } } diff --git a/libs/anglify/src/components/text-field/text-field.component.html b/libs/anglify/src/components/text-field/text-field.component.html index 26f8396d..3c1b4aac 100644 --- a/libs/anglify/src/components/text-field/text-field.component.html +++ b/libs/anglify/src/components/text-field/text-field.component.html @@ -3,14 +3,17 @@ #anglifyInput [hint]="hint" [persistentHint]="persistentHint" - [alwaysFloatingLabel]="alwaysFloatingLabel" + [floating]="(input?.floating$ | async) || alwaysFloatingLabel" [hideDetails]="hideDetails" [counter]="counter" [length]="(input?.length$ | async) || 0" [maxLength]="(input?.maxLength$ | async) || undefined" [dense]="dense" - [error]="error" + [error]="(input?.invalid$ | async) || error" [inputId]="input?.id" + [focused]="(input?.focused$ | async) ?? false" + [disabled]="(input?.disabled$ | async) ?? false" + (onInputClick)="input?.elementRef?.nativeElement?.focus()" > diff --git a/libs/anglify/src/components/text-field/text-field.component.scss b/libs/anglify/src/components/text-field/text-field.component.scss index 63de1a5e..4970a8e0 100644 --- a/libs/anglify/src/components/text-field/text-field.component.scss +++ b/libs/anglify/src/components/text-field/text-field.component.scss @@ -45,8 +45,7 @@ } } - &.anglify-input-floating, - &.anglify-input-always-floating-label { + &.anglify-input-floating { ::ng-deep input::placeholder { color: var(--color-on-surface-medium-emphasis); } diff --git a/libs/anglify/src/components/text-field/text-field.component.ts b/libs/anglify/src/components/text-field/text-field.component.ts index 845b2d59..29194966 100644 --- a/libs/anglify/src/components/text-field/text-field.component.ts +++ b/libs/anglify/src/components/text-field/text-field.component.ts @@ -9,9 +9,7 @@ import { QueryList, Self, ViewChild, - type AfterViewInit, } from '@angular/core'; -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { SlotDirective } from '../../directives/slot/slot.directive'; import { SlotOutletDirective } from '../../directives/slot-outlet/slot-outlet.directive'; import { createSettingsProvider } from '../../factories/settings.factory'; @@ -22,7 +20,6 @@ import { InputAppearance } from '../input/input.interface'; import { DEFAULT_TEXT_FIELD_SETTINGS, TEXT_FIELD_SETTINGS } from './text-field-settings.token'; import { EntireTextFieldSettings } from './text-field.interface'; -@UntilDestroy() @Component({ selector: 'anglify-text-field', standalone: true, @@ -34,7 +31,7 @@ import { EntireTextFieldSettings } from './text-field.interface'; imports: [InputComponent, AsyncPipe, SlotOutletDirective, FindSlotPipe, SlotDirective], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TextFieldComponent implements AfterViewInit { +export class TextFieldComponent { @ContentChildren(SlotDirective) public readonly slots?: QueryList; @ContentChild(InputDirective) public readonly input?: InputDirective; @@ -87,36 +84,4 @@ export class TextFieldComponent implements AfterViewInit { @Input() public dense = this.settings.dense; public constructor(@Self() @Inject('anglifyTextFieldSettings') public settings: EntireTextFieldSettings) {} - - public ngAfterViewInit() { - this.anglifyInput.onInputClick.pipe(untilDestroyed(this)).subscribe(() => this.input?.elementRef.nativeElement.focus()); - - if (this.input) { - this.input.disabled$.pipe(untilDestroyed(this)).subscribe(disabled => - setTimeout(() => { - this.anglifyInput.disabled = disabled; - }, 0) - ); - - this.input.focused$.pipe(untilDestroyed(this)).subscribe(focused => - setTimeout(() => { - this.anglifyInput.focused = focused; - }, 0) - ); - - this.input.floating$.pipe(untilDestroyed(this)).subscribe(floating => - setTimeout(() => { - this.anglifyInput.floating = floating; - }, 0) - ); - - this.input.invalid$.pipe(untilDestroyed(this)).subscribe(invalid => - setTimeout(() => { - this.anglifyInput.error = invalid; - }, 0) - ); - } else { - console.warn('An input field that has an anglifyInput directive must be added to the anglify-text-field component for it to work'); - } - } } From 98777463608004b27574fbdd57a46652a6ba3f91 Mon Sep 17 00:00:00 2001 From: Valentin Gavran Date: Fri, 25 Nov 2022 15:25:33 +0100 Subject: [PATCH 3/4] docs: improve copy code example button styling --- .../src/app/components/copy-button/copy-button.component.scss | 4 ++-- apps/docs/src/styles.scss | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/docs/src/app/components/copy-button/copy-button.component.scss b/apps/docs/src/app/components/copy-button/copy-button.component.scss index bc671103..7cd23982 100644 --- a/apps/docs/src/app/components/copy-button/copy-button.component.scss +++ b/apps/docs/src/app/components/copy-button/copy-button.component.scss @@ -1,5 +1,5 @@ :host { position: absolute; - top: 0; - right: 0; + top: 4px; + right: 4px; } diff --git a/apps/docs/src/styles.scss b/apps/docs/src/styles.scss index 4cb478f5..b36cdb43 100644 --- a/apps/docs/src/styles.scss +++ b/apps/docs/src/styles.scss @@ -131,7 +131,3 @@ pre { gap: 14px; } } - -input { - width: 0; -} From 0b5c8618dc728e6e2ffbdd516d7489c6ccb9e96c Mon Sep 17 00:00:00 2001 From: Valentin Gavran Date: Fri, 25 Nov 2022 13:36:01 +0100 Subject: [PATCH 4/4] refactor: refactor combobox, autocomplete & select BREAKING CHANGE: The Autocomplete, Combobox & Select components have been completely rewritten. This has changed the way they are used --- .../inputs-table/inputs-table.component.html | 2 +- .../inputs-table/inputs-table.component.ts | 10 +- .../access-value/access-value.component.html | 25 + .../access-value/access-value.component.scss | 6 + .../access-value/access-value.component.ts | 36 ++ .../autocomplete/chips/chips.component.html | 5 + .../autocomplete/chips/chips.component.scss | 8 + .../autocomplete/chips/chips.component.ts | 15 + .../control-values-manually.component.html | 12 + .../control-values-manually.component.scss | 11 + .../control-values-manually.component.ts | 25 + .../items-slot/items-slot.component.html | 20 + .../items-slot/items-slot.component.scss | 3 + .../items-slot/items-slot.component.ts | 30 + .../no-data-with-chips.component.html | 16 + .../no-data-with-chips.component.scss | 3 + .../no-data-with-chips.component.ts | 41 ++ .../access-value/access-value.component.html | 19 + .../access-value/access-value.component.scss | 6 + .../access-value/access-value.component.ts | 36 ++ .../combobox/chips/chips.component.html | 5 + .../combobox/chips/chips.component.scss | 8 + .../combobox/chips/chips.component.ts | 15 + .../control-values-manually.component.html | 12 + .../control-values-manually.component.scss | 11 + .../control-values-manually.component.ts | 25 + .../items-slot/items-slot.component.html | 20 + .../items-slot/items-slot.component.scss | 3 + .../items-slot/items-slot.component.ts | 30 + .../no-data-with-chips.component.html | 16 + .../no-data-with-chips.component.scss | 3 + .../no-data-with-chips.component.ts | 41 ++ apps/docs/src/app/examples/examples.ts | 24 + .../no-data-with-chips.component.html | 17 + .../no-data-with-chips.component.scss | 3 + .../no-data-with-chips.component.ts | 47 ++ .../autocomplete-playground.component.html | 34 +- .../autocomplete-playground.component.ts | 72 ++- .../combobox-playground.component.html | 34 +- .../combobox/combobox-playground.component.ts | 68 ++- .../divider/divider-playground.component.ts | 4 +- .../progress-linear-playground.component.ts | 14 +- .../select/select-playground.component.html | 27 +- .../select/select-playground.component.ts | 68 ++- .../simple-table-playground.component.html | 6 +- .../simple-table-playground.component.ts | 10 +- .../text-field-playground.component.html | 9 +- .../text-field-playground.component.ts | 27 +- .../assets/pages/components/autocomplete.md | 26 + .../src/assets/pages/components/combobox.md | 26 + .../src/assets/pages/components/select.md | 6 + apps/docs/src/styles.scss | 27 +- .../components/autocomplete/_variables.scss | 0 .../autocomplete-settings.token.ts | 23 + .../autocomplete/autocomplete.component.html | 218 ++++--- .../autocomplete/autocomplete.component.scss | 161 +++--- .../autocomplete/autocomplete.component.ts | 257 +++++++-- .../autocomplete/autocomplete.interface.ts | 76 +++ .../autocomplete/autocomplete.machine.drawio | 543 ++++++++++++++++++ .../autocomplete/autocomplete.machine.ts | 210 +++++++ .../src/components/combobox/_variables.scss | 0 .../combobox/combobox-settings.token.ts | 23 + .../combobox/combobox.component.html | 228 ++++---- .../combobox/combobox.component.scss | 161 +++--- .../components/combobox/combobox.component.ts | 277 +++++++-- .../components/combobox/combobox.interface.ts | 76 +++ .../combobox/combobox.machine.drawio | 448 +++++++++++++++ .../components/combobox/combobox.machine.ts | 210 +++++++ .../data-table/data-table.component.html | 4 +- .../data-table/services/pagination.service.ts | 25 +- .../components/menu/menu/menu.component.ts | 8 +- .../select/select-settings.token.ts | 17 +- .../components/select/select.component.html | 220 ++++--- .../components/select/select.component.scss | 144 +++-- .../src/components/select/select.component.ts | 430 ++++++-------- .../src/components/select/select.interface.ts | 75 ++- .../components/select/select.machine.drawio | 248 ++++++++ .../src/components/select/select.machine.ts | 145 +++++ .../pipes/find-slot/find-slot.pipe.spec.ts | 8 - .../src/pipes/select-item-viewer.pipe.ts | 20 + libs/anglify/src/utils/machine.ts | 56 ++ 81 files changed, 4249 insertions(+), 1129 deletions(-) create mode 100644 apps/docs/src/app/examples/autocomplete/access-value/access-value.component.html create mode 100644 apps/docs/src/app/examples/autocomplete/access-value/access-value.component.scss create mode 100644 apps/docs/src/app/examples/autocomplete/access-value/access-value.component.ts create mode 100644 apps/docs/src/app/examples/autocomplete/chips/chips.component.html create mode 100644 apps/docs/src/app/examples/autocomplete/chips/chips.component.scss create mode 100644 apps/docs/src/app/examples/autocomplete/chips/chips.component.ts create mode 100644 apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.html create mode 100644 apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.scss create mode 100644 apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.ts create mode 100644 apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.html create mode 100644 apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.scss create mode 100644 apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.ts create mode 100644 apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.html create mode 100644 apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.scss create mode 100644 apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.ts create mode 100644 apps/docs/src/app/examples/combobox/access-value/access-value.component.html create mode 100644 apps/docs/src/app/examples/combobox/access-value/access-value.component.scss create mode 100644 apps/docs/src/app/examples/combobox/access-value/access-value.component.ts create mode 100644 apps/docs/src/app/examples/combobox/chips/chips.component.html create mode 100644 apps/docs/src/app/examples/combobox/chips/chips.component.scss create mode 100644 apps/docs/src/app/examples/combobox/chips/chips.component.ts create mode 100644 apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.html create mode 100644 apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.scss create mode 100644 apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.ts create mode 100644 apps/docs/src/app/examples/combobox/items-slot/items-slot.component.html create mode 100644 apps/docs/src/app/examples/combobox/items-slot/items-slot.component.scss create mode 100644 apps/docs/src/app/examples/combobox/items-slot/items-slot.component.ts create mode 100644 apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.html create mode 100644 apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.scss create mode 100644 apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.ts create mode 100644 apps/docs/src/app/examples/examples.ts create mode 100644 apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.html create mode 100644 apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.scss create mode 100644 apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.ts delete mode 100644 libs/anglify/src/components/autocomplete/_variables.scss create mode 100644 libs/anglify/src/components/autocomplete/autocomplete-settings.token.ts create mode 100644 libs/anglify/src/components/autocomplete/autocomplete.interface.ts create mode 100644 libs/anglify/src/components/autocomplete/autocomplete.machine.drawio create mode 100644 libs/anglify/src/components/autocomplete/autocomplete.machine.ts delete mode 100644 libs/anglify/src/components/combobox/_variables.scss create mode 100644 libs/anglify/src/components/combobox/combobox-settings.token.ts create mode 100644 libs/anglify/src/components/combobox/combobox.interface.ts create mode 100644 libs/anglify/src/components/combobox/combobox.machine.drawio create mode 100644 libs/anglify/src/components/combobox/combobox.machine.ts create mode 100644 libs/anglify/src/components/select/select.machine.drawio create mode 100644 libs/anglify/src/components/select/select.machine.ts delete mode 100644 libs/anglify/src/pipes/find-slot/find-slot.pipe.spec.ts create mode 100644 libs/anglify/src/pipes/select-item-viewer.pipe.ts create mode 100644 libs/anglify/src/utils/machine.ts diff --git a/apps/docs/src/app/components/inputs-table/inputs-table.component.html b/apps/docs/src/app/components/inputs-table/inputs-table.component.html index 03b64fc2..516e521e 100644 --- a/apps/docs/src/app/components/inputs-table/inputs-table.component.html +++ b/apps/docs/src/app/components/inputs-table/inputs-table.component.html @@ -1,4 +1,4 @@ - + {{ c }} diff --git a/apps/docs/src/app/components/inputs-table/inputs-table.component.ts b/apps/docs/src/app/components/inputs-table/inputs-table.component.ts index 1be122c2..fb4027ae 100644 --- a/apps/docs/src/app/components/inputs-table/inputs-table.component.ts +++ b/apps/docs/src/app/components/inputs-table/inputs-table.component.ts @@ -2,10 +2,10 @@ import { ChipComponent, ItemGroupComponent, SlotDirective } from '@anglify/compo import { AsyncPipe, NgForOf, NgIf } from '@angular/common'; import { HttpClient, HttpClientModule } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { ReactiveFormsModule } from '@angular/forms'; import { UntilDestroy } from '@ngneat/until-destroy'; import { BehaviorSubject, combineLatest, type Observable } from 'rxjs'; -import { map, startWith, take } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import type { APIConfig, Documentation } from '../../app.interface'; import { ComponentAPIComponent } from '../component-api/component-api.component'; import { DirectiveAPIComponent } from '../directive-api/directive-api.component'; @@ -50,7 +50,7 @@ export class InputsTableComponent { this._interfaces$.next(value); } - public selection = new FormControl(0); + protected selection$ = new BehaviorSubject([0]); public constructor(private readonly httpClient: HttpClient) { this.httpClient @@ -94,9 +94,7 @@ export class InputsTableComponent { }) ); - public selectedName$ = combineLatest([this.selection.valueChanges.pipe(startWith(this.selection.value)), this.selectables$]).pipe( - map(([index, selectables]) => selectables[index!]) - ); + public selectedName$ = combineLatest([this.selection$, this.selectables$]).pipe(map(([index, selectables]) => selectables[index[0]!])); public components$ = combineLatest([this.documentation$, this.config$]).pipe( map(([documentation, config]) => { diff --git a/apps/docs/src/app/examples/autocomplete/access-value/access-value.component.html b/apps/docs/src/app/examples/autocomplete/access-value/access-value.component.html new file mode 100644 index 00000000..9c47ac95 --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/access-value/access-value.component.html @@ -0,0 +1,25 @@ +
+ + + Selected IDs: {{ selection1 }} +
+ +
+ + + Selected Names: {{ selection2 }} +
diff --git a/apps/docs/src/app/examples/autocomplete/access-value/access-value.component.scss b/apps/docs/src/app/examples/autocomplete/access-value/access-value.component.scss new file mode 100644 index 00000000..35e4c432 --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/access-value/access-value.component.scss @@ -0,0 +1,6 @@ +:host { + display: flex; + width: 100%; + flex-direction: column; + gap: 48px; +} diff --git a/apps/docs/src/app/examples/autocomplete/access-value/access-value.component.ts b/apps/docs/src/app/examples/autocomplete/access-value/access-value.component.ts new file mode 100644 index 00000000..2818ba94 --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/access-value/access-value.component.ts @@ -0,0 +1,36 @@ +import { + AutocompleteComponent, + SlotDirective, + ListItemComponent, + ListItemTitleComponent, + ListItemDescriptionComponent, + CheckboxComponent, +} from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { top10MovieNames, top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [ + CommonModule, + AutocompleteComponent, + SlotDirective, + ListItemComponent, + ListItemTitleComponent, + ListItemDescriptionComponent, + CheckboxComponent, + ], + templateUrl: './access-value.component.html', + styleUrls: ['./access-value.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class AccessValueComponent { + protected top10Movies = top10Movies; + + protected selection1 = ['1010', '1004']; + + protected top10MovieNames = top10MovieNames; + + protected selection2 = ['The Godfather']; +} diff --git a/apps/docs/src/app/examples/autocomplete/chips/chips.component.html b/apps/docs/src/app/examples/autocomplete/chips/chips.component.html new file mode 100644 index 00000000..fb148e87 --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/chips/chips.component.html @@ -0,0 +1,5 @@ + + + {{ item.label }} + + diff --git a/apps/docs/src/app/examples/autocomplete/chips/chips.component.scss b/apps/docs/src/app/examples/autocomplete/chips/chips.component.scss new file mode 100644 index 00000000..60c7d9be --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/chips/chips.component.scss @@ -0,0 +1,8 @@ +:host { + width: 100%; + + anglify-chip { + --anglify-chip-height: 14px; + --anglify-chip-padding: 5px 10px; + } +} diff --git a/apps/docs/src/app/examples/autocomplete/chips/chips.component.ts b/apps/docs/src/app/examples/autocomplete/chips/chips.component.ts new file mode 100644 index 00000000..b21b1dcd --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/chips/chips.component.ts @@ -0,0 +1,15 @@ +import { AutocompleteComponent, ChipComponent, SlotDirective } from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [CommonModule, AutocompleteComponent, SlotDirective, ChipComponent], + templateUrl: './chips.component.html', + styleUrls: ['./chips.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class ChipsComponent { + protected top10Movies = top10Movies; +} diff --git a/apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.html b/apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.html new file mode 100644 index 00000000..ac1ae075 --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.html @@ -0,0 +1,12 @@ + + +Selected IDs: {{ selection }} + diff --git a/apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.scss b/apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.scss new file mode 100644 index 00000000..33f90ceb --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.scss @@ -0,0 +1,11 @@ +:host { + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + gap: 24px; + + anglify-autocomplete { + min-width: 100%; + } +} diff --git a/apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.ts b/apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.ts new file mode 100644 index 00000000..9ed8a235 --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/control-values-manually/control-values-manually.component.ts @@ -0,0 +1,25 @@ +import { AutocompleteComponent, ButtonComponent, SlotDirective } from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [CommonModule, AutocompleteComponent, SlotDirective, ButtonComponent], + templateUrl: './control-values-manually.component.html', + styleUrls: ['./control-values-manually.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class ControlValuesManuallyComponent { + protected top10Movies = top10Movies; + + protected selection = ['1010', '1004']; + + protected toggleFightClub() { + if (this.selection.includes('1010')) { + this.selection = this.selection.filter(id => id !== '1010'); + } else { + this.selection = [...this.selection, '1010']; + } + } +} diff --git a/apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.html b/apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.html new file mode 100644 index 00000000..427fc33a --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.html @@ -0,0 +1,20 @@ + + + + {{ item.label }} + {{ item.year }} + + + + + + diff --git a/apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.scss b/apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.scss new file mode 100644 index 00000000..b9bc65ea --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.scss @@ -0,0 +1,3 @@ +:host { + width: 100%; +} diff --git a/apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.ts b/apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.ts new file mode 100644 index 00000000..8b937773 --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/items-slot/items-slot.component.ts @@ -0,0 +1,30 @@ +import { + AutocompleteComponent, + CheckboxComponent, + ListItemComponent, + ListItemDescriptionComponent, + ListItemTitleComponent, + SlotDirective, +} from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [ + CommonModule, + AutocompleteComponent, + SlotDirective, + ListItemComponent, + ListItemTitleComponent, + ListItemDescriptionComponent, + CheckboxComponent, + ], + templateUrl: './items-slot.component.html', + styleUrls: ['./items-slot.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class ItemsSlotComponent { + protected top10Movies = top10Movies; +} diff --git a/apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.html b/apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.html new file mode 100644 index 00000000..a4a39476 --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.html @@ -0,0 +1,16 @@ + + + {{ item.label }} + + + + + + + No data available + + + + + + diff --git a/apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.scss b/apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.scss new file mode 100644 index 00000000..b9bc65ea --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.scss @@ -0,0 +1,3 @@ +:host { + width: 100%; +} diff --git a/apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.ts b/apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.ts new file mode 100644 index 00000000..9e5eed00 --- /dev/null +++ b/apps/docs/src/app/examples/autocomplete/no-data-with-chips/no-data-with-chips.component.ts @@ -0,0 +1,41 @@ +import { + AutocompleteComponent, + ButtonComponent, + ChipComponent, + ClickStopPropagationDirective, + IconComponent, + ListItemComponent, + ListItemTitleComponent, + SlotDirective, + SnackbarService, +} from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [ + CommonModule, + AutocompleteComponent, + SlotDirective, + ListItemComponent, + ListItemTitleComponent, + ChipComponent, + IconComponent, + ButtonComponent, + ClickStopPropagationDirective, + ], + templateUrl: './no-data-with-chips.component.html', + styleUrls: ['./no-data-with-chips.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class NoDataWithChipsComponent { + protected top10Movies = top10Movies; + + public constructor(private readonly snackbar: SnackbarService) {} + + protected openDocs() { + this.snackbar.open({ data: { label: 'Open docs clicked' } }); + } +} diff --git a/apps/docs/src/app/examples/combobox/access-value/access-value.component.html b/apps/docs/src/app/examples/combobox/access-value/access-value.component.html new file mode 100644 index 00000000..1ec25b2e --- /dev/null +++ b/apps/docs/src/app/examples/combobox/access-value/access-value.component.html @@ -0,0 +1,19 @@ +
+ + + Selected IDs: {{ selection1 }} +
+ +
+ + + Selected Names: {{ selection2 }} +
diff --git a/apps/docs/src/app/examples/combobox/access-value/access-value.component.scss b/apps/docs/src/app/examples/combobox/access-value/access-value.component.scss new file mode 100644 index 00000000..35e4c432 --- /dev/null +++ b/apps/docs/src/app/examples/combobox/access-value/access-value.component.scss @@ -0,0 +1,6 @@ +:host { + display: flex; + width: 100%; + flex-direction: column; + gap: 48px; +} diff --git a/apps/docs/src/app/examples/combobox/access-value/access-value.component.ts b/apps/docs/src/app/examples/combobox/access-value/access-value.component.ts new file mode 100644 index 00000000..e4639210 --- /dev/null +++ b/apps/docs/src/app/examples/combobox/access-value/access-value.component.ts @@ -0,0 +1,36 @@ +import { + CheckboxComponent, + ComboboxComponent, + ListItemComponent, + ListItemDescriptionComponent, + ListItemTitleComponent, + SlotDirective, +} from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { top10MovieNames, top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [ + CommonModule, + ComboboxComponent, + SlotDirective, + ListItemComponent, + ListItemTitleComponent, + ListItemDescriptionComponent, + CheckboxComponent, + ], + templateUrl: './access-value.component.html', + styleUrls: ['./access-value.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class AccessValueComponent { + protected top10Movies = top10Movies; + + protected selection1 = ['1010', '1004']; + + protected top10MovieNames = top10MovieNames; + + protected selection2 = ['The Godfather']; +} diff --git a/apps/docs/src/app/examples/combobox/chips/chips.component.html b/apps/docs/src/app/examples/combobox/chips/chips.component.html new file mode 100644 index 00000000..b415f3a8 --- /dev/null +++ b/apps/docs/src/app/examples/combobox/chips/chips.component.html @@ -0,0 +1,5 @@ + + + {{ item.label }} + + diff --git a/apps/docs/src/app/examples/combobox/chips/chips.component.scss b/apps/docs/src/app/examples/combobox/chips/chips.component.scss new file mode 100644 index 00000000..60c7d9be --- /dev/null +++ b/apps/docs/src/app/examples/combobox/chips/chips.component.scss @@ -0,0 +1,8 @@ +:host { + width: 100%; + + anglify-chip { + --anglify-chip-height: 14px; + --anglify-chip-padding: 5px 10px; + } +} diff --git a/apps/docs/src/app/examples/combobox/chips/chips.component.ts b/apps/docs/src/app/examples/combobox/chips/chips.component.ts new file mode 100644 index 00000000..eadfa95a --- /dev/null +++ b/apps/docs/src/app/examples/combobox/chips/chips.component.ts @@ -0,0 +1,15 @@ +import { ChipComponent, ComboboxComponent, SlotDirective } from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [CommonModule, ComboboxComponent, SlotDirective, ChipComponent], + templateUrl: './chips.component.html', + styleUrls: ['./chips.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class ChipsComponent { + protected top10Movies = top10Movies; +} diff --git a/apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.html b/apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.html new file mode 100644 index 00000000..bf940222 --- /dev/null +++ b/apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.html @@ -0,0 +1,12 @@ + + +Selected IDs: {{ selection }} + diff --git a/apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.scss b/apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.scss new file mode 100644 index 00000000..33f90ceb --- /dev/null +++ b/apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.scss @@ -0,0 +1,11 @@ +:host { + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + gap: 24px; + + anglify-autocomplete { + min-width: 100%; + } +} diff --git a/apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.ts b/apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.ts new file mode 100644 index 00000000..2b7725a4 --- /dev/null +++ b/apps/docs/src/app/examples/combobox/control-values-manually/control-values-manually.component.ts @@ -0,0 +1,25 @@ +import { ButtonComponent, ComboboxComponent, SlotDirective } from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [CommonModule, ComboboxComponent, SlotDirective, ButtonComponent], + templateUrl: './control-values-manually.component.html', + styleUrls: ['./control-values-manually.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class ControlValuesManuallyComponent { + protected top10Movies = top10Movies; + + protected selection = ['1010', '1004']; + + protected toggleFightClub() { + if (this.selection.includes('1010')) { + this.selection = this.selection.filter(id => id !== '1010'); + } else { + this.selection = [...this.selection, '1010']; + } + } +} diff --git a/apps/docs/src/app/examples/combobox/items-slot/items-slot.component.html b/apps/docs/src/app/examples/combobox/items-slot/items-slot.component.html new file mode 100644 index 00000000..5999f376 --- /dev/null +++ b/apps/docs/src/app/examples/combobox/items-slot/items-slot.component.html @@ -0,0 +1,20 @@ + + + + {{ item.label }} + {{ item.year }} + + + + + + diff --git a/apps/docs/src/app/examples/combobox/items-slot/items-slot.component.scss b/apps/docs/src/app/examples/combobox/items-slot/items-slot.component.scss new file mode 100644 index 00000000..b9bc65ea --- /dev/null +++ b/apps/docs/src/app/examples/combobox/items-slot/items-slot.component.scss @@ -0,0 +1,3 @@ +:host { + width: 100%; +} diff --git a/apps/docs/src/app/examples/combobox/items-slot/items-slot.component.ts b/apps/docs/src/app/examples/combobox/items-slot/items-slot.component.ts new file mode 100644 index 00000000..6af77aee --- /dev/null +++ b/apps/docs/src/app/examples/combobox/items-slot/items-slot.component.ts @@ -0,0 +1,30 @@ +import { + CheckboxComponent, + ComboboxComponent, + ListItemComponent, + ListItemDescriptionComponent, + ListItemTitleComponent, + SlotDirective, +} from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [ + CommonModule, + ComboboxComponent, + SlotDirective, + ListItemComponent, + ListItemTitleComponent, + ListItemDescriptionComponent, + CheckboxComponent, + ], + templateUrl: './items-slot.component.html', + styleUrls: ['./items-slot.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class ItemsSlotComponent { + protected top10Movies = top10Movies; +} diff --git a/apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.html b/apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.html new file mode 100644 index 00000000..c3a43d0a --- /dev/null +++ b/apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.html @@ -0,0 +1,16 @@ + + + {{ item.label }} + + + + + + + No data available + + + + + + diff --git a/apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.scss b/apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.scss new file mode 100644 index 00000000..b9bc65ea --- /dev/null +++ b/apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.scss @@ -0,0 +1,3 @@ +:host { + width: 100%; +} diff --git a/apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.ts b/apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.ts new file mode 100644 index 00000000..2b41ed98 --- /dev/null +++ b/apps/docs/src/app/examples/combobox/no-data-with-chips/no-data-with-chips.component.ts @@ -0,0 +1,41 @@ +import { + ButtonComponent, + ChipComponent, + ClickStopPropagationDirective, + ComboboxComponent, + IconComponent, + ListItemComponent, + ListItemTitleComponent, + SlotDirective, + SnackbarService, +} from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [ + CommonModule, + ComboboxComponent, + SlotDirective, + ListItemComponent, + ListItemTitleComponent, + ChipComponent, + IconComponent, + ButtonComponent, + ClickStopPropagationDirective, + ], + templateUrl: './no-data-with-chips.component.html', + styleUrls: ['./no-data-with-chips.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class NoDataWithChipsComponent { + protected top10Movies = top10Movies; + + public constructor(private readonly snackbar: SnackbarService) {} + + protected openDocs() { + this.snackbar.open({ data: { label: 'Open docs clicked' } }); + } +} diff --git a/apps/docs/src/app/examples/examples.ts b/apps/docs/src/app/examples/examples.ts new file mode 100644 index 00000000..3211acbc --- /dev/null +++ b/apps/docs/src/app/examples/examples.ts @@ -0,0 +1,24 @@ +export type Movie = { + id: string; + label: string; + year: number; +}; + +export const top10Movies: Movie[] = [ + { id: '1001', label: 'The Shawshank Redemption', year: 1_994 }, + { id: '1002', label: 'The Godfather', year: 1_972 }, + { id: '1003', label: 'The Godfather: Part II', year: 1_974 }, + { id: '1004', label: 'The Dark Knight', year: 2_008 }, + { id: '1005', label: '12 Angry Men', year: 1_957 }, + { id: '1006', label: "Schindler's List", year: 1_993 }, + { id: '1007', label: 'Pulp Fiction', year: 1_994 }, + { + id: '1008', + label: 'The Lord of the Rings: The Return of the King', + year: 2_003, + }, + { id: '1009', label: 'The Good, the Bad and the Ugly', year: 1_966 }, + { id: '1010', label: 'Fight Club', year: 1_999 }, +]; + +export const top10MovieNames = top10Movies.map(movie => movie.label); diff --git a/apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.html b/apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.html new file mode 100644 index 00000000..f60d1f2a --- /dev/null +++ b/apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.html @@ -0,0 +1,17 @@ + + + {{ item.label }} + + + + + + + No data available + + + + + + + diff --git a/apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.scss b/apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.scss new file mode 100644 index 00000000..b9bc65ea --- /dev/null +++ b/apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.scss @@ -0,0 +1,3 @@ +:host { + width: 100%; +} diff --git a/apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.ts b/apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.ts new file mode 100644 index 00000000..af40e2f0 --- /dev/null +++ b/apps/docs/src/app/examples/select/no-data-with-chips/no-data-with-chips.component.ts @@ -0,0 +1,47 @@ +import { + ButtonComponent, + ChipComponent, + ClickStopPropagationDirective, + IconComponent, + ListItemComponent, + ListItemTitleComponent, + SelectComponent, + SlotDirective, + SnackbarService, +} from '@anglify/components'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import type { Movie } from '../../examples'; +import { top10Movies } from '../../examples'; + +@Component({ + standalone: true, + imports: [ + CommonModule, + SelectComponent, + SlotDirective, + ListItemComponent, + ListItemTitleComponent, + ChipComponent, + IconComponent, + ButtonComponent, + ClickStopPropagationDirective, + ], + templateUrl: './no-data-with-chips.component.html', + styleUrls: ['./no-data-with-chips.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class NoDataWithChipsComponent { + protected top10Movies: Movie[] = top10Movies; + + public constructor(private readonly snackbar: SnackbarService) {} + + protected toggleItems() { + if (this.top10Movies.length === 0) this.top10Movies = top10Movies; + else this.top10Movies = []; + } + + protected openDocs() { + this.snackbar.open({ data: { label: 'Open docs clicked' } }); + } +} diff --git a/apps/docs/src/app/playgrounds/autocomplete/autocomplete-playground.component.html b/apps/docs/src/app/playgrounds/autocomplete/autocomplete-playground.component.html index 0f5a89ec..df3db735 100644 --- a/apps/docs/src/app/playgrounds/autocomplete/autocomplete-playground.component.html +++ b/apps/docs/src/app/playgrounds/autocomplete/autocomplete-playground.component.html @@ -1,6 +1,7 @@
{{ label }}
+ + + + + + + + + +
Selection{{ value | json }}
+
+
- - Label + - - Placeholder + - - Hint + + + + + + +
Appearance: Filled Outlined
- Readonly Disabled Clearable Multiple - Close on select Always floating label Persistent hint Hide details diff --git a/apps/docs/src/app/playgrounds/autocomplete/autocomplete-playground.component.ts b/apps/docs/src/app/playgrounds/autocomplete/autocomplete-playground.component.ts index 584b9cf7..ec53b1ec 100644 --- a/apps/docs/src/app/playgrounds/autocomplete/autocomplete-playground.component.ts +++ b/apps/docs/src/app/playgrounds/autocomplete/autocomplete-playground.component.ts @@ -1,67 +1,63 @@ +import type { InputAppearance } from '@anglify/components'; import { + ExpansionPanelComponent, + ExpansionPanelsComponent, AutocompleteComponent, - SlotDirective, CheckboxComponent, - RadioButtonComponent, InputDirective, + RadioButtonComponent, + SlotDirective, TextFieldComponent, } from '@anglify/components'; +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { top10Movies } from '../../examples/examples'; @Component({ standalone: true, - imports: [AutocompleteComponent, SlotDirective, FormsModule, CheckboxComponent, RadioButtonComponent, InputDirective, TextFieldComponent], + imports: [ + CommonModule, + AutocompleteComponent, + SlotDirective, + FormsModule, + CheckboxComponent, + RadioButtonComponent, + InputDirective, + TextFieldComponent, + ExpansionPanelsComponent, + ExpansionPanelComponent, + ], templateUrl: './autocomplete-playground.component.html', styleUrls: ['./autocomplete-playground.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AutocompletePlaygroundComponent { - public appearance: 'filled' | 'outlined' = 'filled'; + protected value: any[] = []; + + protected appearance: InputAppearance = 'outlined'; - public label = 'Label'; + protected label = 'Best movie'; - public placeholder = 'Placeholder'; + protected placeholder = 'Placeholder'; - public hint = 'Hint'; + protected hint = 'Hint'; - public readonly = false; + protected itemTextKey = 'label'; - public disabled = false; + protected itemValueKey = undefined; - public clearable = false; + protected disabled = false; - public multiple = false; + protected clearable = false; - public closeOnSelect = true; + protected multiple = false; - public alwaysFloatingLabel = false; + protected alwaysFloatingLabel = false; - public persistentHint = false; + protected persistentHint = false; - public hideDetails = false; + protected hideDetails = false; - public readonly items = [ - 'test', - 'test 1', - 'test 2', - 'test 3', - 'test 4', - 'test 5', - 'test 6', - 'test 7', - 'test 8', - 'test 9', - 'test 10', - 'test 11', - 'test 12', - 'test 13', - 'test 14', - 'test 15', - 'test 16', - 'test 17', - 'test 18', - 'test 19', - 'test 20', - ]; + protected readonly items = top10Movies; } diff --git a/apps/docs/src/app/playgrounds/combobox/combobox-playground.component.html b/apps/docs/src/app/playgrounds/combobox/combobox-playground.component.html index 5b5abe00..19f93ca6 100644 --- a/apps/docs/src/app/playgrounds/combobox/combobox-playground.component.html +++ b/apps/docs/src/app/playgrounds/combobox/combobox-playground.component.html @@ -1,6 +1,7 @@
{{ label }}
+ + + + + + + + + +
Selection{{ value | json }}
+
+
- - Label + - - Placeholder + - - Hint + + + + + + +
Appearance: Filled Outlined
- Readonly Disabled Clearable Multiple - Close on select Always floating label Persistent hint Hide details diff --git a/apps/docs/src/app/playgrounds/combobox/combobox-playground.component.ts b/apps/docs/src/app/playgrounds/combobox/combobox-playground.component.ts index 42be5939..01e69f38 100644 --- a/apps/docs/src/app/playgrounds/combobox/combobox-playground.component.ts +++ b/apps/docs/src/app/playgrounds/combobox/combobox-playground.component.ts @@ -1,67 +1,63 @@ +import type { InputAppearance } from '@anglify/components'; import { CheckboxComponent, ComboboxComponent, + ExpansionPanelComponent, + ExpansionPanelsComponent, InputDirective, RadioButtonComponent, SlotDirective, TextFieldComponent, } from '@anglify/components'; +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { top10Movies } from '../../examples/examples'; @Component({ standalone: true, - imports: [ComboboxComponent, SlotDirective, FormsModule, CheckboxComponent, RadioButtonComponent, InputDirective, TextFieldComponent], + imports: [ + CommonModule, + ComboboxComponent, + SlotDirective, + FormsModule, + CheckboxComponent, + RadioButtonComponent, + InputDirective, + TextFieldComponent, + ExpansionPanelsComponent, + ExpansionPanelComponent, + ], templateUrl: './combobox-playground.component.html', styleUrls: ['./combobox-playground.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxPlaygroundComponent { - public appearance: 'filled' | 'outlined' = 'filled'; + protected value: any[] = []; - public label = 'Label'; + protected appearance: InputAppearance = 'outlined'; - public placeholder = 'Placeholder'; + protected label = 'Best movie'; - public hint = 'Hint'; + protected placeholder = 'Placeholder'; - public readonly = false; + protected hint = 'Hint'; - public disabled = false; + protected itemTextKey = 'label'; - public clearable = false; + protected itemValueKey = undefined; - public multiple = false; + protected disabled = false; - public closeOnSelect = true; + protected clearable = false; - public alwaysFloatingLabel = false; + protected multiple = false; - public persistentHint = false; + protected alwaysFloatingLabel = false; - public hideDetails = false; + protected persistentHint = false; - public readonly items = [ - 'test', - 'test 1', - 'test 2', - 'test 3', - 'test 4', - 'test 5', - 'test 6', - 'test 7', - 'test 8', - 'test 9', - 'test 10', - 'test 11', - 'test 12', - 'test 13', - 'test 14', - 'test 15', - 'test 16', - 'test 17', - 'test 18', - 'test 19', - 'test 20', - ]; + protected hideDetails = false; + + protected readonly items = top10Movies; } diff --git a/apps/docs/src/app/playgrounds/divider/divider-playground.component.ts b/apps/docs/src/app/playgrounds/divider/divider-playground.component.ts index 6c634692..9fb4b3e5 100644 --- a/apps/docs/src/app/playgrounds/divider/divider-playground.component.ts +++ b/apps/docs/src/app/playgrounds/divider/divider-playground.component.ts @@ -11,7 +11,7 @@ import { FormsModule } from '@angular/forms'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class DividerPlaygroundComponent { - public vertical = false; + protected vertical = false; - public inset = false; + protected inset = false; } diff --git a/apps/docs/src/app/playgrounds/progress-linear/progress-linear-playground.component.ts b/apps/docs/src/app/playgrounds/progress-linear/progress-linear-playground.component.ts index f4df0d21..fad03c70 100644 --- a/apps/docs/src/app/playgrounds/progress-linear/progress-linear-playground.component.ts +++ b/apps/docs/src/app/playgrounds/progress-linear/progress-linear-playground.component.ts @@ -11,22 +11,22 @@ import { FormsModule } from '@angular/forms'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProgressLinearPlaygroundComponent { - public mode = 'indeterminate'; + protected mode = 'indeterminate'; - public progress = 60; + protected progress = 60; - public buffer = 70; + protected buffer = 70; - public stream = true; + protected stream = true; - public size = 4; + protected size = 4; @HostBinding('style.--docs-progress-linear-playground-size') - public get getSizeInPx() { + protected get getSizeInPx() { return `${this.size}px`; } - public get isIndeterminate() { + protected get isIndeterminate() { return this.mode === 'indeterminate'; } } diff --git a/apps/docs/src/app/playgrounds/select/select-playground.component.html b/apps/docs/src/app/playgrounds/select/select-playground.component.html index bac5ecd4..41a638cc 100644 --- a/apps/docs/src/app/playgrounds/select/select-playground.component.html +++ b/apps/docs/src/app/playgrounds/select/select-playground.component.html @@ -1,6 +1,7 @@
{{ label }}
+ + + + + + + + + +
Selection{{ value | json }}
+
+
- - Label + - - Placeholder + - - Hint +
@@ -35,11 +44,9 @@ Filled Outlined
- Readonly Disabled Clearable Multiple - Close on select Always floating label Persistent hint Hide details diff --git a/apps/docs/src/app/playgrounds/select/select-playground.component.ts b/apps/docs/src/app/playgrounds/select/select-playground.component.ts index d078f946..54101544 100644 --- a/apps/docs/src/app/playgrounds/select/select-playground.component.ts +++ b/apps/docs/src/app/playgrounds/select/select-playground.component.ts @@ -1,4 +1,7 @@ +import type { InputAppearance } from '@anglify/components'; import { + ExpansionPanelComponent, + ExpansionPanelsComponent, CheckboxComponent, InputDirective, RadioButtonComponent, @@ -6,62 +9,55 @@ import { SlotDirective, TextFieldComponent, } from '@anglify/components'; +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { top10Movies } from '../../examples/examples'; @Component({ standalone: true, - imports: [SelectComponent, TextFieldComponent, CheckboxComponent, RadioButtonComponent, InputDirective, FormsModule, SlotDirective], + imports: [ + CommonModule, + SelectComponent, + TextFieldComponent, + CheckboxComponent, + RadioButtonComponent, + InputDirective, + FormsModule, + SlotDirective, + ExpansionPanelsComponent, + ExpansionPanelComponent, + ], templateUrl: './select-playground.component.html', styleUrls: ['./select-playground.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SelectPlaygroundComponent { - public appearance: 'filled' | 'outlined' = 'filled'; + protected value: any[] = []; - public label = 'Label'; + protected appearance: InputAppearance = 'outlined'; - public placeholder = 'Placeholder'; + protected label = 'Best movie'; - public hint = 'Hint'; + protected placeholder = 'Placeholder'; - public readonly = false; + protected hint = 'Hint'; - public disabled = false; + protected readonly = false; - public clearable = false; + protected disabled = false; - public multiple = false; + protected clearable = false; - public closeOnSelect = true; + protected multiple = false; - public alwaysFloatingLabel = false; + protected closeOnSelect = true; - public persistentHint = false; + protected alwaysFloatingLabel = false; - public hideDetails = false; + protected persistentHint = false; - public readonly items = [ - 'test', - 'test 1', - 'test 2', - 'test 3', - 'test 4', - 'test 5', - 'test 6', - 'test 7', - 'test 8', - 'test 9', - 'test 10', - 'test 11', - 'test 12', - 'test 13', - 'test 14', - 'test 15', - 'test 16', - 'test 17', - 'test 18', - 'test 19', - 'test 20', - ]; + protected hideDetails = false; + + protected readonly items = top10Movies; } diff --git a/apps/docs/src/app/playgrounds/simple-table/simple-table-playground.component.html b/apps/docs/src/app/playgrounds/simple-table/simple-table-playground.component.html index 89f2bc79..bf1e57d0 100644 --- a/apps/docs/src/app/playgrounds/simple-table/simple-table-playground.component.html +++ b/apps/docs/src/app/playgrounds/simple-table/simple-table-playground.component.html @@ -22,10 +22,10 @@
- Fixed header - Fixed footer - + + Fixed header + Fixed footer
diff --git a/apps/docs/src/app/playgrounds/simple-table/simple-table-playground.component.ts b/apps/docs/src/app/playgrounds/simple-table/simple-table-playground.component.ts index fbdb5212..b1eba1f5 100644 --- a/apps/docs/src/app/playgrounds/simple-table/simple-table-playground.component.ts +++ b/apps/docs/src/app/playgrounds/simple-table/simple-table-playground.component.ts @@ -11,13 +11,13 @@ import { FormsModule } from '@angular/forms'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SimpleTablePlaygroundComponent { - public fixedHeader = false; + protected fixedHeader = false; - public fixedFooter = false; + protected fixedFooter = false; - public _fixedHeight: string | null = null; + protected _fixedHeight: string | null = null; - public readonly desserts = [ + protected readonly desserts = [ { name: 'Frozen Yogurt', calories: 159, @@ -60,7 +60,7 @@ export class SimpleTablePlaygroundComponent { }, ]; - public get fixedHeight() { + protected get fixedHeight() { return this._fixedHeight; } } diff --git a/apps/docs/src/app/playgrounds/text-field/text-field-playground.component.html b/apps/docs/src/app/playgrounds/text-field/text-field-playground.component.html index 0a11d87a..d4068e83 100644 --- a/apps/docs/src/app/playgrounds/text-field/text-field-playground.component.html +++ b/apps/docs/src/app/playgrounds/text-field/text-field-playground.component.html @@ -24,16 +24,13 @@
- - Label + - - Placeholder + - - Hint +
diff --git a/apps/docs/src/app/playgrounds/text-field/text-field-playground.component.ts b/apps/docs/src/app/playgrounds/text-field/text-field-playground.component.ts index f29e0e45..2c9bfe15 100644 --- a/apps/docs/src/app/playgrounds/text-field/text-field-playground.component.ts +++ b/apps/docs/src/app/playgrounds/text-field/text-field-playground.component.ts @@ -1,3 +1,4 @@ +import type { InputAppearance } from '@anglify/components'; import { CheckboxComponent, IconComponent, @@ -18,29 +19,29 @@ import { FormsModule } from '@angular/forms'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class TextFieldPlaygroundComponent { - public appearance: 'filled' | 'outlined' = 'filled'; + protected appearance: InputAppearance = 'outlined'; - public label = 'Label'; + protected label = 'Label'; - public placeholder = 'Placeholder'; + protected placeholder = 'Placeholder'; - public hint = 'Hint'; + protected hint = 'Hint'; - public readonly = false; + protected readonly = false; - public disabled = false; + protected disabled = false; - public alwaysFloatingLabel = false; + protected alwaysFloatingLabel = false; - public persistentHint = false; + protected persistentHint = false; - public prependIcon = false; + protected prependIcon = false; - public prependOuterIcon = false; + protected prependOuterIcon = false; - public appendIcon = false; + protected appendIcon = false; - public appendOuterIcon = false; + protected appendOuterIcon = false; - public hideDetails = false; + protected hideDetails = false; } diff --git a/apps/docs/src/assets/pages/components/autocomplete.md b/apps/docs/src/assets/pages/components/autocomplete.md index 6f43d590..18893487 100644 --- a/apps/docs/src/assets/pages/components/autocomplete.md +++ b/apps/docs/src/assets/pages/components/autocomplete.md @@ -8,6 +8,32 @@ bundle-size="https://bundlephobia.com/package/@anglify/components@latest"/> +## Examples + +### Chips + + + +### Item slot + + + +### Access value + +**When using objects**: Notice the properties `itemTextKey` & `itemValueKey`. These properties can be used to control which field is displayed and which field contains the desired value. If `itemValueKey` is not passed, then the whole object will be returned when the respective item is selected. + +**Primitive values:** In the second example you can see how the component behaves with primitive values (in this case strings). Here the previously mentioned input properties are no longer necessary, since no objects were passed. + + + +### Control values manually + + + +### No data with chips + + + ## API ```typescript diff --git a/apps/docs/src/assets/pages/components/combobox.md b/apps/docs/src/assets/pages/components/combobox.md index 3d9855e0..020dcf6a 100644 --- a/apps/docs/src/assets/pages/components/combobox.md +++ b/apps/docs/src/assets/pages/components/combobox.md @@ -9,6 +9,32 @@ w3c="https://www.w3.org/WAI/ARIA/apg/patterns/combobox/"/> +## Examples + +### Chips + + + +### Item slot + + + +### Access value + +**When using objects**: Notice the properties `itemTextKey` & `itemValueKey`. These properties can be used to control which field is displayed and which field contains the desired value. If `itemValueKey` is not passed, then the whole object will be returned when the respective item is selected. + +**Primitive values:** In the second example you can see how the component behaves with primitive values (in this case strings). Here the previously mentioned input properties are no longer necessary, since no objects were passed. + + + +### Control values manually + + + +### No data with chips + + + ## API ```typescript diff --git a/apps/docs/src/assets/pages/components/select.md b/apps/docs/src/assets/pages/components/select.md index c7ed7fde..24cacafb 100644 --- a/apps/docs/src/assets/pages/components/select.md +++ b/apps/docs/src/assets/pages/components/select.md @@ -9,6 +9,12 @@ w3c="https://www.w3.org/WAI/ARIA/apg/example-index/combobox/combobox-select-only +## Examples + +### No data with chips + + + ## API ```typescript diff --git a/apps/docs/src/styles.scss b/apps/docs/src/styles.scss index b36cdb43..3757ca16 100644 --- a/apps/docs/src/styles.scss +++ b/apps/docs/src/styles.scss @@ -98,18 +98,23 @@ pre { display: grid; border: 1px solid var(--border-color-on-surface-low-emphasis); border-radius: 4px; + grid-template-areas: 'content' 'data' 'controls'; grid-template-columns: 1fr; + grid-template-rows: 1fr auto; @include md-and-up { + grid-template-areas: + 'content controls' + 'data controls'; grid-template-columns: 2fr 1fr; } - .content { + > .content { display: flex; align-items: center; justify-content: center; padding: 24px; - border-bottom: 1px solid var(--border-color-on-surface-low-emphasis); + grid-area: content; anglify-text-field, anglify-select, @@ -118,16 +123,26 @@ pre { width: 100%; max-width: 300px; } - - @include md-and-up { - border-right: 1px solid var(--border-color-on-surface-low-emphasis); - } } .controls { display: flex; flex-direction: column; padding: 24px; + border-bottom: 1px solid var(--border-color-on-surface-low-emphasis); gap: 14px; + grid-area: controls; + + @include md-and-up { + border-bottom: none; + border-left: 1px solid var(--border-color-on-surface-low-emphasis); + } + } + + .data { + border-top: 1px solid var(--border-color-on-surface-low-emphasis); + grid-area: data; + + --anglify-expansion-panels-content-padding: 0; } } diff --git a/libs/anglify/src/components/autocomplete/_variables.scss b/libs/anglify/src/components/autocomplete/_variables.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/libs/anglify/src/components/autocomplete/autocomplete-settings.token.ts b/libs/anglify/src/components/autocomplete/autocomplete-settings.token.ts new file mode 100644 index 00000000..d5ff893b --- /dev/null +++ b/libs/anglify/src/components/autocomplete/autocomplete-settings.token.ts @@ -0,0 +1,23 @@ +import { InjectionToken } from '@angular/core'; +import type { AutocompleteSettings, EntireAutocompleteSettings } from './autocomplete.interface'; + +export const DEFAULT_AUTOCOMPLETE_SETTINGS: EntireAutocompleteSettings = { + appearance: 'filled', + alwaysFloatingLabel: false, + persistentHint: false, + clearable: false, + noDataText: 'No data available', + multiple: false, + dropdownPosition: 'bottom', + disabled: false, + hideDetails: false, + itemTextKey: undefined, + itemValueKey: undefined, + error: undefined, + items: [], + dense: false, + hint: undefined, + label: undefined, + placeholder: undefined, +}; +export const AUTOCOMPLETE_SETTINGS = new InjectionToken('Autocomplete Settings'); diff --git a/libs/anglify/src/components/autocomplete/autocomplete.component.html b/libs/anglify/src/components/autocomplete/autocomplete.component.html index c0fd0d80..3e60a798 100644 --- a/libs/anglify/src/components/autocomplete/autocomplete.component.html +++ b/libs/anglify/src/components/autocomplete/autocomplete.component.html @@ -1,128 +1,102 @@ - - - - - - -
-
-
- - - + + + -
- - {{ item.text }} - - - - + + + + + + + + + + + + + + + + + + + + + + + {{ label }} + +
+ + +
+ {{ selectedItem | selectItemViewer: context.itemTextKey }}, +
- +
+
+ + + + + + {{ noDataText }} + - - - {{ label }} - - - - - - - - - - - - - - - - - - + +
diff --git a/libs/anglify/src/components/autocomplete/autocomplete.component.scss b/libs/anglify/src/components/autocomplete/autocomplete.component.scss index 66fd49bd..d668c18a 100644 --- a/libs/anglify/src/components/autocomplete/autocomplete.component.scss +++ b/libs/anglify/src/components/autocomplete/autocomplete.component.scss @@ -1,94 +1,119 @@ :host { - --anglify-menu-max-width: 100%; - --anglify-menu-border-radius: 4px; - - display: block; - - ::ng-deep { - label { - /* stylelint-disable length-zero-no-unit */ - --anglify-input-outlined-label-start-offset: 0px; - --anglify-input-outlined-label-end-offset: 0px; - --anglify-input-filled-label-start-offset: 0px; - --anglify-input-filled-label-end-offset: 0px; - /* stylelint-enable length-zero-no-unit */ - } - - input { - --anglify-input-filled-label-start-offset: 0; - --anglify-input-outlined-label-start-offset: 0; + --anglify-input-outlined-label-start-offset: 16px; + --anglify-input-outlined-label-end-offset: 16px; + --anglify-input-filled-label-start-offset: 16px; + --anglify-input-filled-label-end-offset: 16px; + --anglify-input-min-width: 0; + --anglify-input-min-height: 54px; + --anglify-input-min-height-dense: 40px; + --anglify-input-flex-basis: 100px; + --anglify-input-flex-grow: 1; + --anglify-input-flex-shrink: 1; + + anglify-menu { + width: 100%; + + &:has(anglify-input:not(.anglify-input-hide-details)) { + --anglify-menu-max-width: 100%; + --anglify-menu-border-radius: 4px; + --anglify-menu-offset-bottom: -24px; } + } - anglify-menu { - border-top-left-radius: 0; - border-top-right-radius: 0; + anglify-input { + ::ng-deep .anglify-input { + cursor: pointer; } - } - &.outlined { - ::ng-deep { - anglify-menu { - border-radius: var(--anglify-menu-border-radius); + .selections { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding-inline-end: var(--anglify-input-filled-label-end-offset); + padding-inline-start: var(--anglify-input-filled-label-start-offset); + + .selection { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; } } - } - .disabled { - color: var(--color-on-surface-low-emphasis); - } + input { + min-width: 100px; + height: 26px; + box-sizing: border-box; + flex-basis: 0; + flex-grow: 1; + flex-shrink: 1; + border: none; + background-color: transparent; + caret-color: var(--color-primary); + color: var(--color-on-surface-high-emphasis); + font: var(--font-body-1); + letter-spacing: var(--font-letter-spacing-body-1); + outline: none; + pointer-events: none; + text-transform: var(--font-text-transform-body-1); + + &::placeholder { + color: transparent; + transition: color 0.2s ease-in-out; + } - anglify-icon { - &.disabled { - cursor: not-allowed; + &:disabled { + color: var(--color-on-surface-medium-emphasis); + } } - } - - .prepend-inner-item { - display: flex; - min-height: calc( - var(--anglify-input-min-height) - var(--anglify-input-filled-content-top-offset, var(--anglify-input-outlined-content-top-offset)) - ); - align-items: center; - margin-right: 5px; - &:last-of-type { - margin-right: unset; - padding-inline-end: var(--anglify-input-filled-label-start-offset); + &.anglify-input-focused { + ::ng-deep input::placeholder { + color: var(--color-on-surface-medium-emphasis); + } } - } - .expand-min-height { - ::ng-deep { - input { - --anglify-input-filled-content-top-offset: 14px; + &.anglify-input-floating, + &.anglify-input-always-floating-label { + ::ng-deep input::placeholder { + color: var(--color-on-surface-medium-emphasis); } - .anglify-input-filled { - --anglify-input-filled-content-top-offset: 24px; + &.anglify-input-disabled { + ::ng-deep input::placeholder { + color: var(--color-on-surface-low-emphasis); + } } + } - .anglify-input-outlined { - --anglify-input-outlined-content-top-offset: 7px; + &.anglify-input-filled { + ::ng-deep { + .anglify-input-content { + padding-top: 24px; + } } } - .prepend-inner-item { - --anglify-input-filled-content-top-offset: 14px; - --anglify-input-outlined-content-top-offset: 7px; + &.anglify-input-outlined { + ::ng-deep { + .anglify-input-content { + padding-top: 14px; + padding-bottom: 14px; + } + } } - } - - .dropdown-menu { - max-height: inherit; - overflow-y: auto; - } - .item { - cursor: pointer; + &.anglify-input-dense { + ::ng-deep input { + height: var(--anglify-input-min-height-dense); + min-height: var(--anglify-input-min-height-dense); + } + } - &.disabled { - color: var(--color-on-surface-low-emphasis); - cursor: not-allowed; + &.anglify-input-disabled { + anglify-icon { + color: var(--color-on-surface-low-emphasis); + } } } } diff --git a/libs/anglify/src/components/autocomplete/autocomplete.component.ts b/libs/anglify/src/components/autocomplete/autocomplete.component.ts index af53d34c..f1d4d323 100644 --- a/libs/anglify/src/components/autocomplete/autocomplete.component.ts +++ b/libs/anglify/src/components/autocomplete/autocomplete.component.ts @@ -1,23 +1,42 @@ -import { AsyncPipe, NgForOf, NgIf } from '@angular/common'; -import { Component, ChangeDetectionStrategy, type AfterViewInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import type { AfterViewInit, OnChanges, SimpleChanges } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ContentChildren, + EventEmitter, + forwardRef, + Inject, + Input, + Output, + QueryList, + Self, + ViewChild, +} from '@angular/core'; +import type { ControlValueAccessor } from '@angular/forms'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { combineLatest, debounceTime, fromEvent, map, ReplaySubject, share, startWith, Subject } from 'rxjs'; +import { distinctUntilChanged, fromEvent, map, merge, NEVER, of, switchMap, tap } from 'rxjs'; import { SlotDirective } from '../../directives/slot/slot.directive'; import { SlotOutletDirective } from '../../directives/slot-outlet/slot-outlet.directive'; import { createSettingsProvider } from '../../factories/settings.factory'; import { FindSlotPipe } from '../../pipes/find-slot/find-slot.pipe'; -import { ChipComponent } from '../chip/chip.component'; +import { SelectItemViewerPipe } from '../../pipes/select-item-viewer.pipe'; +import { INTERNAL_ICONS } from '../../tokens/internal-icons.token'; +import { rotate } from '../../utils/animations'; +import { Machine } from '../../utils/machine'; +import { ButtonComponent } from '../button/button.component'; import { IconComponent } from '../icon/icon.component'; +import { InternalIconSetDefinition } from '../icon/icon.interface'; +import { InputComponent } from '../input/input.component'; import { InputDirective } from '../input/input.directive'; -import { ListComponent } from '../list/components/list/list.component'; import { ListItemComponent } from '../list/components/list-item/list-item.component'; -import { ListItemGroupComponent } from '../list/components/list-item-group/list-item-group.component'; import { ListItemTitleComponent } from '../list/components/list-item-title/list-item-title.component'; import { MenuDirective } from '../menu/components/legacy-menu/legacy-menu.directive'; -import { DEFAULT_SELECT_SETTINGS, SELECT_SETTINGS } from '../select/select-settings.token'; -import { SelectComponent } from '../select/select.component'; -import type { EntireSelectSettings } from '../select/select.interface'; -import { TextFieldComponent } from '../text-field/text-field.component'; +import { MenuComponent } from '../menu/menu/menu.component'; +import { AUTOCOMPLETE_SETTINGS, DEFAULT_AUTOCOMPLETE_SETTINGS } from './autocomplete-settings.token'; +import { EntireAutocompleteSettings } from './autocomplete.interface'; +import { AutocompleteAction, createAutocompleteMachineConfig } from './autocomplete.machine'; @UntilDestroy() @Component({ @@ -25,51 +44,207 @@ import { TextFieldComponent } from '../text-field/text-field.component'; standalone: true, templateUrl: './autocomplete.component.html', styleUrls: ['./autocomplete.component.scss'], - providers: [createSettingsProvider('anglifySelectSettings', DEFAULT_SELECT_SETTINGS, SELECT_SETTINGS)], + animations: [rotate()], + providers: [ + createSettingsProvider('anglifyAutocompleteSettings', DEFAULT_AUTOCOMPLETE_SETTINGS, AUTOCOMPLETE_SETTINGS), + { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AutocompleteComponent), multi: true }, + ], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + CommonModule, FindSlotPipe, - AsyncPipe, - NgForOf, - NgIf, - TextFieldComponent, + InputComponent, MenuDirective, - ChipComponent, IconComponent, - ListComponent, - ListItemGroupComponent, ListItemComponent, ListItemTitleComponent, SlotOutletDirective, SlotDirective, InputDirective, + MenuComponent, + ButtonComponent, + SelectItemViewerPipe, ], }) -export class AutocompleteComponent extends SelectComponent implements AfterViewInit { - protected readonly _inputValue$ = new Subject(); - - public readonly inputValue$ = this._inputValue$.asObservable().pipe( - share({ - connector: () => new ReplaySubject(1), - }) - ); - - public readonly filteredItems$ = combineLatest([this.items$, this.inputValue$.pipe(startWith(''))]).pipe( - map(([items, value]) => (value ? items.filter(item => item.text.includes(value)) : items)), - share({ - connector: () => new ReplaySubject(1), - }) - ); - - public override ngAfterViewInit() { - super.ngAfterViewInit(); - - fromEvent(this.input.elementRef.nativeElement, 'input') +export class AutocompleteComponent implements AfterViewInit, OnChanges, EntireAutocompleteSettings, ControlValueAccessor { + @ContentChildren(SlotDirective) protected readonly slots?: QueryList; + + @ViewChild(InputDirective) protected readonly input?: InputDirective; + + @ViewChild('anglifyInput', { read: InputComponent }) public anglifyInput?: InputComponent; + + @ViewChild('menu') public menu?: MenuComponent; + + @Input() public label = this.settings.label; + + @Input() public dense = this.settings.dense; + + @Input() public placeholder = this.settings.placeholder; + + @Input() public hint = this.settings.hint; + + @Input() public alwaysFloatingLabel = this.settings.alwaysFloatingLabel; + + @Input() public persistentHint = this.settings.persistentHint; + + @Input() public appearance = this.settings.appearance; + + @Input() public dropdownPosition = this.settings.dropdownPosition; + + @Input() public hideDetails = this.settings.hideDetails; + + @Input() public multiple = this.settings.multiple; + + @Input() public noDataText = this.settings.noDataText; + + @Input() public error = this.settings.error; + + @Input() public disabled = this.settings.disabled; + + @Input() public items = this.settings.items; + + @Input() public itemTextKey = this.settings.itemTextKey; + + @Input() public itemValueKey = this.settings.itemValueKey; + + @Input() public clearable = this.settings.clearable; + + @Input() public value: any[] = []; + + @Output() public readonly valueChange = new EventEmitter(); + + protected machine = new Machine(createAutocompleteMachineConfig(this)); + + public constructor( + @Self() @Inject('anglifyAutocompleteSettings') public settings: EntireAutocompleteSettings, + @Inject(INTERNAL_ICONS) public readonly internalIcons: InternalIconSetDefinition + ) {} + + private onChange: (...args: any[]) => void = () => {}; + + public writeValue(value: any) { + this.machine.context$.next({ ...this.machine.context$.value, selectedItems: this.transformValuesToItems(value) }); + } + + public registerOnChange(fn: any) { + this.onChange = fn; + } + + public registerOnTouched(_: any) {} + + public ngOnChanges(changes: SimpleChanges) { + const context = this.machine.context$.value as any; + for (const key of Object.keys(changes)) { + if (context[key] !== changes[key].currentValue) { + if (key === 'value' && context[key] !== changes[key].currentValue) { + context.selectedItems = this.transformValuesToItems(changes[key].currentValue); + } else context[key] = changes[key].currentValue; + } + } + + this.machine.context$.next(context); + } + + public ngAfterViewInit() { + this.bindMachineEvents(); + this.setInputValue(); + this.handleMenuScroll(); + this.notifyValueChange(); + } + + private handleMenuScroll() { + merge( + this.machine.context$.pipe( + map(context => context.highlightedIndex), + distinctUntilChanged() + ), + this.machine.context$.pipe(map(context => context.menuOpened)) + ) + .pipe(untilDestroyed(this)) + .subscribe(() => setTimeout(() => this.menu!.scrollToHighlightedItem(), 0)); + } + + private notifyValueChange() { + this.machine.context$ + .pipe( + untilDestroyed(this), + map(context => context.selectedItems), + distinctUntilChanged(), + tap(selectedItems => { + this.valueChange.emit(this.transformSelectedItemsToValues(selectedItems)); + this.onChange(this.transformSelectedItemsToValues(selectedItems)); + }) + ) + .subscribe(); + } + + private setInputValue() { + const input = this.input!.elementRef.nativeElement; + merge( + fromEvent(input, 'blur'), + this.machine.context$.pipe( + map(context => context.selectedItems), + distinctUntilChanged() + ) + ) + .pipe( + untilDestroyed(this), + tap(() => { + const context = this.machine.context$.value; + if (context.multiple) input.value = ''; + else if (context.selectedItems.length > 0) + input.value = context.selectedItems.map(item => SelectItemViewerPipe.transform(item, context.itemTextKey)).join(', '); + else input.value = ''; + }) + ) + .subscribe(); + } + + private bindMachineEvents() { + const input = this.input!.elementRef.nativeElement; + merge( + this.anglifyInput!.onInputClick.pipe( + map(() => ({ action: AutocompleteAction.CLICK })), + tap(() => input.focus()) + ), + fromEvent(this.menu!.elementRef.nativeElement, 'mousedown').pipe(tap(event => event.preventDefault())), + fromEvent(input, 'focusin').pipe(map(() => ({ action: AutocompleteAction.FOCUS }))), + fromEvent(input, 'focusout').pipe(map(() => ({ action: AutocompleteAction.BLUR }))), + fromEvent(input, 'keydown').pipe( + map(event => event as KeyboardEvent), + switchMap(event => { + if (event.key === 'Enter') return of({ action: AutocompleteAction.ENTER }); + if (event.key === 'ArrowDown') return of({ action: AutocompleteAction.ARROW_DOWN }); + if (event.key === 'ArrowUp') return of({ action: AutocompleteAction.ARROW_UP }); + if (event.key === 'Escape') return of({ action: AutocompleteAction.ESCAPE }); + return NEVER; + }) + ), + fromEvent(input, 'input').pipe( + map(event => ({ action: AutocompleteAction.INPUT, payload: (event.target as HTMLInputElement).value })) + ) + ) .pipe( untilDestroyed(this), - debounceTime(200), - map(inputEvent => inputEvent.target.value) + map(data => data as { action: AutocompleteAction; payload?: any }), + tap(({ action, payload }) => this.machine.next(action, payload)) ) - .subscribe(value => this._inputValue$.next(value)); + .subscribe(); + } + + protected onItemClick = (item: any) => this.machine.next(AutocompleteAction.ITEM_CLICK, item); + + protected clear = () => this.machine.next(AutocompleteAction.CLEAR); + + private transformSelectedItemsToValues(selectedItems: any[]) { + const itemValueKey = this.itemValueKey; + if (itemValueKey) return selectedItems.map(item => item[itemValueKey]); + else return selectedItems; + } + + private transformValuesToItems(values: any[]) { + const itemValueKey = this.itemValueKey; + if (itemValueKey) return this.items.filter(item => values.includes(item[itemValueKey])); + else return values; } } diff --git a/libs/anglify/src/components/autocomplete/autocomplete.interface.ts b/libs/anglify/src/components/autocomplete/autocomplete.interface.ts new file mode 100644 index 00000000..d421d960 --- /dev/null +++ b/libs/anglify/src/components/autocomplete/autocomplete.interface.ts @@ -0,0 +1,76 @@ +import type { Side } from '../../composables/position/position.interface'; +import type { InputAppearance } from '../input/input.interface'; + +export type EntireAutocompleteSettings = { + /** + * Forces label to always be in floating mode. + */ + alwaysFloatingLabel: boolean; + /** + * Sets one of the two predefined input styles (`filled` or `outlined`). + */ + appearance: InputAppearance; + /** + * Add input clear functionality (appends an clear icon). + */ + clearable: boolean; + /** + * Reduces the input height. + */ + dense: boolean; + /** + * Disables the input. + */ + disabled: boolean; + /** + * Sets the position of the menu. + */ + dropdownPosition: Side; + /** + * Puts the input in a manual error state. + */ + error: string | undefined; + /** + * Hides hint and validation errors. + */ + hideDetails: boolean; + /** + * Hint text. + */ + hint: string | undefined; + /** + * Key to use for the value. Required if items is an array of objects. It's assumed + * that items is an array of primitives if this is not set. + */ + itemTextKey: string | undefined; + /** + * Key to use for the value. Whole item is used as value if this is not set. + */ + itemValueKey: string | undefined; + /** + * Can be an array of objects or array of strings/numbers. + */ + items: any[]; + /** + * Sets the input label. + */ + label: string | undefined; + /** + * Changes select to multiple. Accepts array for value. + */ + multiple: boolean; + /** + * Display text when there is no data. + */ + noDataText: string; + /** + * Forces hint to always be visible. + */ + persistentHint: boolean; + /** + * Sets the input’s placeholder text + */ + placeholder: string | undefined; +}; + +export type AutocompleteSettings = Partial; diff --git a/libs/anglify/src/components/autocomplete/autocomplete.machine.drawio b/libs/anglify/src/components/autocomplete/autocomplete.machine.drawio new file mode 100644 index 00000000..d43bbcd5 --- /dev/null +++ b/libs/anglify/src/components/autocomplete/autocomplete.machine.drawio @@ -0,0 +1,543 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/libs/anglify/src/components/autocomplete/autocomplete.machine.ts b/libs/anglify/src/components/autocomplete/autocomplete.machine.ts new file mode 100644 index 00000000..771c90a3 --- /dev/null +++ b/libs/anglify/src/components/autocomplete/autocomplete.machine.ts @@ -0,0 +1,210 @@ +import type { MachineConfig } from '../../utils/machine'; + +export enum AutocompleteState { + IDLE = 'IDLE', + S1 = 'S1', // Focused with closed menu + S2 = 'S2', // Focused with opened menu + S3 = 'S3', // Focused with opened menu and selected item +} +export enum AutocompleteAction { + ARROW_DOWN = 'ARROW_DOWN', + ARROW_UP = 'ARROW_UP', + BLUR = 'BLUR', + CLEAR = 'CLEAR', + CLICK = 'CLICK', + ENTER = 'ENTER', + ESCAPE = 'ESCAPE', + FOCUS = 'FOCUS', + INPUT = 'INPUT', + ITEM_CLICK = 'ITEM_CLICK', +} +export type AutocompleteContext = { + clearable: boolean; + disabled: boolean; + filterFn(items: any[], search: string, context: AutocompleteContext): any[]; + filteredItems: any[]; + highlightedIndex: number | undefined; + itemTextKey: string | undefined; + itemValueKey: string | undefined; + items: any[]; + menuOpened: boolean; + multiple: boolean; + search: string; + selectedItems: any[]; +}; + +export const initialContext: AutocompleteContext = { + highlightedIndex: undefined, + menuOpened: false, + items: [], + filteredItems: [], + selectedItems: [], + search: '', + multiple: false, + itemTextKey: undefined, + itemValueKey: undefined, + filterFn: (items: any[], search: string, context: AutocompleteContext) => { + const itemTextKey = context.itemTextKey; + if (itemTextKey) { + return items.filter(item => item[itemTextKey].toLowerCase().includes(search.toLowerCase())); + } else { + return items.filter(item => item.toLowerCase().includes(search.toLowerCase())); + } + }, + clearable: false, + disabled: false, +}; + +export function createAutocompleteMachineConfig( + context: Partial +): MachineConfig { + return { + context: { ...initialContext, ...context }, + initial: AutocompleteState.IDLE, + states: { + IDLE: { + on: { + CLICK: context => (context.disabled ? AutocompleteState.IDLE : AutocompleteState.S3), + FOCUS: AutocompleteState.S1, + CLEAR: AutocompleteState.IDLE, + }, + do: { + beforeEach: context => { + context.highlightedIndex = undefined; + context.menuOpened = false; + context.filteredItems = []; + context.search = ''; + }, + CLEAR: context => { + if (context.clearable && !context.disabled) context.selectedItems = []; + }, + }, + }, + S1: { + on: { + INPUT: AutocompleteState.S2, + BLUR: AutocompleteState.IDLE, + ARROW_DOWN: AutocompleteState.S3, + ARROW_UP: AutocompleteState.S3, + CLICK: AutocompleteState.S3, + CLEAR: AutocompleteState.IDLE, + }, + do: { + beforeEach: context => { + context.menuOpened = false; + }, + ENTER: context => handleEnter(context), + ITEM_CLICK: (context, _, payload) => handleItemClick(context, payload), + }, + }, + S2: { + on: { + ESCAPE: AutocompleteState.S1, + BLUR: AutocompleteState.IDLE, + INPUT: AutocompleteState.S2, + ARROW_DOWN: AutocompleteState.S3, + ARROW_UP: AutocompleteState.S3, + CLEAR: AutocompleteState.IDLE, + ITEM_CLICK: context => (context.multiple ? AutocompleteState.S2 : AutocompleteState.S1), + ENTER: context => (context.multiple ? AutocompleteState.S2 : AutocompleteState.S1), + }, + do: { + beforeEach: context => { + context.menuOpened = true; + }, + ENTER: context => handleEnter(context), + ITEM_CLICK: (context, _, payload) => handleItemClick(context, payload), + INPUT: (context, _, payload) => { + context.search = payload; + + if (payload === '' && context.multiple === false) { + context.selectedItems = []; + } + + context.filteredItems = context.filterFn(context.items, context.search, context); + + if (context.filteredItems.length > 0) { + context.highlightedIndex = context.filteredItems.includes(context.selectedItems[0]) + ? context.filteredItems.indexOf(context.selectedItems[0]) + : 0; + } else { + context.highlightedIndex = 0; + } + }, + }, + }, + S3: { + on: { + ENTER: context => (context.multiple ? AutocompleteState.S2 : AutocompleteState.S1), + ARROW_DOWN: AutocompleteState.S3, + ARROW_UP: AutocompleteState.S3, + INPUT: AutocompleteState.S2, + ESCAPE: AutocompleteState.S1, + BLUR: AutocompleteState.IDLE, + ITEM_CLICK: AutocompleteState.S1, + CLEAR: AutocompleteState.IDLE, + }, + do: { + beforeEach: context => { + context.menuOpened = true; + }, + CLICK: context => { + if (context.filteredItems.length === 0) context.filteredItems = context.items; + if (context.highlightedIndex === undefined) { + context.highlightedIndex = context.selectedItems.length > 0 ? context.filteredItems.indexOf(context.selectedItems[0]) : 0; + } + }, + ARROW_DOWN: (context, _action, _payload, previous) => { + if (context.filteredItems.length === 0) context.filteredItems = context.items; + if (previous === AutocompleteState.S1 && context.highlightedIndex !== undefined) return; + if (context.highlightedIndex === undefined) { + context.highlightedIndex = context.selectedItems.length > 0 ? context.filteredItems.indexOf(context.selectedItems[0]) : 0; + } else { + context.highlightedIndex = (context.highlightedIndex + 1) % context.filteredItems.length; + } + }, + ARROW_UP: (context, _action, _payload, previous) => { + if (context.filteredItems.length === 0) context.filteredItems = context.items; + if (previous === AutocompleteState.S1 && context.highlightedIndex !== undefined) return; + if (context.highlightedIndex === undefined) { + context.highlightedIndex = + context.selectedItems.length > 0 + ? context.filteredItems.indexOf(context.selectedItems[0]) + : context.filteredItems.length - 1; + } else { + context.highlightedIndex = (context.highlightedIndex - 1 + context.filteredItems.length) % context.filteredItems.length; + } + }, + }, + }, + }, + }; +} + +function handleItemClick(context: AutocompleteContext, item: any) { + if (context.multiple) { + if (context.selectedItems.includes(item)) { + context.selectedItems = context.selectedItems.filter(selectedItem => selectedItem !== item); + } else { + context.selectedItems = [...context.selectedItems, item]; + } + } else { + context.selectedItems = [item]; + } + + context.highlightedIndex = context.filteredItems.indexOf(item); +} + +function handleEnter(context: AutocompleteContext) { + const item = context.filteredItems.find((_, index) => index === context.highlightedIndex); + if (!item) return; + if (context.multiple) { + if (context.selectedItems.includes(item)) { + context.selectedItems = context.selectedItems.filter(selectedItem => selectedItem !== item); + } else { + context.selectedItems = [...context.selectedItems, item]; + } + } else { + context.selectedItems = [item]; + } +} diff --git a/libs/anglify/src/components/combobox/_variables.scss b/libs/anglify/src/components/combobox/_variables.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/libs/anglify/src/components/combobox/combobox-settings.token.ts b/libs/anglify/src/components/combobox/combobox-settings.token.ts new file mode 100644 index 00000000..54b792f5 --- /dev/null +++ b/libs/anglify/src/components/combobox/combobox-settings.token.ts @@ -0,0 +1,23 @@ +import { InjectionToken } from '@angular/core'; +import type { ComboboxSettings, EntireComboboxSettings } from './combobox.interface'; + +export const DEFAULT_COMBOBOX_SETTINGS: EntireComboboxSettings = { + appearance: 'filled', + alwaysFloatingLabel: false, + persistentHint: false, + clearable: false, + noDataText: 'No data available', + multiple: false, + dropdownPosition: 'bottom', + disabled: false, + hideDetails: false, + itemTextKey: undefined, + itemValueKey: undefined, + error: undefined, + items: [], + dense: false, + hint: undefined, + label: undefined, + placeholder: undefined, +}; +export const COMBOBOX_SETTINGS = new InjectionToken('Combobox Settings'); diff --git a/libs/anglify/src/components/combobox/combobox.component.html b/libs/anglify/src/components/combobox/combobox.component.html index a2661417..3e60a798 100644 --- a/libs/anglify/src/components/combobox/combobox.component.html +++ b/libs/anglify/src/components/combobox/combobox.component.html @@ -1,138 +1,102 @@ - - - - - - -
-
-
- - - + + + -
- - {{ item.text }} - - - - + + + + + + + + + + + + + + + + + + + + + + + {{ label }} + +
+ + +
+ {{ selectedItem | selectItemViewer: context.itemTextKey }}, +
- +
+
+ + + + + + {{ noDataText }} + - - - {{ label }} - - - - - - - - - - - - - - - - - - + +
diff --git a/libs/anglify/src/components/combobox/combobox.component.scss b/libs/anglify/src/components/combobox/combobox.component.scss index 66fd49bd..d668c18a 100644 --- a/libs/anglify/src/components/combobox/combobox.component.scss +++ b/libs/anglify/src/components/combobox/combobox.component.scss @@ -1,94 +1,119 @@ :host { - --anglify-menu-max-width: 100%; - --anglify-menu-border-radius: 4px; - - display: block; - - ::ng-deep { - label { - /* stylelint-disable length-zero-no-unit */ - --anglify-input-outlined-label-start-offset: 0px; - --anglify-input-outlined-label-end-offset: 0px; - --anglify-input-filled-label-start-offset: 0px; - --anglify-input-filled-label-end-offset: 0px; - /* stylelint-enable length-zero-no-unit */ - } - - input { - --anglify-input-filled-label-start-offset: 0; - --anglify-input-outlined-label-start-offset: 0; + --anglify-input-outlined-label-start-offset: 16px; + --anglify-input-outlined-label-end-offset: 16px; + --anglify-input-filled-label-start-offset: 16px; + --anglify-input-filled-label-end-offset: 16px; + --anglify-input-min-width: 0; + --anglify-input-min-height: 54px; + --anglify-input-min-height-dense: 40px; + --anglify-input-flex-basis: 100px; + --anglify-input-flex-grow: 1; + --anglify-input-flex-shrink: 1; + + anglify-menu { + width: 100%; + + &:has(anglify-input:not(.anglify-input-hide-details)) { + --anglify-menu-max-width: 100%; + --anglify-menu-border-radius: 4px; + --anglify-menu-offset-bottom: -24px; } + } - anglify-menu { - border-top-left-radius: 0; - border-top-right-radius: 0; + anglify-input { + ::ng-deep .anglify-input { + cursor: pointer; } - } - &.outlined { - ::ng-deep { - anglify-menu { - border-radius: var(--anglify-menu-border-radius); + .selections { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding-inline-end: var(--anglify-input-filled-label-end-offset); + padding-inline-start: var(--anglify-input-filled-label-start-offset); + + .selection { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; } } - } - .disabled { - color: var(--color-on-surface-low-emphasis); - } + input { + min-width: 100px; + height: 26px; + box-sizing: border-box; + flex-basis: 0; + flex-grow: 1; + flex-shrink: 1; + border: none; + background-color: transparent; + caret-color: var(--color-primary); + color: var(--color-on-surface-high-emphasis); + font: var(--font-body-1); + letter-spacing: var(--font-letter-spacing-body-1); + outline: none; + pointer-events: none; + text-transform: var(--font-text-transform-body-1); + + &::placeholder { + color: transparent; + transition: color 0.2s ease-in-out; + } - anglify-icon { - &.disabled { - cursor: not-allowed; + &:disabled { + color: var(--color-on-surface-medium-emphasis); + } } - } - - .prepend-inner-item { - display: flex; - min-height: calc( - var(--anglify-input-min-height) - var(--anglify-input-filled-content-top-offset, var(--anglify-input-outlined-content-top-offset)) - ); - align-items: center; - margin-right: 5px; - &:last-of-type { - margin-right: unset; - padding-inline-end: var(--anglify-input-filled-label-start-offset); + &.anglify-input-focused { + ::ng-deep input::placeholder { + color: var(--color-on-surface-medium-emphasis); + } } - } - .expand-min-height { - ::ng-deep { - input { - --anglify-input-filled-content-top-offset: 14px; + &.anglify-input-floating, + &.anglify-input-always-floating-label { + ::ng-deep input::placeholder { + color: var(--color-on-surface-medium-emphasis); } - .anglify-input-filled { - --anglify-input-filled-content-top-offset: 24px; + &.anglify-input-disabled { + ::ng-deep input::placeholder { + color: var(--color-on-surface-low-emphasis); + } } + } - .anglify-input-outlined { - --anglify-input-outlined-content-top-offset: 7px; + &.anglify-input-filled { + ::ng-deep { + .anglify-input-content { + padding-top: 24px; + } } } - .prepend-inner-item { - --anglify-input-filled-content-top-offset: 14px; - --anglify-input-outlined-content-top-offset: 7px; + &.anglify-input-outlined { + ::ng-deep { + .anglify-input-content { + padding-top: 14px; + padding-bottom: 14px; + } + } } - } - - .dropdown-menu { - max-height: inherit; - overflow-y: auto; - } - .item { - cursor: pointer; + &.anglify-input-dense { + ::ng-deep input { + height: var(--anglify-input-min-height-dense); + min-height: var(--anglify-input-min-height-dense); + } + } - &.disabled { - color: var(--color-on-surface-low-emphasis); - cursor: not-allowed; + &.anglify-input-disabled { + anglify-icon { + color: var(--color-on-surface-low-emphasis); + } } } } diff --git a/libs/anglify/src/components/combobox/combobox.component.ts b/libs/anglify/src/components/combobox/combobox.component.ts index 48185a46..75a44f47 100644 --- a/libs/anglify/src/components/combobox/combobox.component.ts +++ b/libs/anglify/src/components/combobox/combobox.component.ts @@ -1,80 +1,253 @@ -import { AsyncPipe, NgForOf, NgIf } from '@angular/common'; -import { Component, ChangeDetectionStrategy, forwardRef, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import type { AfterViewInit, OnChanges, SimpleChanges } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ContentChildren, + EventEmitter, + forwardRef, + Inject, + Input, + Output, + QueryList, + Self, + ViewChild, +} from '@angular/core'; +import type { ControlValueAccessor } from '@angular/forms'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; -import { combineLatest, map, ReplaySubject, share, startWith } from 'rxjs'; -import { ClickStopPropagationDirective } from '../../directives/click-stop-propagation/click-stop-propagation.directive'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { distinctUntilChanged, fromEvent, map, merge, NEVER, of, switchMap, tap } from 'rxjs'; import { SlotDirective } from '../../directives/slot/slot.directive'; import { SlotOutletDirective } from '../../directives/slot-outlet/slot-outlet.directive'; import { createSettingsProvider } from '../../factories/settings.factory'; import { FindSlotPipe } from '../../pipes/find-slot/find-slot.pipe'; -import { AutocompleteComponent } from '../autocomplete/autocomplete.component'; -import { ChipComponent } from '../chip/chip.component'; +import { SelectItemViewerPipe } from '../../pipes/select-item-viewer.pipe'; +import { INTERNAL_ICONS } from '../../tokens/internal-icons.token'; +import { rotate } from '../../utils/animations'; +import { Machine } from '../../utils/machine'; +import { ButtonComponent } from '../button/button.component'; import { IconComponent } from '../icon/icon.component'; +import { InternalIconSetDefinition } from '../icon/icon.interface'; +import { InputComponent } from '../input/input.component'; import { InputDirective } from '../input/input.directive'; -import { ListComponent } from '../list/components/list/list.component'; import { ListItemComponent } from '../list/components/list-item/list-item.component'; -import { ListItemGroupComponent } from '../list/components/list-item-group/list-item-group.component'; import { ListItemTitleComponent } from '../list/components/list-item-title/list-item-title.component'; import { MenuDirective } from '../menu/components/legacy-menu/legacy-menu.directive'; -import { DEFAULT_SELECT_SETTINGS, SELECT_SETTINGS } from '../select/select-settings.token'; -import type { EntireSelectSettings, SelectItem } from '../select/select.interface'; -import { TextFieldComponent } from '../text-field/text-field.component'; +import { MenuComponent } from '../menu/menu/menu.component'; +import { COMBOBOX_SETTINGS, DEFAULT_COMBOBOX_SETTINGS } from './combobox-settings.token'; +import { EntireComboboxSettings } from './combobox.interface'; +import { ComboboxAction, createComboboxMachineConfig } from './combobox.machine'; +@UntilDestroy() @Component({ selector: 'anglify-combobox', standalone: true, templateUrl: './combobox.component.html', styleUrls: ['./combobox.component.scss'], + animations: [rotate()], providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ComboboxComponent), - multi: true, - }, - createSettingsProvider('anglifySelectSettings', DEFAULT_SELECT_SETTINGS, SELECT_SETTINGS), + createSettingsProvider('anglifyComboboxSettings', DEFAULT_COMBOBOX_SETTINGS, COMBOBOX_SETTINGS), + { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ComboboxComponent), multi: true }, ], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - TextFieldComponent, - NgIf, + CommonModule, + FindSlotPipe, + InputComponent, MenuDirective, - AsyncPipe, - ClickStopPropagationDirective, - NgForOf, + IconComponent, ListItemComponent, - ListItemGroupComponent, - ListComponent, ListItemTitleComponent, - IconComponent, - ChipComponent, - SlotDirective, SlotOutletDirective, - FindSlotPipe, + SlotDirective, InputDirective, + MenuComponent, + ButtonComponent, + SelectItemViewerPipe, ], }) -export class ComboboxComponent extends AutocompleteComponent { - @Input() public addItem = this.settings.addItem; - - @Input() public addItemFn: (input: string) => Promise | SelectItem = input => ({ text: input, value: input }); - - public readonly isUniqueItem$ = combineLatest([this.filteredItems$, this.selectedItems$, this.inputValue$.pipe(startWith(''))]).pipe( - map(([filtered, selected, value]) => { - const toCompare = value.toLowerCase(); - return ( - Boolean(value) && - !filtered.some(item => item.text.toLowerCase() === toCompare) && - !selected.some(item => item.text.toLowerCase() === toCompare) - ); - }), - share({ - connector: () => new ReplaySubject(1), - }) - ); - - public selectTag = async (input: string) => { - const item = await this.addItemFn(input); - this._items$.next([...this._items$.value, item]); - void this.select(item); - }; +export class ComboboxComponent implements AfterViewInit, ControlValueAccessor, EntireComboboxSettings, OnChanges { + @ContentChildren(SlotDirective) protected readonly slots?: QueryList; + + @ViewChild(InputDirective) protected readonly input?: InputDirective; + + @ViewChild('anglifyInput', { read: InputComponent }) public anglifyInput?: InputComponent; + + @ViewChild('menu') public menu?: MenuComponent; + + @Input() public label = this.settings.label; + + @Input() public dense = this.settings.dense; + + @Input() public placeholder = this.settings.placeholder; + + @Input() public hint = this.settings.hint; + + @Input() public alwaysFloatingLabel = this.settings.alwaysFloatingLabel; + + @Input() public persistentHint = this.settings.persistentHint; + + @Input() public appearance = this.settings.appearance; + + @Input() public dropdownPosition = this.settings.dropdownPosition; + + @Input() public hideDetails = this.settings.hideDetails; + + @Input() public multiple = this.settings.multiple; + + @Input() public noDataText = this.settings.noDataText; + + @Input() public error = this.settings.error; + + @Input() public disabled = this.settings.disabled; + + @Input() public items = this.settings.items; + + @Input() public itemTextKey = this.settings.itemTextKey; + + @Input() public itemValueKey = this.settings.itemValueKey; + + @Input() public clearable = this.settings.clearable; + + @Input() public value: any[] = []; + + @Output() public readonly valueChange = new EventEmitter(); + + protected machine = new Machine(createComboboxMachineConfig(this)); + + public constructor( + @Self() @Inject('anglifyComboboxSettings') public settings: EntireComboboxSettings, + @Inject(INTERNAL_ICONS) public readonly internalIcons: InternalIconSetDefinition + ) {} + + private onChange: (...args: any[]) => void = () => {}; + + public writeValue(value: any) { + this.machine.context$.next({ ...this.machine.context$.value, selectedItems: this.transformValuesToItems(value) }); + } + + public registerOnChange(fn: any) { + this.onChange = fn; + } + + public registerOnTouched(_: any) {} + + public ngOnChanges(changes: SimpleChanges) { + const context = this.machine.context$.value as any; + for (const key of Object.keys(changes)) { + if (context[key] !== changes[key].currentValue) { + if (key === 'value' && context[key] !== changes[key].currentValue) { + context.selectedItems = this.transformValuesToItems(changes[key].currentValue); + } else context[key] = changes[key].currentValue; + } + } + + this.machine.context$.next(context); + } + + public ngAfterViewInit() { + this.bindMachineEvents(); + this.setInputValue(); + this.handleMenuScroll(); + this.notifyValueChange(); + } + + private handleMenuScroll() { + merge( + this.machine.context$.pipe( + map(context => context.highlightedIndex), + distinctUntilChanged() + ), + this.machine.context$.pipe(map(context => context.menuOpened)) + ) + .pipe(untilDestroyed(this)) + .subscribe(() => setTimeout(() => this.menu!.scrollToHighlightedItem(), 0)); + } + + private notifyValueChange() { + this.machine.context$ + .pipe( + untilDestroyed(this), + map(context => context.selectedItems), + distinctUntilChanged(), + tap(selectedItems => { + const transformed = this.transformSelectedItemsToValues(selectedItems); + this.valueChange.emit(transformed); + this.onChange(transformed); + }) + ) + .subscribe(); + } + + private setInputValue() { + const input = this.input!.elementRef.nativeElement; + merge( + fromEvent(input, 'blur'), + this.machine.context$.pipe( + map(context => context.selectedItems), + distinctUntilChanged() + ) + ) + .pipe( + untilDestroyed(this), + tap(() => { + const context = this.machine.context$.value; + if (context.multiple) { + input.value = ''; + } else if (context.selectedItems.length > 0) { + input.value = context.selectedItems.map(item => SelectItemViewerPipe.transform(item, context.itemTextKey)).join(', '); + } else { + input.value = ''; + } + }) + ) + .subscribe(); + } + + private bindMachineEvents() { + const input = this.input!.elementRef.nativeElement; + merge( + this.anglifyInput!.onInputClick.pipe( + map(() => ({ action: ComboboxAction.CLICK })), + tap(() => input.focus()) + ), + fromEvent(this.menu!.elementRef.nativeElement, 'mousedown').pipe(tap(event => event.preventDefault())), + fromEvent(input, 'focusin').pipe(map(() => ({ action: ComboboxAction.FOCUS }))), + fromEvent(input, 'focusout').pipe(map(() => ({ action: ComboboxAction.BLUR }))), + fromEvent(input, 'keydown').pipe( + map(event => event as KeyboardEvent), + switchMap(event => { + if (event.key === 'Enter') return of({ action: ComboboxAction.ENTER, payload: event }); + if (event.key === 'ArrowDown') return of({ action: ComboboxAction.ARROW_DOWN, payload: event }); + if (event.key === 'ArrowUp') return of({ action: ComboboxAction.ARROW_UP, payload: event }); + if (event.key === 'Escape') return of({ action: ComboboxAction.ESCAPE, payload: event }); + if (event.key === 'Tab') return of({ action: ComboboxAction.TAB, payload: event }); + return NEVER; + }) + ), + fromEvent(input, 'input').pipe(map(event => ({ action: ComboboxAction.INPUT, payload: (event.target as HTMLInputElement).value }))) + ) + .pipe( + untilDestroyed(this), + map(data => data as { action: ComboboxAction; payload?: any }), + tap(({ action, payload }) => this.machine.next(action, payload)) + ) + .subscribe(); + } + + protected onItemClick = (item: any) => this.machine.next(ComboboxAction.ITEM_CLICK, item); + + protected clear = () => this.machine.next(ComboboxAction.CLEAR); + + private transformSelectedItemsToValues(selectedItems: any[]) { + const itemValueKey = this.itemValueKey; + if (itemValueKey) return selectedItems.map(item => item[itemValueKey]); + else return selectedItems; + } + + private transformValuesToItems(values: any[]) { + const itemValueKey = this.itemValueKey; + if (itemValueKey) return this.items.filter(item => values.includes(item[itemValueKey])); + else return values; + } } diff --git a/libs/anglify/src/components/combobox/combobox.interface.ts b/libs/anglify/src/components/combobox/combobox.interface.ts new file mode 100644 index 00000000..da40c93d --- /dev/null +++ b/libs/anglify/src/components/combobox/combobox.interface.ts @@ -0,0 +1,76 @@ +import type { Side } from '../../composables/position/position.interface'; +import type { InputAppearance } from '../input/input.interface'; + +export type EntireComboboxSettings = { + /** + * Forces label to always be in floating mode. + */ + alwaysFloatingLabel: boolean; + /** + * Sets one of the two predefined input styles (`filled` or `outlined`). + */ + appearance: InputAppearance; + /** + * Add input clear functionality (appends an clear icon). + */ + clearable: boolean; + /** + * Reduces the input height. + */ + dense: boolean; + /** + * Disables the input. + */ + disabled: boolean; + /** + * Sets the position of the menu. + */ + dropdownPosition: Side; + /** + * Puts the input in a manual error state. + */ + error: string | undefined; + /** + * Hides hint and validation errors. + */ + hideDetails: boolean; + /** + * Hint text. + */ + hint: string | undefined; + /** + * Key to use for the value. Required if items is an array of objects. It's assumed + * that items is an array of primitives if this is not set. + */ + itemTextKey: string | undefined; + /** + * Key to use for the value. Whole item is used as value if this is not set. + */ + itemValueKey: string | undefined; + /** + * Can be an array of objects or array of strings/numbers. + */ + items: any[]; + /** + * Sets the input label. + */ + label: string | undefined; + /** + * Changes select to multiple. Accepts array for value. + */ + multiple: boolean; + /** + * Display text when there is no data. + */ + noDataText: string; + /** + * Forces hint to always be visible. + */ + persistentHint: boolean; + /** + * Sets the input’s placeholder text + */ + placeholder: string | undefined; +}; + +export type ComboboxSettings = Partial; diff --git a/libs/anglify/src/components/combobox/combobox.machine.drawio b/libs/anglify/src/components/combobox/combobox.machine.drawio new file mode 100644 index 00000000..4e78d617 --- /dev/null +++ b/libs/anglify/src/components/combobox/combobox.machine.drawio @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/libs/anglify/src/components/combobox/combobox.machine.ts b/libs/anglify/src/components/combobox/combobox.machine.ts new file mode 100644 index 00000000..fefe37b1 --- /dev/null +++ b/libs/anglify/src/components/combobox/combobox.machine.ts @@ -0,0 +1,210 @@ +import type { MachineConfig } from '../../utils/machine'; + +export enum ComboboxState { + IDLE = 'IDLE', + S1 = 'S1', // Focused with closed menu + S2 = 'S2', // Focused with opened menu +} +export enum ComboboxAction { + ARROW_DOWN = 'ARROW_DOWN', + ARROW_UP = 'ARROW_UP', + BLUR = 'BLUR', + CLEAR = 'CLEAR', + CLICK = 'CLICK', + ENTER = 'ENTER', + ESCAPE = 'ESCAPE', + FOCUS = 'FOCUS', + INPUT = 'INPUT', + ITEM_CLICK = 'ITEM_CLICK', + TAB = 'TAB', +} +export type ComboboxContext = { + clearable: boolean; + disabled: boolean; + filterFn(items: any[], search: string, context: ComboboxContext): any[]; + filteredItems: any[]; + highlightedIndex: number | undefined; + itemTextKey: string | undefined; + itemValueKey: string | undefined; + items: any[]; + menuOpened: boolean; + multiple: boolean; + search: string; + selectedItems: any[]; +}; + +export const initialContext: ComboboxContext = { + highlightedIndex: undefined, + menuOpened: false, + items: [], + filteredItems: [], + selectedItems: [], + search: '', + multiple: false, + itemTextKey: undefined, + itemValueKey: undefined, + filterFn: (items: any[], search: string, context: ComboboxContext) => { + const itemTextKey = context.itemTextKey; + if (itemTextKey) { + return items.filter(item => item[itemTextKey].toLowerCase().includes(search.toLowerCase())); + } else { + return items.filter(item => item.toLowerCase().includes(search.toLowerCase())); + } + }, + clearable: false, + disabled: false, +}; + +export function createComboboxMachineConfig( + context: Partial +): MachineConfig { + return { + context: { ...initialContext, ...context }, + initial: ComboboxState.IDLE, + states: { + IDLE: { + on: { + FOCUS: ComboboxState.S1, + CLICK: context => (context.disabled ? ComboboxState.IDLE : ComboboxState.S2), + CLEAR: ComboboxState.IDLE, + }, + do: { + BLUR: context => { + context.highlightedIndex = undefined; + context.filteredItems = []; + context.menuOpened = false; + context.search = ''; + }, + CLEAR: context => { + if (context.clearable && !context.disabled) { + context.highlightedIndex = undefined; + context.filteredItems = []; + context.selectedItems = []; + context.menuOpened = false; + context.search = ''; + } + }, + }, + }, + S1: { + on: { + BLUR: ComboboxState.IDLE, + CLEAR: ComboboxState.IDLE, + INPUT: ComboboxState.S2, + ENTER: ComboboxState.S2, + ARROW_DOWN: ComboboxState.S2, + ARROW_UP: ComboboxState.S2, + CLICK: ComboboxState.S2, + }, + do: { + beforeEach: context => (context.menuOpened = false), + ENTER: (context, action, payload, previous) => handleSelection(context, action, payload, previous), + TAB: (context, action, payload, previous) => handleSelection(context, action, payload, previous), + ITEM_CLICK: (context, _action, payload) => selectSingleItem(context, payload), + }, + }, + S2: { + on: { + BLUR: ComboboxState.IDLE, + CLEAR: ComboboxState.IDLE, + INPUT: ComboboxState.S2, + ARROW_DOWN: ComboboxState.S2, + ARROW_UP: ComboboxState.S2, + ESCAPE: ComboboxState.S1, + ENTER: context => (context.multiple ? ComboboxState.S2 : ComboboxState.S1), + TAB: context => (context.multiple ? ComboboxState.S2 : ComboboxState.S1), + ITEM_CLICK: context => (context.multiple ? ComboboxState.S2 : ComboboxState.S1), + }, + do: { + beforeEach: context => { + context.menuOpened = true; + if (context.filteredItems.length === 0) context.filteredItems = context.items; + }, + INPUT: (context, _, payload) => filterItems(context, payload), + ENTER: (context, action, payload, previous) => handleSelection(context, action, payload, previous), + TAB: (context, action, payload, previous) => handleSelection(context, action, payload, previous), + ARROW_DOWN: (context, _action, _payload, previous) => handleArrowDown(context, previous), + ARROW_UP: (context, action, payload, previous) => handleArrowUp(context, action, payload, previous), + ITEM_CLICK: (context, _action, payload) => toggleItem(context, payload), + }, + }, + }, + }; +} + +function filterItems(context: ComboboxContext, search: string) { + context.search = search; + context.filteredItems = context.filterFn(context.items, search, context); + context.highlightedIndex = undefined; +} + +function highlightItem(context: ComboboxContext, item: any) { + context.highlightedIndex = context.filteredItems.indexOf(item); +} + +function handleArrowDown(context: ComboboxContext, previous: ComboboxState) { + if (context.highlightedIndex === undefined) { + if (context.selectedItems.length > 0) { + highlightItem(context, context.selectedItems[0]); + } else if (context.filteredItems.length > 0) { + highlightItem(context, context.filteredItems[0]); + } + } else if (previous === ComboboxState.S2) { + context.highlightedIndex = (context.highlightedIndex + 1) % context.filteredItems.length; + } +} + +function handleArrowUp(context: ComboboxContext, _action: ComboboxAction, _payload: any, previous: ComboboxState) { + if (context.highlightedIndex === undefined) { + if (context.selectedItems.length > 0) { + highlightItem(context, context.selectedItems[context.selectedItems.length - 1]); + } else if (context.filteredItems.length > 0) { + highlightItem(context, context.filteredItems[context.filteredItems.length - 1]); + } + } else if (previous === ComboboxState.S2) { + context.highlightedIndex = (context.highlightedIndex - 1 + context.filteredItems.length) % context.filteredItems.length; + } +} + +export function createItem(value: string, itemTextKey?: string, itemValueKey?: string) { + if (!itemTextKey && !itemValueKey) return value; + const item: any = {}; + if (itemTextKey) item[itemTextKey] = value; + if (itemValueKey) item[itemValueKey] = value; + return item; +} + +function toggleItem(context: ComboboxContext, item: any) { + const itemTextKey = context.itemTextKey; + if (itemTextKey) { + const index = context.selectedItems.findIndex(selectedItem => selectedItem[itemTextKey] === item[itemTextKey]); + if (index === -1) context.selectedItems = [...context.selectedItems, item]; + else context.selectedItems = context.selectedItems.filter((_, sIndex) => sIndex !== index); + } else if (context.selectedItems.includes(item)) { + context.selectedItems = context.selectedItems.filter(selectedItem => selectedItem !== item); + } else { + context.selectedItems = [...context.selectedItems, item]; + } +} + +function selectSingleItem(context: ComboboxContext, item: any) { + if (!context.selectedItems.includes(item)) { + context.selectedItems = [item]; + } +} + +export function handleSelection(context: ComboboxContext, action: ComboboxAction, payload: any, previous: ComboboxState) { + const functionToCall = context.multiple ? toggleItem : selectSingleItem; + if (previous === ComboboxState.S1) handleArrowDown(context, previous); // if we are in S1 and press enter, we want to open the menu + else if (context.highlightedIndex === undefined) { + const item = context.items.find(item => (context.itemTextKey ? item[context.itemTextKey] === context.search : item === context.search)); + if (item) functionToCall(context, item); + else { + if (context.search === '') return; + functionToCall(context, createItem(context.search, context.itemTextKey, context.itemValueKey)); + } + } else { + if (action === ComboboxAction.TAB) payload.preventDefault(); + functionToCall(context, context.filteredItems[context.highlightedIndex]); + } +} diff --git a/libs/anglify/src/components/data-table/data-table.component.html b/libs/anglify/src/components/data-table/data-table.component.html index f6770ebe..cb6bb4d3 100644 --- a/libs/anglify/src/components/data-table/data-table.component.html +++ b/libs/anglify/src/components/data-table/data-table.component.html @@ -118,8 +118,8 @@ (['5']); public readonly showFirstLastPageControls$ = new BehaviorSubject(this.settings.showFirstLastPageControls); - public itemsPerPageOptions = [ - { value: 5, text: '5' }, - { value: 10, text: '10' }, - { value: 15, text: '15' }, - { value: Number.POSITIVE_INFINITY, text: 'All' }, - ]; + public itemsPerPageOptions = ['5', '10', '15', 'All']; public constructor( @Host() @Inject('anglifyDataTableSettings') public settings: EntireDataTableSettings, @@ -34,10 +29,14 @@ export class PaginationService { .subscribe(); } - public readonly currentlyDisplayedItemsRange$ = combineLatest([this.page$, this.itemsPerPage$, this.dataService.filteredItems$]).pipe( + public readonly currentlyDisplayedItemsRange$ = combineLatest([ + this.page$, + this.itemsPerPage$.pipe(map(selection => selection[0])), + this.dataService.filteredItems$, + ]).pipe( map(([page, itemsPerPage, items]) => { - const start = (page - 1) * (itemsPerPage.value === Number.POSITIVE_INFINITY ? items.length : itemsPerPage.value); - let end = page * (itemsPerPage.value === Number.POSITIVE_INFINITY ? items.length : itemsPerPage.value); + const start = (page - 1) * (itemsPerPage === 'All' ? items.length : Number(itemsPerPage)); + let end = page * (itemsPerPage === 'All' ? items.length : Number(itemsPerPage)); if (end > items.length) end = items.length; return { start, end }; }) @@ -51,10 +50,10 @@ export class PaginationService { map(([items, range]) => items.slice(range.start, range.end)) ); - public readonly maxPages$ = combineLatest([this.dataService.sortedItems$, this.itemsPerPage$]).pipe( + public readonly maxPages$ = combineLatest([this.dataService.sortedItems$, this.itemsPerPage$.pipe(map(selection => selection[0]))]).pipe( map(([items, itemsPerPage]) => { - if (itemsPerPage.value === Number.POSITIVE_INFINITY) return 1; - return Math.ceil(items.length / itemsPerPage.value); + if (itemsPerPage === 'All') return 1; + return Math.ceil(items.length / Number(itemsPerPage)); }) ); diff --git a/libs/anglify/src/components/menu/menu/menu.component.ts b/libs/anglify/src/components/menu/menu/menu.component.ts index 0fc1be6a..6b2382a6 100644 --- a/libs/anglify/src/components/menu/menu/menu.component.ts +++ b/libs/anglify/src/components/menu/menu/menu.component.ts @@ -217,7 +217,7 @@ export class MenuComponent { private readonly menuClosed$ = this.value$.pipe(filter(value => !value)); public constructor( - private readonly elementRef: ElementRef, + public readonly elementRef: ElementRef, @Self() @Inject('anglifyMenuSettings') private readonly settings: EntireMenuSettings ) { bindObservableValueToNativeElement(this, this.actualPosition$, this.elementRef.nativeElement); @@ -381,4 +381,10 @@ export class MenuComponent { this.elementRef.nativeElement.style.removeProperty('--anglify-menu-computed-vertical-offset'); this.positionOverride$.next(null); } + + public scrollToHighlightedItem() { + const highlightedItem = this.menu?.nativeElement.querySelector('.highlight'); + if (!highlightedItem) return; + highlightedItem.scrollIntoView({ block: 'nearest' }); + } } diff --git a/libs/anglify/src/components/select/select-settings.token.ts b/libs/anglify/src/components/select/select-settings.token.ts index 3997482a..4fe4fbee 100644 --- a/libs/anglify/src/components/select/select-settings.token.ts +++ b/libs/anglify/src/components/select/select-settings.token.ts @@ -5,12 +5,19 @@ export const DEFAULT_SELECT_SETTINGS: EntireSelectSettings = { appearance: 'filled', alwaysFloatingLabel: false, persistentHint: false, - dropdownPosition: 'bottom', - dropdownAutoPosition: true, - dropdownOffset: 0, clearable: false, - closeOnSelect: true, noDataText: 'No data available', - addItem: 'Add item:', + multiple: false, + dropdownPosition: 'bottom', + disabled: false, + hideDetails: false, + itemTextKey: undefined, + itemValueKey: undefined, + error: undefined, + items: [], + dense: false, + hint: undefined, + label: undefined, + placeholder: undefined, }; export const SELECT_SETTINGS = new InjectionToken('Select Settings'); diff --git a/libs/anglify/src/components/select/select.component.html b/libs/anglify/src/components/select/select.component.html index 51983497..9f240a28 100644 --- a/libs/anglify/src/components/select/select.component.html +++ b/libs/anglify/src/components/select/select.component.html @@ -1,123 +1,103 @@ - - - - - - - - - {{ label }} - - -
-
- - - {{ selectedItemsText$ | async }} - + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ label }} + +
+ + +
+ {{ selectedItem | selectItemViewer: context.itemTextKey }}, +
+
+
+ +
+
+
+ + + + {{ noDataText }} + -
- - -
- - - - - - - - - -
- - - - + + diff --git a/libs/anglify/src/components/select/select.component.scss b/libs/anglify/src/components/select/select.component.scss index 17059af3..63e5d8cc 100644 --- a/libs/anglify/src/components/select/select.component.scss +++ b/libs/anglify/src/components/select/select.component.scss @@ -1,66 +1,124 @@ :host { - --anglify-menu-max-width: 100%; - --anglify-menu-border-radius: 4px; + --anglify-input-outlined-label-start-offset: 16px; + --anglify-input-outlined-label-end-offset: 16px; + --anglify-input-filled-label-start-offset: 16px; + --anglify-input-filled-label-end-offset: 16px; + --anglify-input-min-width: 0; + --anglify-input-min-height: 54px; + --anglify-input-min-height-dense: 40px; + --anglify-input-flex-basis: 100px; + --anglify-input-flex-grow: 1; + --anglify-input-flex-shrink: 1; - display: block; + anglify-menu { + width: 100%; - ::ng-deep { - .anglify-input-container { - cursor: pointer !important; + &:has(anglify-input:not(.anglify-input-hide-details)) { + --anglify-menu-max-width: 100%; + --anglify-menu-border-radius: 4px; + --anglify-menu-offset-bottom: -24px; } - .anglify-input-content { - overflow: hidden; + ::ng-deep { + anglify-input:not(.anglify-input-disabled) .anglify-input-container { + cursor: pointer !important; + } } + } - anglify-menu { - border-top-left-radius: 0; - border-top-right-radius: 0; + anglify-input { + ::ng-deep .anglify-input { + cursor: pointer; } - } - .input { - overflow: hidden; - min-width: 167px; - padding-inline-end: var(--anglify-input-filled-label-end-offset); - padding-inline-start: var(--anglify-input-filled-label-start-offset); + .selections { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding-inline-end: var(--anglify-input-filled-label-end-offset); + padding-inline-start: var(--anglify-input-filled-label-start-offset); - .value { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + .selection { + overflow: hidden; + min-width: 0; + max-width: 95%; + text-overflow: ellipsis; + white-space: nowrap; + } } - } - .outlined { - ::ng-deep { - anglify-menu { - border-radius: var(--anglify-menu-border-radius); + input { + min-width: 0; + height: 26px; + box-sizing: border-box; + flex: 1 1; + border: none; + background-color: transparent; + caret-color: var(--color-primary); + color: var(--color-on-surface-high-emphasis); + font: var(--font-body-1); + letter-spacing: var(--font-letter-spacing-body-1); + outline: none; + pointer-events: none; + text-transform: var(--font-text-transform-body-1); + + &::placeholder { + color: transparent; + transition: color 0.2s ease-in-out; + } + + &:disabled { + color: var(--color-on-surface-medium-emphasis); } } - } - .disabled { - color: var(--color-on-surface-low-emphasis); - } + &.anglify-input-focused { + ::ng-deep input::placeholder { + color: var(--color-on-surface-medium-emphasis); + } + } - anglify-icon { - &.disabled { - cursor: not-allowed; + &.anglify-input-floating, + &.anglify-input-always-floating-label { + ::ng-deep input::placeholder { + color: var(--color-on-surface-medium-emphasis); + } + + &.anglify-input-disabled { + ::ng-deep input::placeholder { + color: var(--color-on-surface-low-emphasis); + } + } } - } - .dropdown-menu { - max-height: inherit; - overflow-y: auto; - } + &.anglify-input-filled { + ::ng-deep { + .anglify-input-content { + padding-top: 24px; + } + } + } - .item { - cursor: pointer; + &.anglify-input-outlined { + ::ng-deep { + .anglify-input-content { + padding-top: 14px; + padding-bottom: 14px; + } + } + } + + &.anglify-input-dense { + ::ng-deep input { + height: var(--anglify-input-min-height-dense); + min-height: var(--anglify-input-min-height-dense); + } + } - &.disabled { - color: var(--color-on-surface-low-emphasis); - cursor: not-allowed; + &.anglify-input-disabled { + anglify-icon { + color: var(--color-on-surface-low-emphasis); + } } } } diff --git a/libs/anglify/src/components/select/select.component.ts b/libs/anglify/src/components/select/select.component.ts index 73874a86..c3865979 100644 --- a/libs/anglify/src/components/select/select.component.ts +++ b/libs/anglify/src/components/select/select.component.ts @@ -1,40 +1,43 @@ -import { AsyncPipe, NgForOf, NgIf } from '@angular/common'; +import { CommonModule } from '@angular/common'; +import type { OnChanges, SimpleChanges } from '@angular/core'; import { - Component, ChangeDetectionStrategy, - ViewChild, - Input, - Inject, - Self, + Component, ContentChildren, + EventEmitter, + forwardRef, + Inject, + Input, + Output, QueryList, - HostBinding, - HostListener, - Optional, + Self, + ViewChild, type AfterViewInit, - type OnInit, } from '@angular/core'; -import { NgControl, type ControlValueAccessor } from '@angular/forms'; +import type { ControlValueAccessor } from '@angular/forms'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { BehaviorSubject, firstValueFrom, map, ReplaySubject, share } from 'rxjs'; +import { distinctUntilChanged, fromEvent, map, merge, NEVER, of, switchMap, tap } from 'rxjs'; import { SlotDirective } from '../../directives/slot/slot.directive'; import { SlotOutletDirective } from '../../directives/slot-outlet/slot-outlet.directive'; import { createSettingsProvider } from '../../factories/settings.factory'; import { FindSlotPipe } from '../../pipes/find-slot/find-slot.pipe'; +import { SelectItemViewerPipe } from '../../pipes/select-item-viewer.pipe'; import { INTERNAL_ICONS } from '../../tokens/internal-icons.token'; import { rotate } from '../../utils/animations'; +import { Machine } from '../../utils/machine'; +import { ButtonComponent } from '../button/button.component'; import { IconComponent } from '../icon/icon.component'; import { InternalIconSetDefinition } from '../icon/icon.interface'; +import { InputComponent } from '../input/input.component'; import { InputDirective } from '../input/input.directive'; -import { InputAppearance } from '../input/input.interface'; -import { ListComponent } from '../list/components/list/list.component'; import { ListItemComponent } from '../list/components/list-item/list-item.component'; -import { ListItemGroupComponent } from '../list/components/list-item-group/list-item-group.component'; import { ListItemTitleComponent } from '../list/components/list-item-title/list-item-title.component'; import { MenuDirective } from '../menu/components/legacy-menu/legacy-menu.directive'; -import { TextFieldComponent } from '../text-field/text-field.component'; +import { MenuComponent } from '../menu/menu/menu.component'; import { DEFAULT_SELECT_SETTINGS, SELECT_SETTINGS } from './select-settings.token'; -import { EntireSelectSettings, type SelectItem } from './select.interface'; +import { EntireSelectSettings } from './select.interface'; +import { createSelectMachineConfig, SelectAction } from './select.machine'; @UntilDestroy() @Component({ @@ -42,304 +45,217 @@ import { EntireSelectSettings, type SelectItem } from './select.interface'; standalone: true, templateUrl: './select.component.html', styleUrls: ['./select.component.scss'], - providers: [createSettingsProvider('anglifySelectSettings', DEFAULT_SELECT_SETTINGS, SELECT_SETTINGS)], + providers: [ + createSettingsProvider('anglifySelectSettings', DEFAULT_SELECT_SETTINGS, SELECT_SETTINGS), + { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SelectComponent), multi: true }, + ], changeDetection: ChangeDetectionStrategy.OnPush, animations: [rotate()], imports: [ - TextFieldComponent, + CommonModule, + FindSlotPipe, + InputComponent, MenuDirective, - AsyncPipe, IconComponent, - NgIf, - ListComponent, ListItemComponent, ListItemTitleComponent, - ListItemGroupComponent, - FindSlotPipe, SlotOutletDirective, - InputDirective, - MenuDirective, SlotDirective, - NgForOf, + InputDirective, + MenuComponent, + ButtonComponent, + SelectItemViewerPipe, ], }) -export class SelectComponent implements ControlValueAccessor, OnInit, AfterViewInit { - @ContentChildren(SlotDirective) public readonly slots?: QueryList; - - @ViewChild(InputDirective, { static: true }) public readonly input!: InputDirective; - - @ViewChild(MenuDirective, { static: true }) public readonly menu!: MenuDirective; - - /** - * Sets the input label. - */ - @Input() public label?: string; - - /** - * Reduces the input height. - */ - @Input() public dense = false; - - /** - * Sets the input’s placeholder text - */ - @Input() public placeholder?: string; - - /** - * Hint text. - */ - @Input() public hint?: string; - - /** - * Forces label to always be in floating mode. - */ - @Input() public alwaysFloatingLabel: boolean = this.settings.alwaysFloatingLabel; - - /** - * Forces hint to always be visible. - */ - @Input() public persistentHint: boolean = this.settings.persistentHint; - - /** - * Sets one of the two predefined input styles (`filled` or `outlined`). - */ - @Input() public appearance: InputAppearance = this.settings.appearance; - - /** - * Add input clear functionality (appends an clear icon). - */ - @Input() public clearable: boolean = this.settings.clearable; - - /** - * Sets the position of the menu. - */ - @Input() public dropdownPosition = this.settings.dropdownPosition; +export class SelectComponent implements AfterViewInit, OnChanges, EntireSelectSettings, ControlValueAccessor { + @ContentChildren(SlotDirective) protected readonly slots?: QueryList; - /** - * Automatically determines the best position for the menu. If possible the preset position is used. - */ - @Input() public dropdownAutoPosition = this.settings.dropdownAutoPosition; - - /** - * Displaces the menu from the input element along the relevant axes. - */ - @Input() public dropdownOffset = this.settings.dropdownOffset; - - /** - * Hides hint and validation errors. - */ - @Input() public hideDetails = false; - - /** - * Puts input in readonly state. - */ - @Input() public readonly = false; - - /** - * Changes select to multiple. Accepts array for value. - */ - @Input() public multiple = false; - - /** - * Designates if menu should close when its content is clicked. - */ - @Input() public closeOnSelect: boolean = this.settings.closeOnSelect; - - /** - * Display text when there is no data. - */ - @Input() public noDataText: string = this.settings.noDataText; - - public get error() { - return this._error$.value; - } + @ViewChild(InputDirective) protected readonly input?: InputDirective; - /** - * Puts the input in a manual error state. - */ - @Input() public set error(error: string | undefined) { - this._error$.next(error); - } + @ViewChild('anglifyInput', { read: InputComponent }) public anglifyInput?: InputComponent; - public get disabled() { - return this._disabled$.value; - } + @ViewChild('menu') public menu?: MenuComponent; - /** - * Disables the input. - */ - @Input() public set disabled(isDisabled: boolean) { - this._disabled$.next(isDisabled); - } + @Input() public label = this.settings.label; - /** - * Can be an array of objects or array of strings/numbers. - */ - @Input() public set items(items: number[] | SelectItem[] | string[]) { - if (this.assumePrimitive(items)) { - const primitiveItems = items as boolean[] | number[] | string[]; - this._items$.next(this.mapPrimitiveToSelectItem(primitiveItems)); - } else { - this._items$.next(items as SelectItem[]); - } - } + @Input() public dense = this.settings.dense; + + @Input() public placeholder = this.settings.placeholder; + + @Input() public hint = this.settings.hint; - protected readonly _error$ = new BehaviorSubject(undefined); + @Input() public alwaysFloatingLabel = this.settings.alwaysFloatingLabel; - public readonly error$ = this._error$.asObservable().pipe(share({ connector: () => new ReplaySubject(1) })); + @Input() public persistentHint = this.settings.persistentHint; + + @Input() public appearance = this.settings.appearance; + + @Input() public dropdownPosition = this.settings.dropdownPosition; - protected readonly _disabled$ = new BehaviorSubject(false); + @Input() public hideDetails = this.settings.hideDetails; - public readonly disabled$ = this._disabled$.asObservable().pipe(share({ connector: () => new ReplaySubject(1) })); + @Input() public multiple = this.settings.multiple; - protected readonly _isOpen$ = new BehaviorSubject(false); + @Input() public noDataText = this.settings.noDataText; - public readonly isOpen$ = this._isOpen$.asObservable().pipe(share({ connector: () => new ReplaySubject(1) })); + @Input() public error = this.settings.error; - protected readonly _items$ = new BehaviorSubject([]); + @Input() public disabled = this.settings.disabled; - public readonly items$ = this._items$.asObservable(); + @Input() public items = this.settings.items; - protected readonly _selectedItems$ = new BehaviorSubject([]); + @Input() public itemTextKey = this.settings.itemTextKey; - public readonly selectedItems$ = this._selectedItems$.asObservable().pipe(share({ connector: () => new ReplaySubject(1) })); + @Input() public itemValueKey = this.settings.itemValueKey; - public readonly selectedItemsText$ = this.selectedItems$.pipe( - map(items => items.map(item => item.text)), - map(texts => texts.join(', ')), - share({ connector: () => new ReplaySubject(1) }) - ); + @Input() public clearable = this.settings.clearable; - public readonly isItemSelected$ = (item: SelectItem) => - this.selectedItems$.pipe(map(selectedItems => selectedItems.some(selected => selected.value === item.value))); + @Input() public value: any[] = []; - public onChange: (...args: any[]) => void = () => {}; + @Output() public readonly valueChange = new EventEmitter(); - public onTouch: (...args: any[]) => void = () => {}; + protected machine = new Machine(createSelectMachineConfig(this)); public constructor( @Self() @Inject('anglifySelectSettings') public settings: EntireSelectSettings, - @Inject(INTERNAL_ICONS) public readonly internalIcons: InternalIconSetDefinition, - @Optional() @Self() public readonly ngControl?: NgControl - ) { - if (this.ngControl) this.ngControl.valueAccessor = this; - } + @Inject(INTERNAL_ICONS) public readonly internalIcons: InternalIconSetDefinition + ) {} - public ngOnInit() { - this.input.ngControl = this.ngControl; - } + private onChange: (...args: any[]) => void = () => {}; - public ngAfterViewInit() { - this.menu.isOpen$.pipe(untilDestroyed(this)).subscribe(isOpen => { - this._isOpen$.next(isOpen); - }); + public writeValue(value: any) { + this.machine.context$.next({ ...this.machine.context$.value, selectedItems: this.transformValuesToItems(value) }); } - public registerOnChange(fn: (...args: any[]) => void) { + public registerOnChange(fn: any) { this.onChange = fn; } - public registerOnTouched(fn: (...args: any[]) => void) { - this.onTouch = fn; - } - - public writeValue(item: any) { - if (item !== undefined && item !== null) { - if (!this._selectedItems$.value.length) { - if (Array.isArray(item)) { - this._selectedItems$.next(item as SelectItem[]); - } else { - this._selectedItems$.next([item as SelectItem]); - } - } + public registerOnTouched(_: any) {} - if (!Array.isArray(item) && !this.multiple) { - this.input.elementRef.nativeElement.value = (item as SelectItem).text; - } else { - this.input.elementRef.nativeElement.value = ''; + public ngOnChanges(changes: SimpleChanges) { + const context = this.machine.context$.value as any; + for (const key of Object.keys(changes)) { + if (context[key] !== changes[key].currentValue) { + if (key === 'value' && context[key] !== changes[key].currentValue) { + context.selectedItems = this.transformValuesToItems(changes[key].currentValue); + } else context[key] = changes[key].currentValue; } - } else { - this.input.elementRef.nativeElement.value = ''; } - this.onChange(item); + this.machine.context$.next(context); } - public setDisabledState(isDisabled: boolean) { - this._disabled$.next(isDisabled); + public ngAfterViewInit() { + this.bindMachineEvents(); + this.setInputValue(); + this.handleMenuScroll(); + this.notifyValueChange(); } - @HostBinding('class.outlined') - public get hasOutlinedAppearance() { - return this.appearance === 'outlined'; + private handleMenuScroll() { + merge( + this.machine.context$.pipe( + map(context => context.highlightedIndex), + distinctUntilChanged() + ), + this.machine.context$.pipe(map(context => context.menuOpened)) + ) + .pipe(untilDestroyed(this)) + .subscribe(() => setTimeout(() => this.menu!.scrollToHighlightedItem(), 0)); } - @HostListener('document:keydown', ['$event', '$event.key']) - public onEscapeDown(_: MouseEvent, key: string) { - if (key === 'Escape' && this._isOpen$.value) { - this.input.elementRef.nativeElement.blur(); - this.menu.close(); - } + private notifyValueChange() { + this.machine.context$ + .pipe( + untilDestroyed(this), + map(context => context.selectedItems), + distinctUntilChanged(), + tap(selectedItems => { + this.valueChange.emit(this.transformSelectedItemsToValues(selectedItems)); + this.onChange(this.transformSelectedItemsToValues(selectedItems)); + }) + ) + .subscribe(); } - public trackItem(_index: number, item: SelectItem) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return item.value; + private setInputValue() { + const input = this.input!.elementRef.nativeElement; + merge(fromEvent(input, 'blur'), this.machine.context$) + .pipe( + tap(() => { + const context = this.machine.context$.value; + if (context.multiple) { + input.value = ''; + } else if (context.selectedItems.length > 0) { + input.value = context.selectedItems.map(item => SelectItemViewerPipe.transform(item, context.itemTextKey)).join(', '); + } else { + input.value = ''; + } + }) + ) + .pipe(untilDestroyed(this)) + .subscribe(); } - public select = async (item: SelectItem) => { - if (this.disabled || this.readonly || item.disabled) return; - - if (this.multiple) { - try { - if (await firstValueFrom(this.isItemSelected$(item), { defaultValue: false })) { - this._selectedItems$.next(this._selectedItems$.value.filter(selected => selected.value !== item.value)); - } else { - this._selectedItems$.next([...this._selectedItems$.value, item]); - } - - if (this._selectedItems$.value.length) { - this.writeValue(this._selectedItems$.value); - } else { - this.writeValue(null); - } - } catch {} - } else { - this._selectedItems$.next([item]); - this.writeValue(item); - } - - if (this.closeOnSelect) { - this.menu.close(); - } - }; - - public clearSelection = () => { - if (this.disabled || this.readonly) return; - - this._selectedItems$.next([]); - if (this.multiple) { - if (this._selectedItems$.value.length) { - this.writeValue(this._selectedItems$.value); - } else { - this.writeValue(null); - } - } else { - this.writeValue(null); - } - }; + private bindMachineEvents() { + const input = this.input!.elementRef.nativeElement; + merge( + this.anglifyInput!.onInputClick.pipe( + map(() => ({ action: SelectAction.CLICK })), + tap(() => input.focus()) + ), + fromEvent(this.menu!.elementRef.nativeElement, 'mousedown').pipe(tap(event => event.preventDefault())), + fromEvent(input, 'focusin').pipe(map(() => ({ action: SelectAction.FOCUS }))), + fromEvent(input, 'focusout').pipe(map(() => ({ action: SelectAction.BLUR }))), + fromEvent(input, 'keydown').pipe( + map(event => event as KeyboardEvent), + switchMap(event => { + if (event.key === 'Enter') { + event.preventDefault(); + return of({ action: SelectAction.ENTER }); + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + return of({ action: SelectAction.ARROW_DOWN }); + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + return of({ action: SelectAction.ARROW_UP }); + } + + if (event.key === 'Escape') { + event.preventDefault(); + return of({ action: SelectAction.ESCAPE }); + } + + return NEVER; + }) + ) + ) + .pipe( + untilDestroyed(this), + map(data => data as { action: SelectAction; payload?: any }), + tap(({ action, payload }) => this.machine.next(action, payload)) + ) + .subscribe(); + } - public toggleMenu = () => { - if (this.disabled || this.readonly) return; + protected onItemClick = (item: any) => this.machine.next(SelectAction.ITEM_CLICK, item); - this.menu.toggle(); - }; + protected clear = () => this.machine.next(SelectAction.CLEAR); - protected assumePrimitive(items: number[] | SelectItem[] | string[]) { - return typeof items[0] === 'string' || typeof items[0] === 'number' || typeof items[0] === 'boolean'; + private transformSelectedItemsToValues(selectedItems: any[]) { + const itemValueKey = this.itemValueKey; + if (itemValueKey) return selectedItems.map(item => item[itemValueKey]); + else return selectedItems; } - protected mapPrimitiveToSelectItem(items: boolean[] | number[] | string[]): SelectItem[] { - return items.map(item => ({ text: item.toLocaleString(), value: item })); + private transformValuesToItems(values: any[]) { + const itemValueKey = this.itemValueKey; + if (itemValueKey) return this.items.filter(item => values.includes(item[itemValueKey])); + else return values; } } diff --git a/libs/anglify/src/components/select/select.interface.ts b/libs/anglify/src/components/select/select.interface.ts index 249568d1..6e5f8fc3 100644 --- a/libs/anglify/src/components/select/select.interface.ts +++ b/libs/anglify/src/components/select/select.interface.ts @@ -1,4 +1,4 @@ -import type { Position } from '../../composables/position/position.interface'; +import type { Side } from '@floating-ui/dom'; import type { InputAppearance } from '../input/input.interface'; export type SelectItem = { @@ -8,16 +8,75 @@ export type SelectItem = { }; export type EntireSelectSettings = { - addItem: string; - alwaysFloatingLabel: false; + /** + * Forces label to always be in floating mode. + */ + alwaysFloatingLabel: boolean; + /** + * Sets one of the two predefined input styles (`filled` or `outlined`). + */ appearance: InputAppearance; + /** + * Add input clear functionality (appends an clear icon). + */ clearable: boolean; - closeOnSelect: boolean; - dropdownAutoPosition: boolean; - dropdownOffset: number; - dropdownPosition: Position; + /** + * Reduces the input height. + */ + dense: boolean; + /** + * Disables the input. + */ + disabled: boolean; + /** + * Sets the position of the menu. + */ + dropdownPosition: Side; + /** + * Puts the input in a manual error state. + */ + error: string | undefined; + /** + * Hides hint and validation errors. + */ + hideDetails: boolean; + /** + * Hint text. + */ + hint: string | undefined; + /** + * Key to use for the value. Required if items is an array of objects. It's assumed + * that items is an array of primitives if this is not set. + */ + itemTextKey: string | undefined; + /** + * Key to use for the value. Whole item is used as value if this is not set. + */ + itemValueKey: string | undefined; + /** + * Can be an array of objects or array of strings/numbers. + */ + items: any[]; + /** + * Sets the input label. + */ + label: string | undefined; + /** + * Changes select to multiple. Accepts array for value. + */ + multiple: boolean; + /** + * Display text when there is no data. + */ noDataText: string; - persistentHint: false; + /** + * Forces hint to always be visible. + */ + persistentHint: boolean; + /** + * Sets the input’s placeholder text + */ + placeholder: string | undefined; }; export type SelectSettings = Partial; diff --git a/libs/anglify/src/components/select/select.machine.drawio b/libs/anglify/src/components/select/select.machine.drawio new file mode 100644 index 00000000..2e829223 --- /dev/null +++ b/libs/anglify/src/components/select/select.machine.drawio @@ -0,0 +1,248 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/libs/anglify/src/components/select/select.machine.ts b/libs/anglify/src/components/select/select.machine.ts new file mode 100644 index 00000000..04fc2234 --- /dev/null +++ b/libs/anglify/src/components/select/select.machine.ts @@ -0,0 +1,145 @@ +import type { MachineConfig } from '../../utils/machine'; + +export enum SelectState { + IDLE = 'IDLE', + S1 = 'S1', // Focused with closed menu + S2 = 'S2', // Focused with opened menu +} +export enum SelectAction { + ARROW_DOWN = 'ARROW_DOWN', + ARROW_UP = 'ARROW_UP', + BLUR = 'BLUR', + CLEAR = 'CLEAR', + CLICK = 'CLICK', + ENTER = 'ENTER', + ESCAPE = 'ESCAPE', + FOCUS = 'FOCUS', + ITEM_CLICK = 'ITEM_CLICK', +} +export type SelectContext = { + clearable: boolean; + disabled: boolean; + highlightedIndex: number | undefined; + itemTextKey: string | undefined; + itemValueKey: string | undefined; + items: any[]; + menuOpened: boolean; + multiple: boolean; + selectedItems: any[]; +}; + +export const initialContext: SelectContext = { + highlightedIndex: undefined, + menuOpened: false, + items: [], + selectedItems: [], + multiple: false, + itemTextKey: undefined, + itemValueKey: undefined, + clearable: false, + disabled: false, +}; + +export function createSelectMachineConfig(context: Partial): MachineConfig { + return { + context: { ...initialContext, ...context }, + initial: SelectState.IDLE, + states: { + IDLE: { + on: { + FOCUS: SelectState.S1, + CLICK: context => (context.disabled ? SelectState.IDLE : SelectState.S2), + CLEAR: SelectState.IDLE, + }, + do: { + beforeEach: context => { + context.menuOpened = false; + context.highlightedIndex = undefined; + }, + CLEAR: context => { + context.selectedItems = []; + }, + }, + }, + S1: { + on: { + ENTER: SelectState.S2, + ARROW_DOWN: SelectState.S2, + ARROW_UP: SelectState.S2, + BLUR: SelectState.IDLE, + CLEAR: SelectState.IDLE, + CLICK: SelectState.S2, + }, + do: { + beforeEach: context => { + context.menuOpened = false; + }, + ENTER: context => + handleSelection( + context, + context.items.find((_, index) => index === context.highlightedIndex) + ), + ITEM_CLICK: (context, _, payload) => handleSelection(context, payload), + }, + }, + S2: { + on: { + ESCAPE: SelectState.S1, + CLEAR: SelectState.IDLE, + BLUR: SelectState.IDLE, + ENTER: context => (context.multiple ? SelectState.S2 : SelectState.S1), + ITEM_CLICK: context => (context.multiple ? SelectState.S2 : SelectState.S1), + ARROW_DOWN: SelectState.S2, + ARROW_UP: SelectState.S2, + }, + do: { + beforeEach: context => { + context.menuOpened = true; + }, + ENTER: (context, _action, _payload, previous) => { + if (previous === SelectState.S2) { + handleSelection( + context, + context.items.find((_, index) => index === context.highlightedIndex) + ); + } else if (previous === SelectState.S1 && context.highlightedIndex === undefined) { + context.highlightedIndex = + context.selectedItems.length > 0 ? context.items.indexOf(context.selectedItems[0]) : context.items.length - 1; + } + }, + ARROW_DOWN: (context, _action, _payload, previous) => { + if (previous === SelectState.S1 && context.highlightedIndex !== undefined) return; + if (context.highlightedIndex === undefined) { + context.highlightedIndex = context.selectedItems.length > 0 ? context.items.indexOf(context.selectedItems[0]) : 0; + } else { + context.highlightedIndex = (context.highlightedIndex + 1) % context.items.length; + } + }, + ARROW_UP: (context, _action, _payload, previous) => { + if (previous === SelectState.S1 && context.highlightedIndex !== undefined) return; + if (context.highlightedIndex === undefined) { + context.highlightedIndex = + context.selectedItems.length > 0 ? context.items.indexOf(context.selectedItems[0]) : context.items.length - 1; + } else { + context.highlightedIndex = (context.highlightedIndex - 1 + context.items.length) % context.items.length; + } + }, + ITEM_CLICK: (context, _, payload) => handleSelection(context, payload), + }, + }, + }, + }; +} + +function handleSelection(context: SelectContext, item: any) { + if (context.multiple) { + const index = context.selectedItems.indexOf(item); + if (index > -1) { + context.selectedItems.splice(index, 1); + } else { + context.selectedItems.push(item); + } + } else { + context.selectedItems = [item]; + } +} diff --git a/libs/anglify/src/pipes/find-slot/find-slot.pipe.spec.ts b/libs/anglify/src/pipes/find-slot/find-slot.pipe.spec.ts deleted file mode 100644 index 51d313d6..00000000 --- a/libs/anglify/src/pipes/find-slot/find-slot.pipe.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { FindSlotPipe } from './find-slot.pipe'; - -describe('FindSlotPipe', () => { - it('create an instance', () => { - const pipe = new FindSlotPipe(); - expect(pipe).toBeTruthy(); - }); -}); diff --git a/libs/anglify/src/pipes/select-item-viewer.pipe.ts b/libs/anglify/src/pipes/select-item-viewer.pipe.ts new file mode 100644 index 00000000..2865f021 --- /dev/null +++ b/libs/anglify/src/pipes/select-item-viewer.pipe.ts @@ -0,0 +1,20 @@ +import type { PipeTransform } from '@angular/core'; +import { Pipe } from '@angular/core'; + +@Pipe({ + name: 'selectItemViewer', + standalone: true, +}) +export class SelectItemViewerPipe implements PipeTransform { + public transform(item: any | string, itemText: string | undefined): string { + return SelectItemViewerPipe.transform(item, itemText); + } + + /** + * If itemText is defined, it will return the value of the itemText property of each item. + * It's assumed that the items are strings if itemText is undefined. + */ + public static transform(item: any | string, itemText: string | undefined): string { + return itemText ? item[itemText] : item; + } +} diff --git a/libs/anglify/src/utils/machine.ts b/libs/anglify/src/utils/machine.ts new file mode 100644 index 00000000..7d1f021e --- /dev/null +++ b/libs/anglify/src/utils/machine.ts @@ -0,0 +1,56 @@ +import { BehaviorSubject } from 'rxjs'; + +export type State = { + do: { + [key in A | 'beforeEach']?: (context: C, action: A, payload: any, previousState: S) => void; + }; + on: { + [key in A]?: S | ((context: C) => S); + }; +}; + +export type MachineConfig = { + context: C; + initial: S; + states: { + [K in S]?: State; + }; +}; + +export class Machine { + public readonly currentState$ = new BehaviorSubject(this.config.initial); + + public readonly context$ = new BehaviorSubject({ ...this.config.context }); + + public constructor(public readonly config: MachineConfig) {} + + public next(action: A, payload?: any) { + const currentStateObject = this.config.states[this.currentState$.value]; + if (!currentStateObject) return; + const nextState = this.getNextStateName(currentStateObject, action); + if (!nextState) return; + const nextStateObject = this.config.states[nextState]; + if (!nextStateObject) return; + const beforeEach = nextStateObject.do.beforeEach; + if (beforeEach) { + const context = { ...this.context$.value }; + beforeEach(context, action, payload, this.currentState$.value); + this.context$.next(context); + } + + const effect = nextStateObject.do[action]; + if (effect) { + const context = { ...this.context$.value }; + effect(context, action, payload, this.currentState$.value); + this.context$.next(context); + } + + this.currentState$.next(nextState); + } + + private getNextStateName(currentStateObject: State, action: A): S | undefined { + const nextState = currentStateObject.on[action] as S | ((context: C) => S); + if (!nextState) return; + return typeof nextState === 'function' ? nextState(this.context$.value) : nextState; + } +}