From 553771a833a8c83bf7dbf80f43dc35c74bc7bc6f Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Thu, 11 Apr 2024 06:53:17 +0200 Subject: [PATCH 01/11] feat(select): add inital select component with sample page --- apps/demo-app/src/app/app.routes.ts | 7 ++ .../pages/overview/overview.component.html | 3 + .../select-sample/select-sample.component.css | 11 ++ .../select-sample.component.html | 11 ++ .../select-sample.component.spec.ts | 21 ++++ .../select-sample/select-sample.component.ts | 33 ++++++ apps/demo-app/src/styles.css | 3 + libs/sketch/package-lock.json | 72 ++++++++++++ libs/sketch/package.json | 2 + libs/sketch/src/index.ts | 2 + .../select-option/select-option.component.css | 0 .../select-option.component.html | 1 + .../select-option/select-option.component.ts | 21 ++++ .../select/directives/multiple.directive.ts | 9 ++ .../select/directives/overlay.directive.ts | 107 ++++++++++++++++++ libs/sketch/src/lib/select/index.ts | 4 + .../src/lib/select/select.component.css | 0 .../src/lib/select/select.component.html | 17 +++ .../sketch/src/lib/select/select.component.ts | 78 +++++++++++++ package-lock.json | 29 +++++ package.json | 1 + 21 files changed, 432 insertions(+) create mode 100644 apps/demo-app/src/app/pages/select-sample/select-sample.component.css create mode 100644 apps/demo-app/src/app/pages/select-sample/select-sample.component.html create mode 100644 apps/demo-app/src/app/pages/select-sample/select-sample.component.spec.ts create mode 100644 apps/demo-app/src/app/pages/select-sample/select-sample.component.ts create mode 100644 libs/sketch/package-lock.json create mode 100644 libs/sketch/src/lib/select/components/select-option/select-option.component.css create mode 100644 libs/sketch/src/lib/select/components/select-option/select-option.component.html create mode 100644 libs/sketch/src/lib/select/components/select-option/select-option.component.ts create mode 100644 libs/sketch/src/lib/select/directives/multiple.directive.ts create mode 100644 libs/sketch/src/lib/select/directives/overlay.directive.ts create mode 100644 libs/sketch/src/lib/select/index.ts create mode 100644 libs/sketch/src/lib/select/select.component.css create mode 100644 libs/sketch/src/lib/select/select.component.html create mode 100644 libs/sketch/src/lib/select/select.component.ts diff --git a/apps/demo-app/src/app/app.routes.ts b/apps/demo-app/src/app/app.routes.ts index 7f027c9..3a83079 100644 --- a/apps/demo-app/src/app/app.routes.ts +++ b/apps/demo-app/src/app/app.routes.ts @@ -15,6 +15,13 @@ export const appRoutes: Route[] = [ (m) => m.OverviewComponent ), }, + { + path: 'select', + loadComponent: () => + import('./pages/select-sample/select-sample.component').then( + (m) => m.SelectSampleComponent + ), + }, { path: 'list-sample', children: [ diff --git a/apps/demo-app/src/app/pages/overview/overview.component.html b/apps/demo-app/src/app/pages/overview/overview.component.html index 16aa922..cd69568 100644 --- a/apps/demo-app/src/app/pages/overview/overview.component.html +++ b/apps/demo-app/src/app/pages/overview/overview.component.html @@ -4,3 +4,6 @@ Lorem ipsum dolor... + + Lorem ipsum dolor... + diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.css b/apps/demo-app/src/app/pages/select-sample/select-sample.component.css new file mode 100644 index 0000000..7d46ccb --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.css @@ -0,0 +1,11 @@ +.sk-select-content { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +sk-select-option { + color: deeppink; + font-weight: bold; + font-size: 1.5em; +} diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html new file mode 100644 index 0000000..08662e2 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html @@ -0,0 +1,11 @@ +Multiple: + + +
+ Test 1 + Test 2 + Test 3 + Test 4 + Test 5 +
+
diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.spec.ts b/apps/demo-app/src/app/pages/select-sample/select-sample.component.spec.ts new file mode 100644 index 0000000..39ef70e --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SelectSampleComponent } from './select-sample.component'; + +describe('SelectSampleComponent', () => { + let component: SelectSampleComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SelectSampleComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SelectSampleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts new file mode 100644 index 0000000..11a9aaa --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts @@ -0,0 +1,33 @@ +import { Component, model, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + MultipleDirective, + SelectComponent, + SelectOptionComponent, +} from '@qupaya/sketch'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-select-sample', + standalone: true, + imports: [ + CommonModule, + SelectComponent, + SelectOptionComponent, + MultipleDirective, + FormsModule, + ], + templateUrl: './select-sample.component.html', + styleUrl: './select-sample.component.css', +}) +export class SelectSampleComponent { + test = model(); + multiple = signal(false); + + switchMultiple(event: Event): void { + console.log('event', event); + if ('checked' in event.target!) { + this.multiple.set(event.target.checked as boolean); + } + } +} diff --git a/apps/demo-app/src/styles.css b/apps/demo-app/src/styles.css index a130ed3..8d63845 100644 --- a/apps/demo-app/src/styles.css +++ b/apps/demo-app/src/styles.css @@ -1,3 +1,6 @@ +/* TODO: move to lib component*/ +@import '@angular/cdk/overlay-prebuilt.css'; + /* You can add global styles to this file, and also import other style files */ :root { --sk-top-bar-height: 84px; diff --git a/libs/sketch/package-lock.json b/libs/sketch/package-lock.json new file mode 100644 index 0000000..de90213 --- /dev/null +++ b/libs/sketch/package-lock.json @@ -0,0 +1,72 @@ +{ + "name": "@qupaya/sketch", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@qupaya/sketch", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^17.3.0", + "@angular/core": "^17.3.0" + } + }, + "node_modules/@angular/common": { + "version": "17.3.3", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.3.3.tgz", + "integrity": "sha512-GwlKetNpfWKiG2j4S6bYTi6PA2iT4+eln7o8owo44xZWdQnWQjfxnH39vQuCyhi6OOQL1dozmae+fVXgQsV6jQ==", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0" + }, + "peerDependencies": { + "@angular/core": "17.3.3", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/core": { + "version": "17.3.3", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-17.3.3.tgz", + "integrity": "sha512-O/jr3aFJMCxF6Jmymjx4jIigRHJfqM/ALIi60y2LVznBVFkk9xyMTsAjgWQIEHX+2muEIzgfKuXzpL0y30y+wA==", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0" + }, + "peerDependencies": { + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.14.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "peer": true + }, + "node_modules/zone.js": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.4.tgz", + "integrity": "sha512-NtTUvIlNELez7Q1DzKVIFZBzNb646boQMgpATo9z3Ftuu/gWvzxCW7jdjcUDoRGxRikrhVHB/zLXh1hxeJawvw==", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + } + } + } +} diff --git a/libs/sketch/package.json b/libs/sketch/package.json index 7fe68ad..14b9cea 100644 --- a/libs/sketch/package.json +++ b/libs/sketch/package.json @@ -3,6 +3,8 @@ "version": "0.0.1", "peerDependencies": { "@angular/common": "^17.3.0", + "@angular/cdk": "^17.3.3", + "@angular/forms": "~17.3.0", "@angular/core": "^17.3.0", "@angular/router": "~17.3.0", "rxjs": "~7.8.0" diff --git a/libs/sketch/src/index.ts b/libs/sketch/src/index.ts index 43b2280..7b90ebf 100644 --- a/libs/sketch/src/index.ts +++ b/libs/sketch/src/index.ts @@ -1,2 +1,4 @@ +export * from './lib/components/sketch/sketch.component'; +export * from './lib/select'; export * from './lib/components/list'; export * from './lib/components/sketch/sketch.component'; diff --git a/libs/sketch/src/lib/select/components/select-option/select-option.component.css b/libs/sketch/src/lib/select/components/select-option/select-option.component.css new file mode 100644 index 0000000..e69de29 diff --git a/libs/sketch/src/lib/select/components/select-option/select-option.component.html b/libs/sketch/src/lib/select/components/select-option/select-option.component.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/libs/sketch/src/lib/select/components/select-option/select-option.component.html @@ -0,0 +1 @@ + diff --git a/libs/sketch/src/lib/select/components/select-option/select-option.component.ts b/libs/sketch/src/lib/select/components/select-option/select-option.component.ts new file mode 100644 index 0000000..ace3c92 --- /dev/null +++ b/libs/sketch/src/lib/select/components/select-option/select-option.component.ts @@ -0,0 +1,21 @@ +import { Component, HostListener, inject, input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SelectComponent } from '../../select.component'; + +@Component({ + selector: 'sk-select-option', + standalone: true, + imports: [CommonModule], + templateUrl: './select-option.component.html', + styleUrl: './select-option.component.css', +}) +export class SelectOptionComponent { + private readonly parent = inject(SelectComponent); + value = input.required(); + + @HostListener('click', ['$event']) + selectItem(): void { + console.log('selectItem', this.value(), typeof this.value()); + this.parent.selectionChanged(this.value()); + } +} diff --git a/libs/sketch/src/lib/select/directives/multiple.directive.ts b/libs/sketch/src/lib/select/directives/multiple.directive.ts new file mode 100644 index 0000000..90ae30c --- /dev/null +++ b/libs/sketch/src/lib/select/directives/multiple.directive.ts @@ -0,0 +1,9 @@ +import { booleanAttribute, Directive, input } from '@angular/core'; + +@Directive({ + selector: '[skMultiple]', + standalone: true, +}) +export class MultipleDirective { + multiple = input(true, { transform: booleanAttribute, alias: 'skMultiple' }); +} diff --git a/libs/sketch/src/lib/select/directives/overlay.directive.ts b/libs/sketch/src/lib/select/directives/overlay.directive.ts new file mode 100644 index 0000000..8452204 --- /dev/null +++ b/libs/sketch/src/lib/select/directives/overlay.directive.ts @@ -0,0 +1,107 @@ +import { + Directive, + ElementRef, + inject, + input, + OnChanges, + OnDestroy, + OnInit, + output, +} from '@angular/core'; +import { CdkPortal } from '@angular/cdk/portal'; +import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { DOCUMENT } from '@angular/common'; + +export const DEFAULT_POSITIONS: ConnectedPosition[] = [ + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }, +]; + +export const DEFAULT_DROPOUT_POSITIONS: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom' }, +]; + +@Directive({ + selector: '[skCdkOverlay]', + standalone: true, +}) +export class CdkOverlayDirective implements OnChanges, OnInit, OnDestroy { + private readonly overlay = inject(Overlay); + private readonly elementRef = inject(ElementRef); + private readonly window = inject(DOCUMENT).defaultView; + + portal = input(undefined, { alias: 'skCdkOverlay' }); + show = input(false, { alias: 'skCdkOverlayShow' }); + connectedPositions = input(DEFAULT_POSITIONS, { + alias: 'skCdkOverlayPositions', + }); + disposeDelay = input(0, { alias: 'skCdkOverlayDisposeDelay' }); + relativeTo = input(undefined, { + alias: 'skCdkOverlayRelativeTo', + }); + + showChange = output({ alias: 'skCdkOverlayShowChange' }); + + private _overlayRef?: OverlayRef; + private _relatedElement?: HTMLElement; + + public ngOnInit(): void { + this._relatedElement = this.relativeTo() || this.elementRef.nativeElement; + } + + public ngOnChanges(): void { + if (this._relatedElement) { + if (this.show()) { + this._show(); + } else { + this._hide(); + } + } + } + + public ngOnDestroy(): void { + this._hide(); + } + + private _show(): void { + this._createOverlay(); + } + + private _hide(): void { + this.window?.setTimeout( + () => this._overlayRef?.dispose(), + this.disposeDelay() + ); + } + + private _createOverlay(): void { + if (!this._relatedElement) { + return; + } + + const positionStrategy = this.overlay + .position() + .flexibleConnectedTo(this._relatedElement) + .withPositions(this.connectedPositions()) + .withPush(true) + .withFlexibleDimensions(false); + + this._overlayRef = this.overlay.create({ + hasBackdrop: true, + backdropClass: 'cdk-overlay-transparent-backdrop', + positionStrategy, + scrollStrategy: this.overlay.scrollStrategies.reposition({ + autoClose: true, + }), + }); + + this._overlayRef.attach(this.portal()); + this._overlayRef.detachments().subscribe(() => { + this._hide(); + this.showChange.emit(false); + }); + this._overlayRef.backdropClick().subscribe(() => { + this._overlayRef?.detach(); + }); + } +} diff --git a/libs/sketch/src/lib/select/index.ts b/libs/sketch/src/lib/select/index.ts new file mode 100644 index 0000000..8e64083 --- /dev/null +++ b/libs/sketch/src/lib/select/index.ts @@ -0,0 +1,4 @@ +export * from './components/select-option/select-option.component'; +export * from './select.component'; +export * from './directives/overlay.directive'; +export * from './directives/multiple.directive'; diff --git a/libs/sketch/src/lib/select/select.component.css b/libs/sketch/src/lib/select/select.component.css new file mode 100644 index 0000000..e69de29 diff --git a/libs/sketch/src/lib/select/select.component.html b/libs/sketch/src/lib/select/select.component.html new file mode 100644 index 0000000..19875d5 --- /dev/null +++ b/libs/sketch/src/lib/select/select.component.html @@ -0,0 +1,17 @@ +
+ @if (selectedValue()) { + {{ selectedValue() }} + } @else { + {{ placeholder() }} + } +
+ + + + diff --git a/libs/sketch/src/lib/select/select.component.ts b/libs/sketch/src/lib/select/select.component.ts new file mode 100644 index 0000000..83aeaca --- /dev/null +++ b/libs/sketch/src/lib/select/select.component.ts @@ -0,0 +1,78 @@ +import { + ChangeDetectorRef, + Component, + forwardRef, + inject, + input, + signal, + ViewChild, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + CdkOverlayDirective, + DEFAULT_DROPOUT_POSITIONS, +} from './directives/overlay.directive'; +import { CdkPortal } from '@angular/cdk/portal'; +import { MultipleDirective } from './directives/multiple.directive'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector: 'sk-select', + standalone: true, + imports: [CommonModule, CdkOverlayDirective, CdkPortal], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectComponent), + multi: true, + }, + ], + templateUrl: './select.component.html', + styleUrl: './select.component.css', +}) +export class SelectComponent implements ControlValueAccessor { + private readonly multipleRef = inject(MultipleDirective, { optional: true }); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private onChange?: (value: T | T[] | undefined) => void; + private onTouched?: () => void; + readonly placeholder = input('Select an option'); + readonly selectedValue = signal(undefined); + readonly panelIsVisible = signal(false); + + readonly overlayPositions = DEFAULT_DROPOUT_POSITIONS; + + @ViewChild(CdkOverlayDirective) + public overlay?: CdkOverlayDirective; + + selectionChanged(value: T): void { + if (this.multipleRef?.multiple()) { + this.selectedValue.update((selected) => { + if (Array.isArray(selected)) { + return selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value]; + } + return [value]; + }); + } else { + this.selectedValue.set(value); + } + this.onChange?.( + this.selectedValue() ?? this.multipleRef?.multiple() ? [] : undefined + ); + this.onTouched?.(); + } + + writeValue(obj: T | T[] | undefined): void { + this.selectedValue.set(obj); + this.changeDetectorRef.markForCheck(); + } + + registerOnChange(fn: (_: T | T[] | undefined) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } +} diff --git a/package-lock.json b/package-lock.json index 902dd9c..028d08d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@angular/animations": "~17.3.0", + "@angular/cdk": "^17.3.3", "@angular/common": "~17.3.0", "@angular/compiler": "~17.3.0", "@angular/core": "~17.3.0", @@ -2044,6 +2045,34 @@ "@angular/core": "17.3.2" } }, + "node_modules/@angular/cdk": { + "version": "17.3.3", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.3.3.tgz", + "integrity": "sha512-hfS9pwaNE6CTZqP3FBh9tZPbuf//bDqZ5IpMzscfDFrwX8ycxBiI3znH/rFSf9l1rL0OQGoqWWNVfJCT+RrukA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "optionalDependencies": { + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@angular/common": "^17.0.0 || ^18.0.0", + "@angular/core": "^17.0.0 || ^18.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/cdk/node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "optional": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/@angular/cli": { "version": "17.3.3", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.3.tgz", diff --git a/package.json b/package.json index 432e4f9..7f43522 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "private": true, "dependencies": { "@angular/animations": "~17.3.0", + "@angular/cdk": "^17.3.3", "@angular/common": "~17.3.0", "@angular/compiler": "~17.3.0", "@angular/core": "~17.3.0", From 9b166cf859a4bb8791cb8a5da4d99c37ef3e0993 Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Thu, 11 Apr 2024 11:00:28 +0200 Subject: [PATCH 02/11] feat(select): update sample --- apps/demo-app/src/app/app.config.ts | 2 +- .../select-options-sample.component.css | 28 ++++++++++ .../select-options-sample.component.html | 7 +++ .../select-options-sample.component.spec.ts | 21 +++++++ .../select-options-sample.component.ts | 37 +++++++++++++ .../select-sample/select-sample.component.css | 17 ++---- .../select-sample.component.html | 19 ++++--- .../select-sample/select-sample.component.ts | 12 +++- .../select-option/select-option.component.ts | 1 - .../select/directives/overlay.directive.ts | 38 +++++++++---- .../src/lib/select/select.component.html | 6 +- .../sketch/src/lib/select/select.component.ts | 55 ++++++++++++++----- 12 files changed, 193 insertions(+), 50 deletions(-) create mode 100644 apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css create mode 100644 apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html create mode 100644 apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.spec.ts create mode 100644 apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts diff --git a/apps/demo-app/src/app/app.config.ts b/apps/demo-app/src/app/app.config.ts index e0f998a..7804895 100644 --- a/apps/demo-app/src/app/app.config.ts +++ b/apps/demo-app/src/app/app.config.ts @@ -1,10 +1,10 @@ import { ApplicationConfig } from '@angular/core'; +import { appRoutes } from './app.routes'; import { provideRouter, withComponentInputBinding, withViewTransitions, } from '@angular/router'; -import { appRoutes } from './app.routes'; import { provideAnimations } from '@angular/platform-browser/animations'; export const appConfig: ApplicationConfig = { diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css new file mode 100644 index 0000000..399929c --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css @@ -0,0 +1,28 @@ +:host { + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; + margin-top: 0.5rem; + background-color: #fff; + color: #000; + border-radius: 8px; +} + +sk-select-option { + padding: 0.5rem 1rem; + cursor: pointer; + transition: color .3s ease-in-out, font-weight .3s ease-in-out, font-size .3s ease-in-out; +} + +@media (hover) { + sk-select-option:hover { + color: deeppink; + font-weight: bold; + font-size: 1.5em; + } +} + +sk-select-option:not(:last-child) { + border-bottom: 1px solid #000; +} \ No newline at end of file diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html new file mode 100644 index 0000000..e088bcc --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html @@ -0,0 +1,7 @@ + +
Test 1
+
+Test 2 +Test 3 +Test 4 +Test 5 \ No newline at end of file diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.spec.ts b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.spec.ts new file mode 100644 index 0000000..57c2fa5 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SelectOptionsSampleComponent } from './select-options-sample.component'; + +describe('SelectOptionsSampleComponent', () => { + let component: SelectOptionsSampleComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SelectOptionsSampleComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SelectOptionsSampleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts new file mode 100644 index 0000000..6ce1773 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts @@ -0,0 +1,37 @@ +import { Component, HostBinding } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SelectOptionComponent } from '@qupaya/sketch'; +import { + animate, + query, + stagger, + style, + transition, + trigger, +} from '@angular/animations'; + +@Component({ + selector: 'app-select-options-sample', + standalone: true, + imports: [CommonModule, SelectOptionComponent], + templateUrl: './select-options-sample.component.html', + styleUrl: './select-options-sample.component.css', + animations: [ + trigger('options', [ + transition(':enter', [ + query(':enter', [ + style({ opacity: 0, transform: 'translateY(-1.5rem)', scale: 0.8 }), + stagger('80ms', [ + animate( + '350ms cubic-bezier(0.05, 0.7, 0.1, 1)', + style({ opacity: 1, transform: 'translateY(0)', scale: 1 }) + ), + ]), + ]), + ]), + ]), + ], +}) +export class SelectOptionsSampleComponent { + @HostBinding('@options') overlayAnimation = true; +} diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.css b/apps/demo-app/src/app/pages/select-sample/select-sample.component.css index 7d46ccb..34c0002 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.css +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.css @@ -1,11 +1,6 @@ -.sk-select-content { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -sk-select-option { - color: deeppink; - font-weight: bold; - font-size: 1.5em; -} +.sk-label { + background: #fff; + color: #000; + padding: 1rem; + border-radius: 8px; +} \ No newline at end of file diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html index 08662e2..e28130a 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html @@ -1,11 +1,16 @@ Multiple: - -
- Test 1 - Test 2 - Test 3 - Test 4 - Test 5 + +
Please select an option
+
+ @if (Array.isArray(value)) { + @for (item of value; track item) { + {{ item.data }} + } + } @else { + {{ value?.data }} + }
+ +
diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts index 11a9aaa..c9819e5 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts @@ -1,4 +1,4 @@ -import { Component, model, signal } from '@angular/core'; +import { Component, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MultipleDirective, @@ -6,6 +6,7 @@ import { SelectOptionComponent, } from '@qupaya/sketch'; import { FormsModule } from '@angular/forms'; +import { SelectOptionsSampleComponent } from './select-options-sample/select-options-sample.component'; @Component({ selector: 'app-select-sample', @@ -16,18 +17,23 @@ import { FormsModule } from '@angular/forms'; SelectOptionComponent, MultipleDirective, FormsModule, + SelectOptionsSampleComponent, ], templateUrl: './select-sample.component.html', styleUrl: './select-sample.component.css', }) export class SelectSampleComponent { - test = model(); multiple = signal(false); + value: { data: number } | { data: number }[] | undefined; + selectedValue = signal<{ data: number } | { data: number }[] | undefined>( + undefined + ); switchMultiple(event: Event): void { - console.log('event', event); if ('checked' in event.target!) { this.multiple.set(event.target.checked as boolean); } } + + protected readonly Array = Array; } diff --git a/libs/sketch/src/lib/select/components/select-option/select-option.component.ts b/libs/sketch/src/lib/select/components/select-option/select-option.component.ts index ace3c92..96b51dd 100644 --- a/libs/sketch/src/lib/select/components/select-option/select-option.component.ts +++ b/libs/sketch/src/lib/select/components/select-option/select-option.component.ts @@ -15,7 +15,6 @@ export class SelectOptionComponent { @HostListener('click', ['$event']) selectItem(): void { - console.log('selectItem', this.value(), typeof this.value()); this.parent.selectionChanged(this.value()); } } diff --git a/libs/sketch/src/lib/select/directives/overlay.directive.ts b/libs/sketch/src/lib/select/directives/overlay.directive.ts index 8452204..8fbd072 100644 --- a/libs/sketch/src/lib/select/directives/overlay.directive.ts +++ b/libs/sketch/src/lib/select/directives/overlay.directive.ts @@ -11,6 +11,7 @@ import { import { CdkPortal } from '@angular/cdk/portal'; import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; import { DOCUMENT } from '@angular/common'; +import { fromEvent, Subject, takeUntil } from 'rxjs'; export const DEFAULT_POSITIONS: ConnectedPosition[] = [ { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }, @@ -29,9 +30,10 @@ export class CdkOverlayDirective implements OnChanges, OnInit, OnDestroy { private readonly overlay = inject(Overlay); private readonly elementRef = inject(ElementRef); private readonly window = inject(DOCUMENT).defaultView; + private readonly destroy$$ = new Subject(); portal = input(undefined, { alias: 'skCdkOverlay' }); - show = input(false, { alias: 'skCdkOverlayShow' }); + showOverlay = input(false, { alias: 'skCdkOverlayShow' }); connectedPositions = input(DEFAULT_POSITIONS, { alias: 'skCdkOverlayPositions', }); @@ -39,7 +41,6 @@ export class CdkOverlayDirective implements OnChanges, OnInit, OnDestroy { relativeTo = input(undefined, { alias: 'skCdkOverlayRelativeTo', }); - showChange = output({ alias: 'skCdkOverlayShowChange' }); private _overlayRef?: OverlayRef; @@ -47,34 +48,41 @@ export class CdkOverlayDirective implements OnChanges, OnInit, OnDestroy { public ngOnInit(): void { this._relatedElement = this.relativeTo() || this.elementRef.nativeElement; + if (this.window) { + fromEvent(this.window, 'resize') + .pipe(takeUntil(this.destroy$$)) + .subscribe(() => this._syncOverlayWidth()); + } } public ngOnChanges(): void { if (this._relatedElement) { - if (this.show()) { - this._show(); + if (this.showOverlay()) { + this.show(); } else { - this._hide(); + this.hide(); } } } public ngOnDestroy(): void { - this._hide(); + this.hide(); + this.destroy$$.next(); + this.destroy$$.complete(); } - private _show(): void { - this._createOverlay(); + private show(): void { + this.createOverlay(); } - private _hide(): void { + private hide(): void { this.window?.setTimeout( () => this._overlayRef?.dispose(), this.disposeDelay() ); } - private _createOverlay(): void { + private createOverlay(): void { if (!this._relatedElement) { return; } @@ -95,13 +103,21 @@ export class CdkOverlayDirective implements OnChanges, OnInit, OnDestroy { }), }); + this._syncOverlayWidth(); + this._overlayRef.attach(this.portal()); this._overlayRef.detachments().subscribe(() => { - this._hide(); + this.hide(); this.showChange.emit(false); }); this._overlayRef.backdropClick().subscribe(() => { this._overlayRef?.detach(); }); } + + private _syncOverlayWidth(): void { + this._overlayRef?.updateSize({ + width: this._relatedElement?.getBoundingClientRect().width, + }); + } } diff --git a/libs/sketch/src/lib/select/select.component.html b/libs/sketch/src/lib/select/select.component.html index 19875d5..9bbce14 100644 --- a/libs/sketch/src/lib/select/select.component.html +++ b/libs/sketch/src/lib/select/select.component.html @@ -5,10 +5,10 @@ [skCdkOverlayPositions]="overlayPositions" (click)="panelIsVisible.set(true)" > - @if (selectedValue()) { - {{ selectedValue() }} + @if (showPlaceholder()) { + } @else { - {{ placeholder() }} + }
diff --git a/libs/sketch/src/lib/select/select.component.ts b/libs/sketch/src/lib/select/select.component.ts index 83aeaca..cc4562f 100644 --- a/libs/sketch/src/lib/select/select.component.ts +++ b/libs/sketch/src/lib/select/select.component.ts @@ -1,11 +1,12 @@ import { ChangeDetectorRef, Component, + computed, + effect, forwardRef, inject, - input, signal, - ViewChild, + untracked, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { @@ -23,7 +24,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; providers: [ { provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SelectComponent), + useExisting: forwardRef(() => SelectComponent), multi: true, }, ], @@ -33,16 +34,39 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; export class SelectComponent implements ControlValueAccessor { private readonly multipleRef = inject(MultipleDirective, { optional: true }); private readonly changeDetectorRef = inject(ChangeDetectorRef); - private onChange?: (value: T | T[] | undefined) => void; - private onTouched?: () => void; - readonly placeholder = input('Select an option'); + readonly selectedValue = signal(undefined); + readonly showPlaceholder = computed(() => { + const selectedValue = this.selectedValue(); + const isArray = Array.isArray(selectedValue); + return ( + (!isArray && !selectedValue) || (isArray && selectedValue.length <= 0) + ); + }); readonly panelIsVisible = signal(false); - readonly overlayPositions = DEFAULT_DROPOUT_POSITIONS; - @ViewChild(CdkOverlayDirective) - public overlay?: CdkOverlayDirective; + protected updateSelectionMode = effect( + () => { + const selectedValue = untracked(this.selectedValue); + if (!this.multipleRef?.multiple()) { + this.selectedValue.set( + Array.isArray(selectedValue) ? selectedValue[0] : selectedValue + ); + } else { + this.selectedValue.set( + Array.isArray(selectedValue) + ? selectedValue + : selectedValue + ? [selectedValue] + : undefined + ); + } + this.onChange?.(this.selectedValue()); + this.onTouched?.(); + }, + { allowSignalWrites: true } + ); selectionChanged(value: T): void { if (this.multipleRef?.multiple()) { @@ -55,11 +79,12 @@ export class SelectComponent implements ControlValueAccessor { return [value]; }); } else { - this.selectedValue.set(value); + this.selectedValue.set( + this.selectedValue() === value ? undefined : value + ); } - this.onChange?.( - this.selectedValue() ?? this.multipleRef?.multiple() ? [] : undefined - ); + + this.onChange?.(this.selectedValue()); this.onTouched?.(); } @@ -75,4 +100,8 @@ export class SelectComponent implements ControlValueAccessor { registerOnTouched(fn: () => void): void { this.onTouched = fn; } + + private onChange?: (value: T | T[] | undefined) => void; + private onTouched?: () => void; + protected readonly Array = Array; } From 4e823039a70a6eb1ff0761cf9a2d87f36353be87 Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Fri, 12 Apr 2024 09:10:19 +0200 Subject: [PATCH 03/11] feat(select): add animation to select sample --- .../select-options-sample.component.css | 46 ++++---- .../select-options-sample.component.html | 6 +- .../select-options-sample.component.ts | 101 +++++++++++++++--- .../select-sample/select-sample.component.css | 51 ++++++++- .../select-sample.component.html | 19 +++- .../select-sample/select-sample.component.ts | 53 +++++++-- .../select/directives/overlay.directive.ts | 38 ++++--- .../src/lib/select/select.component.html | 5 +- .../sketch/src/lib/select/select.component.ts | 12 +++ 9 files changed, 261 insertions(+), 70 deletions(-) diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css index 399929c..bf07ce9 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css @@ -1,28 +1,38 @@ :host { - display: flex; - flex-direction: column; - gap: 0.25rem; - width: 100%; - margin-top: 0.5rem; - background-color: #fff; - color: #000; - border-radius: 8px; + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; + margin-top: 0.5rem; } sk-select-option { - padding: 0.5rem 1rem; - cursor: pointer; - transition: color .3s ease-in-out, font-weight .3s ease-in-out, font-size .3s ease-in-out; + background-color: #fff; + color: #000; + padding: 0.5rem 1rem; + cursor: pointer; + transition: color 0.3s ease-in-out, font-weight 0.3s ease-in-out, + font-size 0.3s ease-in-out; +} + +sk-select-option:first-child { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +sk-select-option:last-child { + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; } @media (hover) { - sk-select-option:hover { - color: deeppink; - font-weight: bold; - font-size: 1.5em; - } + sk-select-option:hover { + color: deeppink; + font-weight: bold; + font-size: 1.5em; + } } sk-select-option:not(:last-child) { - border-bottom: 1px solid #000; -} \ No newline at end of file + border-bottom: 1px solid #000; +} diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html index e088bcc..f14bade 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html @@ -1,7 +1,5 @@ - -
Test 1
-
+Test 1 Test 2 Test 3 Test 4 -Test 5 \ No newline at end of file +Test 5 diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts index 6ce1773..2e6a1fc 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts @@ -1,14 +1,72 @@ -import { Component, HostBinding } from '@angular/core'; +import { Component, effect, HostBinding, input } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { SelectOptionComponent } from '@qupaya/sketch'; import { animate, + AnimationMetadata, query, + sequence, stagger, style, transition, trigger, } from '@angular/animations'; +import { SelectOptionComponent } from '@qupaya/sketch'; + +export const slideDeleteAnimation = (): AnimationMetadata => { + return stagger('80ms', [ + sequence([ + animate( + `200ms cubic-bezier(0.3, 0, 0.8, 0.15)`, + style({ + transform: `translateX(-200%)`, + }) + ), + animate( + `200ms ease`, + style({ + height: 0, + }) + ), + ]), + ]); +}; + +export const slideFadeAnimationFactory = (): AnimationMetadata[] => { + return [ + style({ opacity: 0, transform: 'translateY(-1.5rem)', scale: 0.8 }), + stagger('80ms', [ + animate( + '350ms cubic-bezier(0.05, 0.7, 0.1, 1)', + style({ opacity: 1, transform: 'translateY(0)', scale: 1 }) + ), + ]), + ]; +}; + +export const fadeFactory = ( + from: number, + to: number, + duration = '200ms' +): AnimationMetadata[] => { + return [ + style({ opacity: from }), + animate(`${duration} ease`, style({ opacity: to })), + ]; +}; + +export const zoomFactory = ( + from: number, + to: number, + duration = '500ms' +): AnimationMetadata[] => { + return [ + style({ opacity: from, scale: from }), + animate( + `${duration} cubic-bezier(0.05, 0.7, 0.1, 1)`, + style({ opacity: to, scale: to }) + ), + ]; +}; @Component({ selector: 'app-select-options-sample', @@ -17,21 +75,34 @@ import { templateUrl: './select-options-sample.component.html', styleUrl: './select-options-sample.component.css', animations: [ - trigger('options', [ - transition(':enter', [ - query(':enter', [ - style({ opacity: 0, transform: 'translateY(-1.5rem)', scale: 0.8 }), - stagger('80ms', [ - animate( - '350ms cubic-bezier(0.05, 0.7, 0.1, 1)', - style({ opacity: 1, transform: 'translateY(0)', scale: 1 }) - ), - ]), - ]), - ]), + trigger('animation', [ + transition( + '* => visible', + query('sk-select-option', slideFadeAnimationFactory(), { + optional: true, + }) + ), + transition( + '* => hidden', + query('sk-select-option', slideDeleteAnimation(), { + optional: true, + }) + ), + transition( + 'visible => void', + query('sk-select-option', fadeFactory(1, 0, '350ms'), { + optional: true, + }) + ), ]), ], }) export class SelectOptionsSampleComponent { - @HostBinding('@options') overlayAnimation = true; + @HostBinding('@animation') animateOptions = 'hidden'; + + show = input.required(); + + protected readonly toggleShow = effect(() => { + this.animateOptions = this.show() ? 'visible' : 'hidden'; + }); } diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.css b/apps/demo-app/src/app/pages/select-sample/select-sample.component.css index 34c0002..65c36b1 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.css +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.css @@ -1,6 +1,51 @@ +sk-select { + display: block; + margin-top: 1rem; +} + .sk-label { - background: #fff; - color: #000; - padding: 1rem; + padding: 0.5rem 1rem; border-radius: 8px; + background: rgba(255, 255, 255, 0.1); + color: #fefefe; + box-shadow: rgba(255, 255, 255, 0.05) 0px 6px 24px 0px, rgba(255, 255, 255, 0.08) 0px 0px 0px 1px; +} + +.sk-options { + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; + margin-top: 0.5rem; +} + +sk-select-option { + background-color: #fff; + color: #000; + padding: 0.5rem 1rem; + cursor: pointer; + transition: color 0.3s ease-in-out, font-weight 0.3s ease-in-out, + font-size 0.3s ease-in-out; +} + +sk-select-option:first-child { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +sk-select-option:last-child { + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +@media (hover) { + sk-select-option:hover { + color: deeppink; + font-weight: bold; + font-size: 1.5em; + } +} + +sk-select-option:not(:last-child) { + border-bottom: 1px solid #000; } \ No newline at end of file diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html index e28130a..68f59ec 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html @@ -1,6 +1,11 @@ Multiple: - +
Please select an option
@if (Array.isArray(value)) { @@ -12,5 +17,15 @@ }
- + + +
diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts index c9819e5..4e0a605 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts @@ -1,4 +1,4 @@ -import { Component, signal } from '@angular/core'; +import { Component, effect, signal, untracked } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MultipleDirective, @@ -6,7 +6,17 @@ import { SelectOptionComponent, } from '@qupaya/sketch'; import { FormsModule } from '@angular/forms'; -import { SelectOptionsSampleComponent } from './select-options-sample/select-options-sample.component'; +import { + SelectOptionsSampleComponent, + slideDeleteAnimation, + slideFadeAnimationFactory, +} from './select-options-sample/select-options-sample.component'; +import { + AnimationEvent, + query, + transition, + trigger, +} from '@angular/animations'; @Component({ selector: 'app-select-sample', @@ -21,13 +31,32 @@ import { SelectOptionsSampleComponent } from './select-options-sample/select-opt ], templateUrl: './select-sample.component.html', styleUrl: './select-sample.component.css', + animations: [ + trigger('animation', [ + transition( + 'hidden => visible', + query('.sk-option', slideFadeAnimationFactory(), { + optional: true, + }) + ), + transition( + 'visible => hidden', + query('.sk-option', slideDeleteAnimation(), { + optional: true, + }) + ), + ]), + ], }) export class SelectSampleComponent { - multiple = signal(false); + readonly showAnimation = signal(false); + readonly animateOptions = signal<'hidden' | 'visible'>('hidden'); + readonly multiple = signal(false); + readonly selectedValue = signal< + { data: number } | { data: number }[] | undefined + >(undefined); + value: { data: number } | { data: number }[] | undefined; - selectedValue = signal<{ data: number } | { data: number }[] | undefined>( - undefined - ); switchMultiple(event: Event): void { if ('checked' in event.target!) { @@ -35,5 +64,17 @@ export class SelectSampleComponent { } } + logAnimationData(event: AnimationEvent): void { + console.log('animation event', event); + } + + protected readonly runAnimation = effect( + () => { + this.animateOptions.set(this.showAnimation() ? 'visible' : 'hidden'); + console.log('runAnimation', untracked(this.animateOptions)); + }, + { allowSignalWrites: true } + ); + protected readonly Array = Array; } diff --git a/libs/sketch/src/lib/select/directives/overlay.directive.ts b/libs/sketch/src/lib/select/directives/overlay.directive.ts index 8fbd072..75dfc72 100644 --- a/libs/sketch/src/lib/select/directives/overlay.directive.ts +++ b/libs/sketch/src/lib/select/directives/overlay.directive.ts @@ -1,9 +1,9 @@ import { Directive, + effect, ElementRef, inject, input, - OnChanges, OnDestroy, OnInit, output, @@ -11,7 +11,7 @@ import { import { CdkPortal } from '@angular/cdk/portal'; import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; import { DOCUMENT } from '@angular/common'; -import { fromEvent, Subject, takeUntil } from 'rxjs'; +import { fromEvent, merge, Subject, takeUntil } from 'rxjs'; export const DEFAULT_POSITIONS: ConnectedPosition[] = [ { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }, @@ -26,7 +26,7 @@ export const DEFAULT_DROPOUT_POSITIONS: ConnectedPosition[] = [ selector: '[skCdkOverlay]', standalone: true, }) -export class CdkOverlayDirective implements OnChanges, OnInit, OnDestroy { +export class CdkOverlayDirective implements OnInit, OnDestroy { private readonly overlay = inject(Overlay); private readonly elementRef = inject(ElementRef); private readonly window = inject(DOCUMENT).defaultView; @@ -46,16 +46,7 @@ export class CdkOverlayDirective implements OnChanges, OnInit, OnDestroy { private _overlayRef?: OverlayRef; private _relatedElement?: HTMLElement; - public ngOnInit(): void { - this._relatedElement = this.relativeTo() || this.elementRef.nativeElement; - if (this.window) { - fromEvent(this.window, 'resize') - .pipe(takeUntil(this.destroy$$)) - .subscribe(() => this._syncOverlayWidth()); - } - } - - public ngOnChanges(): void { + protected detectVisibleChange = effect(() => { if (this._relatedElement) { if (this.showOverlay()) { this.show(); @@ -63,6 +54,15 @@ export class CdkOverlayDirective implements OnChanges, OnInit, OnDestroy { this.hide(); } } + }); + + public ngOnInit(): void { + this._relatedElement = this.relativeTo() || this.elementRef.nativeElement; + if (this.window) { + fromEvent(this.window, 'resize') + .pipe(takeUntil(this.destroy$$)) + .subscribe(() => this._syncOverlayWidth()); + } } public ngOnDestroy(): void { @@ -76,6 +76,7 @@ export class CdkOverlayDirective implements OnChanges, OnInit, OnDestroy { } private hide(): void { + this.showChange.emit(false); this.window?.setTimeout( () => this._overlayRef?.dispose(), this.disposeDelay() @@ -106,13 +107,10 @@ export class CdkOverlayDirective implements OnChanges, OnInit, OnDestroy { this._syncOverlayWidth(); this._overlayRef.attach(this.portal()); - this._overlayRef.detachments().subscribe(() => { - this.hide(); - this.showChange.emit(false); - }); - this._overlayRef.backdropClick().subscribe(() => { - this._overlayRef?.detach(); - }); + merge( + this._overlayRef.backdropClick(), + this._overlayRef.detachments() + ).subscribe(() => this.hide()); } private _syncOverlayWidth(): void { diff --git a/libs/sketch/src/lib/select/select.component.html b/libs/sketch/src/lib/select/select.component.html index 9bbce14..9661212 100644 --- a/libs/sketch/src/lib/select/select.component.html +++ b/libs/sketch/src/lib/select/select.component.html @@ -1,9 +1,10 @@
@if (showPlaceholder()) { diff --git a/libs/sketch/src/lib/select/select.component.ts b/libs/sketch/src/lib/select/select.component.ts index cc4562f..3aac9ba 100644 --- a/libs/sketch/src/lib/select/select.component.ts +++ b/libs/sketch/src/lib/select/select.component.ts @@ -5,8 +5,11 @@ import { effect, forwardRef, inject, + input, + output, signal, untracked, + ViewEncapsulation, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { @@ -30,11 +33,15 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; ], templateUrl: './select.component.html', styleUrl: './select.component.css', + encapsulation: ViewEncapsulation.ShadowDom, }) export class SelectComponent implements ControlValueAccessor { private readonly multipleRef = inject(MultipleDirective, { optional: true }); private readonly changeDetectorRef = inject(ChangeDetectorRef); + animationDelay = input(0); + open = output(); + readonly selectedValue = signal(undefined); readonly showPlaceholder = computed(() => { const selectedValue = this.selectedValue(); @@ -68,6 +75,11 @@ export class SelectComponent implements ControlValueAccessor { { allowSignalWrites: true } ); + togglePanel(visible: boolean): void { + this.panelIsVisible.set(visible); + this.open.emit(visible); + } + selectionChanged(value: T): void { if (this.multipleRef?.multiple()) { this.selectedValue.update((selected) => { From 55b1c04c6d4fc14634e8d853df1bfff3ee61f5cd Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Mon, 15 Apr 2024 09:35:13 +0200 Subject: [PATCH 04/11] wip(select): focus test --- .../select-options-sample.component.css | 41 ++++++++----------- .../select-options-sample.component.ts | 6 +-- .../select-sample.component.html | 10 +---- .../select-option/select-option.component.ts | 19 ++++++++- .../src/lib/select/select.component.html | 2 + .../sketch/src/lib/select/select.component.ts | 34 +++++++++++---- 6 files changed, 69 insertions(+), 43 deletions(-) diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css index bf07ce9..b99341b 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css @@ -1,38 +1,33 @@ :host { - display: flex; - flex-direction: column; - gap: 0.25rem; - width: 100%; - margin-top: 0.5rem; + display: flex; + flex-direction: column; + width: 100%; + margin-top: 0.5rem; } sk-select-option { - background-color: #fff; - color: #000; - padding: 0.5rem 1rem; - cursor: pointer; - transition: color 0.3s ease-in-out, font-weight 0.3s ease-in-out, + background-color: #fff; + color: #000; + padding: 0.5rem 1rem; + cursor: pointer; + transition: color 0.3s ease-in-out, font-weight 0.3s ease-in-out, font-size 0.3s ease-in-out; } sk-select-option:first-child { - border-top-left-radius: 0.25rem; - border-top-right-radius: 0.25rem; + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; } sk-select-option:last-child { - border-bottom-left-radius: 0.25rem; - border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; } @media (hover) { - sk-select-option:hover { - color: deeppink; - font-weight: bold; - font-size: 1.5em; - } -} - -sk-select-option:not(:last-child) { - border-bottom: 1px solid #000; + sk-select-option:hover { + color: deeppink; + font-weight: bold; + font-size: 1.5em; + } } diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts index 2e6a1fc..25d8bc5 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts @@ -34,9 +34,9 @@ export const slideDeleteAnimation = (): AnimationMetadata => { export const slideFadeAnimationFactory = (): AnimationMetadata[] => { return [ style({ opacity: 0, transform: 'translateY(-1.5rem)', scale: 0.8 }), - stagger('80ms', [ + stagger('32ms', [ animate( - '350ms cubic-bezier(0.05, 0.7, 0.1, 1)', + '150ms cubic-bezier(0.05, 0.7, 0.1, 1)', style({ opacity: 1, transform: 'translateY(0)', scale: 1 }) ), ]), @@ -84,7 +84,7 @@ export const zoomFactory = ( ), transition( '* => hidden', - query('sk-select-option', slideDeleteAnimation(), { + query('sk-select-option', fadeFactory(1, 0, '350ms'), { optional: true, }) ), diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html index 68f59ec..3c5b938 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html @@ -3,7 +3,7 @@
Please select an option
@@ -20,12 +20,4 @@ - -
diff --git a/libs/sketch/src/lib/select/components/select-option/select-option.component.ts b/libs/sketch/src/lib/select/components/select-option/select-option.component.ts index 96b51dd..1ec3ecf 100644 --- a/libs/sketch/src/lib/select/components/select-option/select-option.component.ts +++ b/libs/sketch/src/lib/select/components/select-option/select-option.component.ts @@ -1,4 +1,10 @@ -import { Component, HostListener, inject, input } from '@angular/core'; +import { + Component, + HostBinding, + HostListener, + inject, + input, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { SelectComponent } from '../../select.component'; @@ -13,6 +19,17 @@ export class SelectOptionComponent { private readonly parent = inject(SelectComponent); value = input.required(); + @HostBinding('tabindex') + get tabindex(): number { + return 1; + } + + @HostListener('keydown.space', ['$event']) + @HostListener('keydown.enter', ['$event']) + enterItem(): void { + this.parent.selectionChanged(this.value(), true); + } + @HostListener('click', ['$event']) selectItem(): void { this.parent.selectionChanged(this.value()); diff --git a/libs/sketch/src/lib/select/select.component.html b/libs/sketch/src/lib/select/select.component.html index 9661212..96bf3ef 100644 --- a/libs/sketch/src/lib/select/select.component.html +++ b/libs/sketch/src/lib/select/select.component.html @@ -5,6 +5,8 @@ [skCdkOverlayPositions]="overlayPositions" [skCdkOverlayDisposeDelay]="animationDelay()" (click)="togglePanel(true)" + [tabIndex]="1" + (keydown.enter)="togglePanel(!this.panelIsVisible())" > @if (showPlaceholder()) { diff --git a/libs/sketch/src/lib/select/select.component.ts b/libs/sketch/src/lib/select/select.component.ts index 3aac9ba..e04f402 100644 --- a/libs/sketch/src/lib/select/select.component.ts +++ b/libs/sketch/src/lib/select/select.component.ts @@ -1,7 +1,9 @@ import { + AfterViewInit, ChangeDetectorRef, Component, computed, + contentChildren, effect, forwardRef, inject, @@ -11,7 +13,7 @@ import { untracked, ViewEncapsulation, } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, DOCUMENT } from '@angular/common'; import { CdkOverlayDirective, DEFAULT_DROPOUT_POSITIONS, @@ -19,11 +21,12 @@ import { import { CdkPortal } from '@angular/cdk/portal'; import { MultipleDirective } from './directives/multiple.directive'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { CdkTrapFocus } from '@angular/cdk/a11y'; @Component({ selector: 'sk-select', standalone: true, - imports: [CommonModule, CdkOverlayDirective, CdkPortal], + imports: [CommonModule, CdkOverlayDirective, CdkPortal, CdkTrapFocus], providers: [ { provide: NG_VALUE_ACCESSOR, @@ -35,13 +38,13 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; styleUrl: './select.component.css', encapsulation: ViewEncapsulation.ShadowDom, }) -export class SelectComponent implements ControlValueAccessor { +export class SelectComponent implements ControlValueAccessor, AfterViewInit { private readonly multipleRef = inject(MultipleDirective, { optional: true }); private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly document = inject(DOCUMENT); - animationDelay = input(0); - open = output(); - + readonly animationDelay = input(0); + readonly open = output(); readonly selectedValue = signal(undefined); readonly showPlaceholder = computed(() => { const selectedValue = this.selectedValue(); @@ -51,6 +54,9 @@ export class SelectComponent implements ControlValueAccessor { ); }); readonly panelIsVisible = signal(false); + readonly options = contentChildren('sk-select-option', { + descendants: true, + }); readonly overlayPositions = DEFAULT_DROPOUT_POSITIONS; protected updateSelectionMode = effect( @@ -78,9 +84,19 @@ export class SelectComponent implements ControlValueAccessor { togglePanel(visible: boolean): void { this.panelIsVisible.set(visible); this.open.emit(visible); + + if (visible) { + setTimeout(() => { + this.document.querySelector('sk-select-option')?.focus(); + }, 32); + } } - selectionChanged(value: T): void { + ngAfterViewInit(): void { + console.log('SelectComponent.ngAfterViewInit', this.options()); + } + + selectionChanged(value: T, forceClose = false): void { if (this.multipleRef?.multiple()) { this.selectedValue.update((selected) => { if (Array.isArray(selected)) { @@ -98,6 +114,10 @@ export class SelectComponent implements ControlValueAccessor { this.onChange?.(this.selectedValue()); this.onTouched?.(); + + if (forceClose) { + this.togglePanel(false); + } } writeValue(obj: T | T[] | undefined): void { From 0a9d8d012c5f3fe4cda98950a8f0b1fdaaeb79c3 Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Tue, 16 Apr 2024 12:26:00 +0200 Subject: [PATCH 05/11] feat(select): move select component to correct folder --- apps/demo-app/src/app/pages/overview/overview.component.html | 2 +- libs/sketch/src/index.ts | 2 +- .../select/components/select-option/select-option.component.css | 0 .../components/select-option/select-option.component.html | 0 .../select/components/select-option/select-option.component.ts | 0 .../{ => components}/select/directives/multiple.directive.ts | 0 .../lib/{ => components}/select/directives/overlay.directive.ts | 0 libs/sketch/src/lib/{ => components}/select/index.ts | 0 .../sketch/src/lib/{ => components}/select/select.component.css | 0 .../src/lib/{ => components}/select/select.component.html | 0 libs/sketch/src/lib/{ => components}/select/select.component.ts | 0 11 files changed, 2 insertions(+), 2 deletions(-) rename libs/sketch/src/lib/{ => components}/select/components/select-option/select-option.component.css (100%) rename libs/sketch/src/lib/{ => components}/select/components/select-option/select-option.component.html (100%) rename libs/sketch/src/lib/{ => components}/select/components/select-option/select-option.component.ts (100%) rename libs/sketch/src/lib/{ => components}/select/directives/multiple.directive.ts (100%) rename libs/sketch/src/lib/{ => components}/select/directives/overlay.directive.ts (100%) rename libs/sketch/src/lib/{ => components}/select/index.ts (100%) rename libs/sketch/src/lib/{ => components}/select/select.component.css (100%) rename libs/sketch/src/lib/{ => components}/select/select.component.html (100%) rename libs/sketch/src/lib/{ => components}/select/select.component.ts (100%) diff --git a/apps/demo-app/src/app/pages/overview/overview.component.html b/apps/demo-app/src/app/pages/overview/overview.component.html index cd69568..6759d5f 100644 --- a/apps/demo-app/src/app/pages/overview/overview.component.html +++ b/apps/demo-app/src/app/pages/overview/overview.component.html @@ -4,6 +4,6 @@ Lorem ipsum dolor... - + Lorem ipsum dolor... diff --git a/libs/sketch/src/index.ts b/libs/sketch/src/index.ts index 7b90ebf..2207f91 100644 --- a/libs/sketch/src/index.ts +++ b/libs/sketch/src/index.ts @@ -1,4 +1,4 @@ export * from './lib/components/sketch/sketch.component'; -export * from './lib/select'; +export * from './lib/components/select'; export * from './lib/components/list'; export * from './lib/components/sketch/sketch.component'; diff --git a/libs/sketch/src/lib/select/components/select-option/select-option.component.css b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.css similarity index 100% rename from libs/sketch/src/lib/select/components/select-option/select-option.component.css rename to libs/sketch/src/lib/components/select/components/select-option/select-option.component.css diff --git a/libs/sketch/src/lib/select/components/select-option/select-option.component.html b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.html similarity index 100% rename from libs/sketch/src/lib/select/components/select-option/select-option.component.html rename to libs/sketch/src/lib/components/select/components/select-option/select-option.component.html diff --git a/libs/sketch/src/lib/select/components/select-option/select-option.component.ts b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts similarity index 100% rename from libs/sketch/src/lib/select/components/select-option/select-option.component.ts rename to libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts diff --git a/libs/sketch/src/lib/select/directives/multiple.directive.ts b/libs/sketch/src/lib/components/select/directives/multiple.directive.ts similarity index 100% rename from libs/sketch/src/lib/select/directives/multiple.directive.ts rename to libs/sketch/src/lib/components/select/directives/multiple.directive.ts diff --git a/libs/sketch/src/lib/select/directives/overlay.directive.ts b/libs/sketch/src/lib/components/select/directives/overlay.directive.ts similarity index 100% rename from libs/sketch/src/lib/select/directives/overlay.directive.ts rename to libs/sketch/src/lib/components/select/directives/overlay.directive.ts diff --git a/libs/sketch/src/lib/select/index.ts b/libs/sketch/src/lib/components/select/index.ts similarity index 100% rename from libs/sketch/src/lib/select/index.ts rename to libs/sketch/src/lib/components/select/index.ts diff --git a/libs/sketch/src/lib/select/select.component.css b/libs/sketch/src/lib/components/select/select.component.css similarity index 100% rename from libs/sketch/src/lib/select/select.component.css rename to libs/sketch/src/lib/components/select/select.component.css diff --git a/libs/sketch/src/lib/select/select.component.html b/libs/sketch/src/lib/components/select/select.component.html similarity index 100% rename from libs/sketch/src/lib/select/select.component.html rename to libs/sketch/src/lib/components/select/select.component.html diff --git a/libs/sketch/src/lib/select/select.component.ts b/libs/sketch/src/lib/components/select/select.component.ts similarity index 100% rename from libs/sketch/src/lib/select/select.component.ts rename to libs/sketch/src/lib/components/select/select.component.ts From 9781e5cf2e7e2129161c7b597656abdfa0159560 Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Tue, 16 Apr 2024 13:18:22 +0200 Subject: [PATCH 06/11] feat(select): add focustrap to options container --- .../src/lib/components/select/select.component.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libs/sketch/src/lib/components/select/select.component.html b/libs/sketch/src/lib/components/select/select.component.html index 96bf3ef..2cff839 100644 --- a/libs/sketch/src/lib/components/select/select.component.html +++ b/libs/sketch/src/lib/components/select/select.component.html @@ -9,12 +9,14 @@ (keydown.enter)="togglePanel(!this.panelIsVisible())" > @if (showPlaceholder()) { - + } @else { - + }
- + + + From d520c981d7491aa1778a71f3f137a7f8c9cb5bae Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Wed, 17 Apr 2024 15:18:18 +0200 Subject: [PATCH 07/11] feat(select): add key event functionality --- .../src/app/animations/fade.animations.ts | 12 +++ .../src/app/animations/slide.animation.ts | 20 +++++ .../src/app/animations/zoom.animations.ts | 15 ++++ apps/demo-app/src/app/app.config.ts | 4 +- .../pages/overview/overview.component.html | 5 +- .../select-options-sample.component.css | 46 ++++++----- .../select-options-sample.component.html | 11 +-- .../select-options-sample.component.spec.ts | 6 +- .../select-options-sample.component.ts | 74 ++--------------- .../select-sample/select-sample.component.css | 62 +++++---------- .../select-sample.component.html | 24 +++--- .../select-sample.component.spec.ts | 3 +- .../select-sample/select-sample.component.ts | 79 +++++++++++++------ libs/sketch/src/index.ts | 1 - .../select-option/select-option.component.css | 8 ++ .../select-option/select-option.component.ts | 14 ++-- .../select/directives/multiple.directive.ts | 9 --- .../select/directives/overlay.directive.ts | 71 +++++++---------- .../sketch/src/lib/components/select/index.ts | 1 - .../components/select/select.component.css | 4 + .../components/select/select.component.html | 19 +++-- .../lib/components/select/select.component.ts | 67 ++++++++-------- package-lock.json | 48 +++++++++++ package.json | 3 + 24 files changed, 337 insertions(+), 269 deletions(-) create mode 100644 apps/demo-app/src/app/animations/fade.animations.ts create mode 100644 apps/demo-app/src/app/animations/zoom.animations.ts delete mode 100644 libs/sketch/src/lib/components/select/directives/multiple.directive.ts diff --git a/apps/demo-app/src/app/animations/fade.animations.ts b/apps/demo-app/src/app/animations/fade.animations.ts new file mode 100644 index 0000000..520396b --- /dev/null +++ b/apps/demo-app/src/app/animations/fade.animations.ts @@ -0,0 +1,12 @@ +import { animate, AnimationMetadata, style } from '@angular/animations'; + +export const fadeFactory = ( + from: number, + to: number, + duration = '200ms' +): AnimationMetadata[] => { + return [ + style({ opacity: from }), + animate(`${duration} ease`, style({ opacity: to })), + ]; +}; diff --git a/apps/demo-app/src/app/animations/slide.animation.ts b/apps/demo-app/src/app/animations/slide.animation.ts index f1551ce..cc156d9 100644 --- a/apps/demo-app/src/app/animations/slide.animation.ts +++ b/apps/demo-app/src/app/animations/slide.animation.ts @@ -1,6 +1,7 @@ import { animate, AnimationMetadata, + sequence, stagger, style, } from '@angular/animations'; @@ -16,3 +17,22 @@ export const slideFadeAnimationFactory = (): AnimationMetadata[] => { ]), ]; }; + +export const slideDeleteAnimation = (): AnimationMetadata => { + return stagger('80ms', [ + sequence([ + animate( + `200ms cubic-bezier(0.3, 0, 0.8, 0.15)`, + style({ + transform: `translateX(-200%)`, + }) + ), + animate( + `200ms ease`, + style({ + height: 0, + }) + ), + ]), + ]); +}; diff --git a/apps/demo-app/src/app/animations/zoom.animations.ts b/apps/demo-app/src/app/animations/zoom.animations.ts new file mode 100644 index 0000000..003db37 --- /dev/null +++ b/apps/demo-app/src/app/animations/zoom.animations.ts @@ -0,0 +1,15 @@ +import { animate, AnimationMetadata, style } from '@angular/animations'; + +export const zoomFactory = ( + from: number, + to: number, + duration = '500ms' +): AnimationMetadata[] => { + return [ + style({ opacity: from, scale: from }), + animate( + `${duration} cubic-bezier(0.05, 0.7, 0.1, 1)`, + style({ opacity: to, scale: to }) + ), + ]; +}; diff --git a/apps/demo-app/src/app/app.config.ts b/apps/demo-app/src/app/app.config.ts index 7804895..f95c7ed 100644 --- a/apps/demo-app/src/app/app.config.ts +++ b/apps/demo-app/src/app/app.config.ts @@ -1,4 +1,4 @@ -import { ApplicationConfig } from '@angular/core'; +import { ApplicationConfig, importProvidersFrom } from '@angular/core'; import { appRoutes } from './app.routes'; import { provideRouter, @@ -6,6 +6,7 @@ import { withViewTransitions, } from '@angular/router'; import { provideAnimations } from '@angular/platform-browser/animations'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; export const appConfig: ApplicationConfig = { providers: [ @@ -15,5 +16,6 @@ export const appConfig: ApplicationConfig = { withComponentInputBinding() ), provideAnimations(), + importProvidersFrom(FontAwesomeModule), ], }; diff --git a/apps/demo-app/src/app/pages/overview/overview.component.html b/apps/demo-app/src/app/pages/overview/overview.component.html index 6759d5f..5cdbc21 100644 --- a/apps/demo-app/src/app/pages/overview/overview.component.html +++ b/apps/demo-app/src/app/pages/overview/overview.component.html @@ -4,6 +4,9 @@ Lorem ipsum dolor... - + Lorem ipsum dolor... diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css index b99341b..69229e0 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css @@ -1,33 +1,41 @@ :host { - display: flex; - flex-direction: column; - width: 100%; - margin-top: 0.5rem; + display: flex; + flex-direction: column; + width: 100%; + max-height: 200px; + margin-top: 0.5rem; + overflow-x: auto; } sk-select-option { - background-color: #fff; - color: #000; - padding: 0.5rem 1rem; - cursor: pointer; - transition: color 0.3s ease-in-out, font-weight 0.3s ease-in-out, - font-size 0.3s ease-in-out; + display: flex; + gap: 0.5rem; + background-color: #fff; + color: #000; + padding: 0.5rem 1rem; + cursor: pointer; + transition: color 0.3s ease-in-out, font-weight 0.3s ease-in-out; } sk-select-option:first-child { - border-top-left-radius: 0.25rem; - border-top-right-radius: 0.25rem; + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; } sk-select-option:last-child { - border-bottom-left-radius: 0.25rem; - border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; } @media (hover) { - sk-select-option:hover { - color: deeppink; - font-weight: bold; - font-size: 1.5em; - } + sk-select-option:hover { + color: var(--sk-primary-color); + font-weight: bold; + } +} + +sk-select-option:focus { + border: none; + color: var(--sk-primary-color); + font-weight: bold; } diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html index f14bade..49199a2 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html @@ -1,5 +1,6 @@ -Test 1 -Test 2 -Test 3 -Test 4 -Test 5 +@for (option of options(); track option.value) { + + + {{ option.label }} + +} diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.spec.ts b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.spec.ts index 57c2fa5..3f06ffa 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.spec.ts +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SelectOptionsSampleComponent } from './select-options-sample.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; describe('SelectOptionsSampleComponent', () => { let component: SelectOptionsSampleComponent; @@ -7,11 +8,14 @@ describe('SelectOptionsSampleComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SelectOptionsSampleComponent], + imports: [SelectOptionsSampleComponent, NoopAnimationsModule], }).compileComponents(); fixture = TestBed.createComponent(SelectOptionsSampleComponent); component = fixture.componentInstance; + const componentRef = fixture.componentRef; + componentRef.setInput('show', true); + componentRef.setInput('options', []); fixture.detectChanges(); }); diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts index 25d8bc5..cfa03f6 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts +++ b/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts @@ -1,77 +1,16 @@ import { Component, effect, HostBinding, input } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { - animate, - AnimationMetadata, - query, - sequence, - stagger, - style, - transition, - trigger, -} from '@angular/animations'; +import { query, transition, trigger } from '@angular/animations'; import { SelectOptionComponent } from '@qupaya/sketch'; - -export const slideDeleteAnimation = (): AnimationMetadata => { - return stagger('80ms', [ - sequence([ - animate( - `200ms cubic-bezier(0.3, 0, 0.8, 0.15)`, - style({ - transform: `translateX(-200%)`, - }) - ), - animate( - `200ms ease`, - style({ - height: 0, - }) - ), - ]), - ]); -}; - -export const slideFadeAnimationFactory = (): AnimationMetadata[] => { - return [ - style({ opacity: 0, transform: 'translateY(-1.5rem)', scale: 0.8 }), - stagger('32ms', [ - animate( - '150ms cubic-bezier(0.05, 0.7, 0.1, 1)', - style({ opacity: 1, transform: 'translateY(0)', scale: 1 }) - ), - ]), - ]; -}; - -export const fadeFactory = ( - from: number, - to: number, - duration = '200ms' -): AnimationMetadata[] => { - return [ - style({ opacity: from }), - animate(`${duration} ease`, style({ opacity: to })), - ]; -}; - -export const zoomFactory = ( - from: number, - to: number, - duration = '500ms' -): AnimationMetadata[] => { - return [ - style({ opacity: from, scale: from }), - animate( - `${duration} cubic-bezier(0.05, 0.7, 0.1, 1)`, - style({ opacity: to, scale: to }) - ), - ]; -}; +import { slideFadeAnimationFactory } from '../../../animations/slide.animation'; +import { fadeFactory } from '../../../animations/fade.animations'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { SelectDemoOption } from '../select-sample.component'; @Component({ selector: 'app-select-options-sample', standalone: true, - imports: [CommonModule, SelectOptionComponent], + imports: [CommonModule, SelectOptionComponent, FaIconComponent], templateUrl: './select-options-sample.component.html', styleUrl: './select-options-sample.component.css', animations: [ @@ -98,6 +37,7 @@ export const zoomFactory = ( ], }) export class SelectOptionsSampleComponent { + options = input.required(); @HostBinding('@animation') animateOptions = 'hidden'; show = input.required(); diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.css b/apps/demo-app/src/app/pages/select-sample/select-sample.component.css index 65c36b1..3787c7b 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.css +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.css @@ -1,51 +1,31 @@ sk-select { - display: block; - margin-top: 1rem; + display: block; + margin-top: 1rem; } .sk-label { - padding: 0.5rem 1rem; - border-radius: 8px; - background: rgba(255, 255, 255, 0.1); - color: #fefefe; - box-shadow: rgba(255, 255, 255, 0.05) 0px 6px 24px 0px, rgba(255, 255, 255, 0.08) 0px 0px 0px 1px; -} + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 1rem; + border-radius: 8px; + background: rgb(255 255 255 / 10%); + color: #fefefe; + box-shadow: rgb(255 255 255 / 5%) 0 6px 24px 0, + rgb(255 255 255 / 8%) 0 0 0 1px; -.sk-options { + .sk-label-item { display: flex; - flex-direction: column; + align-items: center; gap: 0.25rem; - width: 100%; - margin-top: 0.5rem; -} + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 90%; -sk-select-option { - background-color: #fff; - color: #000; - padding: 0.5rem 1rem; - cursor: pointer; - transition: color 0.3s ease-in-out, font-weight 0.3s ease-in-out, - font-size 0.3s ease-in-out; -} - -sk-select-option:first-child { - border-top-left-radius: 0.25rem; - border-top-right-radius: 0.25rem; -} - -sk-select-option:last-child { - border-bottom-left-radius: 0.25rem; - border-bottom-right-radius: 0.25rem; -} - -@media (hover) { - sk-select-option:hover { - color: deeppink; - font-weight: bold; - font-size: 1.5em; + fa-icon { + width: 1.5rem; + height: 1.5rem; } + } } - -sk-select-option:not(:last-child) { - border-bottom: 1px solid #000; -} \ No newline at end of file diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html index 3c5b938..a4d0b25 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html @@ -1,23 +1,25 @@ Multiple:
Please select an option
- @if (Array.isArray(value)) { - @for (item of value; track item) { - {{ item.data }} +
+ @for (item of selectedValues(); track item.value) { @if + (selectedValues().length <= 1) { + } - } @else { - {{ value?.data }} - } + {{ item.label }} + } +
- + +
diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.spec.ts b/apps/demo-app/src/app/pages/select-sample/select-sample.component.spec.ts index 39ef70e..cb1ebdc 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.spec.ts +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SelectSampleComponent } from './select-sample.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; describe('SelectSampleComponent', () => { let component: SelectSampleComponent; @@ -7,7 +8,7 @@ describe('SelectSampleComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SelectSampleComponent], + imports: [SelectSampleComponent, NoopAnimationsModule], }).compileComponents(); fixture = TestBed.createComponent(SelectSampleComponent); diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts index 4e0a605..50b73b2 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts @@ -1,22 +1,33 @@ -import { Component, effect, signal, untracked } from '@angular/core'; +import { Component, effect, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { - MultipleDirective, - SelectComponent, - SelectOptionComponent, -} from '@qupaya/sketch'; +import { SelectComponent, SelectOptionComponent } from '@qupaya/sketch'; import { FormsModule } from '@angular/forms'; +import { SelectOptionsSampleComponent } from './select-options-sample/select-options-sample.component'; +import { query, transition, trigger } from '@angular/animations'; import { - SelectOptionsSampleComponent, slideDeleteAnimation, slideFadeAnimationFactory, -} from './select-options-sample/select-options-sample.component'; +} from '../../animations/slide.animation'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { - AnimationEvent, - query, - transition, - trigger, -} from '@angular/animations'; + faBell, + faCog, + faEnvelope, + faGlobe, + faHeart, + faHome, + faKey, + faLock, + faStar, + faUser, +} from '@fortawesome/free-solid-svg-icons'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +export interface SelectDemoOption { + label: string; + icon: IconProp; + value: number; +} @Component({ selector: 'app-select-sample', @@ -25,9 +36,9 @@ import { CommonModule, SelectComponent, SelectOptionComponent, - MultipleDirective, FormsModule, SelectOptionsSampleComponent, + FaIconComponent, ], templateUrl: './select-sample.component.html', styleUrl: './select-sample.component.css', @@ -52,27 +63,45 @@ export class SelectSampleComponent { readonly showAnimation = signal(false); readonly animateOptions = signal<'hidden' | 'visible'>('hidden'); readonly multiple = signal(false); - readonly selectedValue = signal< - { data: number } | { data: number }[] | undefined - >(undefined); + readonly selectedValues = signal([]); + readonly options: SelectDemoOption[] = [ + { label: 'Option 1', icon: faHome, value: 1 }, + { label: 'Option 2', icon: faUser, value: 2 }, + { label: 'Option 3', icon: faCog, value: 3 }, + { label: 'Option 4', icon: faHeart, value: 4 }, + { label: 'Option 5', icon: faStar, value: 5 }, + { label: 'Option 6', icon: faBell, value: 6 }, + { label: 'Option 7', icon: faEnvelope, value: 7 }, + { label: 'Option 8', icon: faGlobe, value: 8 }, + { label: 'Option 9', icon: faLock, value: 9 }, + { label: 'Option 10', icon: faKey, value: 10 }, + ]; - value: { data: number } | { data: number }[] | undefined; + value: number | number[] | undefined; switchMultiple(event: Event): void { - if ('checked' in event.target!) { + if (event?.target && 'checked' in event.target) { this.multiple.set(event.target.checked as boolean); } } - logAnimationData(event: AnimationEvent): void { - console.log('animation event', event); + valueChanged(value: number | number[] | undefined): void { + if (!value) { + this.selectedValues.set([]); + } + if (Array.isArray(value)) { + this.selectedValues.set( + this.options.filter((option) => value.includes(option.value)) + ); + } else { + const item = this.options.find((option) => option.value === value); + this.selectedValues.set(item ? [item] : []); + } + this.value = value; } protected readonly runAnimation = effect( - () => { - this.animateOptions.set(this.showAnimation() ? 'visible' : 'hidden'); - console.log('runAnimation', untracked(this.animateOptions)); - }, + () => this.animateOptions.set(this.showAnimation() ? 'visible' : 'hidden'), { allowSignalWrites: true } ); diff --git a/libs/sketch/src/index.ts b/libs/sketch/src/index.ts index 2207f91..2b6503c 100644 --- a/libs/sketch/src/index.ts +++ b/libs/sketch/src/index.ts @@ -1,4 +1,3 @@ export * from './lib/components/sketch/sketch.component'; export * from './lib/components/select'; export * from './lib/components/list'; -export * from './lib/components/sketch/sketch.component'; diff --git a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.css b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.css index e69de29..75396aa 100644 --- a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.css +++ b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.css @@ -0,0 +1,8 @@ +:host { + border: 1px solid white; +} + +:host:focus { + outline: none; + border-color: deeppink; +} diff --git a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts index 1ec3ecf..9ca549d 100644 --- a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts +++ b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts @@ -1,5 +1,6 @@ import { Component, + effect, HostBinding, HostListener, inject, @@ -17,21 +18,24 @@ import { SelectComponent } from '../../select.component'; }) export class SelectOptionComponent { private readonly parent = inject(SelectComponent); + private tabIndexValue = 1; value = input.required(); + tabIndex = input(); @HostBinding('tabindex') get tabindex(): number { - return 1; + return this.tabIndexValue; } @HostListener('keydown.space', ['$event']) @HostListener('keydown.enter', ['$event']) - enterItem(): void { - this.parent.selectionChanged(this.value(), true); - } - @HostListener('click', ['$event']) selectItem(): void { this.parent.selectionChanged(this.value()); } + + // WORKAROUND: This is a workaround until the HostBinding can also use as signal + protected readonly updateTabIndex = effect(() => { + this.tabIndexValue = this.tabIndex() ?? 1; + }); } diff --git a/libs/sketch/src/lib/components/select/directives/multiple.directive.ts b/libs/sketch/src/lib/components/select/directives/multiple.directive.ts deleted file mode 100644 index 90ae30c..0000000 --- a/libs/sketch/src/lib/components/select/directives/multiple.directive.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { booleanAttribute, Directive, input } from '@angular/core'; - -@Directive({ - selector: '[skMultiple]', - standalone: true, -}) -export class MultipleDirective { - multiple = input(true, { transform: booleanAttribute, alias: 'skMultiple' }); -} diff --git a/libs/sketch/src/lib/components/select/directives/overlay.directive.ts b/libs/sketch/src/lib/components/select/directives/overlay.directive.ts index 75dfc72..cc8ecd8 100644 --- a/libs/sketch/src/lib/components/select/directives/overlay.directive.ts +++ b/libs/sketch/src/lib/components/select/directives/overlay.directive.ts @@ -4,33 +4,30 @@ import { ElementRef, inject, input, - OnDestroy, - OnInit, output, } from '@angular/core'; import { CdkPortal } from '@angular/cdk/portal'; import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; import { DOCUMENT } from '@angular/common'; -import { fromEvent, merge, Subject, takeUntil } from 'rxjs'; +import { fromEvent, merge } from 'rxjs'; +import { toSignal } from '@angular/core/rxjs-interop'; export const DEFAULT_POSITIONS: ConnectedPosition[] = [ { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }, ]; -export const DEFAULT_DROPOUT_POSITIONS: ConnectedPosition[] = [ - { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' }, - { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom' }, -]; - @Directive({ selector: '[skCdkOverlay]', standalone: true, }) -export class CdkOverlayDirective implements OnInit, OnDestroy { +export class CdkOverlayDirective { private readonly overlay = inject(Overlay); private readonly elementRef = inject(ElementRef); - private readonly window = inject(DOCUMENT).defaultView; - private readonly destroy$$ = new Subject(); + private readonly window = inject(DOCUMENT)?.defaultView; + + private readonly windowResize = this.window + ? toSignal(fromEvent(this.window, 'resize')) + : undefined; portal = input(undefined, { alias: 'skCdkOverlay' }); showOverlay = input(false, { alias: 'skCdkOverlayShow' }); @@ -41,47 +38,30 @@ export class CdkOverlayDirective implements OnInit, OnDestroy { relativeTo = input(undefined, { alias: 'skCdkOverlayRelativeTo', }); - showChange = output({ alias: 'skCdkOverlayShowChange' }); + backdropClass = input('cdk-overlay-transparent-backdrop', { + alias: 'skCdkOverlayBackdropClass', + }); + visible = output({ alias: 'skCdkOverlayVisible' }); private _overlayRef?: OverlayRef; - private _relatedElement?: HTMLElement; + private _relatedElement?: HTMLElement = + this.relativeTo() || this.elementRef.nativeElement; - protected detectVisibleChange = effect(() => { + protected readonly detectVisibleChange = effect(() => { if (this._relatedElement) { if (this.showOverlay()) { - this.show(); + this.createOverlay(); } else { this.hide(); } } }); - public ngOnInit(): void { - this._relatedElement = this.relativeTo() || this.elementRef.nativeElement; - if (this.window) { - fromEvent(this.window, 'resize') - .pipe(takeUntil(this.destroy$$)) - .subscribe(() => this._syncOverlayWidth()); + protected readonly updateOverlayPortal = effect(() => { + if (this.windowResize && this.windowResize()) { + this.syncOverlayWidth(); } - } - - public ngOnDestroy(): void { - this.hide(); - this.destroy$$.next(); - this.destroy$$.complete(); - } - - private show(): void { - this.createOverlay(); - } - - private hide(): void { - this.showChange.emit(false); - this.window?.setTimeout( - () => this._overlayRef?.dispose(), - this.disposeDelay() - ); - } + }); private createOverlay(): void { if (!this._relatedElement) { @@ -97,14 +77,14 @@ export class CdkOverlayDirective implements OnInit, OnDestroy { this._overlayRef = this.overlay.create({ hasBackdrop: true, - backdropClass: 'cdk-overlay-transparent-backdrop', + backdropClass: this.backdropClass(), positionStrategy, scrollStrategy: this.overlay.scrollStrategies.reposition({ autoClose: true, }), }); - this._syncOverlayWidth(); + this.syncOverlayWidth(); this._overlayRef.attach(this.portal()); merge( @@ -113,7 +93,12 @@ export class CdkOverlayDirective implements OnInit, OnDestroy { ).subscribe(() => this.hide()); } - private _syncOverlayWidth(): void { + private hide(): void { + this.visible.emit(false); + setTimeout(() => this._overlayRef?.dispose(), this.disposeDelay()); + } + + private syncOverlayWidth(): void { this._overlayRef?.updateSize({ width: this._relatedElement?.getBoundingClientRect().width, }); diff --git a/libs/sketch/src/lib/components/select/index.ts b/libs/sketch/src/lib/components/select/index.ts index 8e64083..ad1b0c7 100644 --- a/libs/sketch/src/lib/components/select/index.ts +++ b/libs/sketch/src/lib/components/select/index.ts @@ -1,4 +1,3 @@ export * from './components/select-option/select-option.component'; export * from './select.component'; export * from './directives/overlay.directive'; -export * from './directives/multiple.directive'; diff --git a/libs/sketch/src/lib/components/select/select.component.css b/libs/sketch/src/lib/components/select/select.component.css index e69de29..8533930 100644 --- a/libs/sketch/src/lib/components/select/select.component.css +++ b/libs/sketch/src/lib/components/select/select.component.css @@ -0,0 +1,4 @@ +.sk-select-trigger:focus { + outline: none; + border: 1px solid deeppink; +} diff --git a/libs/sketch/src/lib/components/select/select.component.html b/libs/sketch/src/lib/components/select/select.component.html index 2cff839..a1caaa9 100644 --- a/libs/sketch/src/lib/components/select/select.component.html +++ b/libs/sketch/src/lib/components/select/select.component.html @@ -1,12 +1,13 @@
@if (showPlaceholder()) { @@ -15,8 +16,16 @@ }
+ - +
- +
diff --git a/libs/sketch/src/lib/components/select/select.component.ts b/libs/sketch/src/lib/components/select/select.component.ts index e04f402..399e57b 100644 --- a/libs/sketch/src/lib/components/select/select.component.ts +++ b/libs/sketch/src/lib/components/select/select.component.ts @@ -1,9 +1,8 @@ import { - AfterViewInit, + booleanAttribute, ChangeDetectorRef, Component, computed, - contentChildren, effect, forwardRef, inject, @@ -14,12 +13,8 @@ import { ViewEncapsulation, } from '@angular/core'; import { CommonModule, DOCUMENT } from '@angular/common'; -import { - CdkOverlayDirective, - DEFAULT_DROPOUT_POSITIONS, -} from './directives/overlay.directive'; +import { CdkOverlayDirective } from './directives/overlay.directive'; import { CdkPortal } from '@angular/cdk/portal'; -import { MultipleDirective } from './directives/multiple.directive'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { CdkTrapFocus } from '@angular/cdk/a11y'; @@ -30,7 +25,7 @@ import { CdkTrapFocus } from '@angular/cdk/a11y'; providers: [ { provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SelectComponent), + useExisting: forwardRef(() => SelectComponent), multi: true, }, ], @@ -38,14 +33,15 @@ import { CdkTrapFocus } from '@angular/cdk/a11y'; styleUrl: './select.component.css', encapsulation: ViewEncapsulation.ShadowDom, }) -export class SelectComponent implements ControlValueAccessor, AfterViewInit { - private readonly multipleRef = inject(MultipleDirective, { optional: true }); +export class SelectComponent implements ControlValueAccessor { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly document = inject(DOCUMENT); - readonly animationDelay = input(0); - readonly open = output(); + animationDelay = input(0); + closeOnSelect = input(false); + multiple = input(false, { transform: booleanAttribute }); readonly selectedValue = signal(undefined); + readonly panelIsVisible = signal(false); readonly showPlaceholder = computed(() => { const selectedValue = this.selectedValue(); const isArray = Array.isArray(selectedValue); @@ -53,16 +49,12 @@ export class SelectComponent implements ControlValueAccessor, AfterViewInit { (!isArray && !selectedValue) || (isArray && selectedValue.length <= 0) ); }); - readonly panelIsVisible = signal(false); - readonly options = contentChildren('sk-select-option', { - descendants: true, - }); - readonly overlayPositions = DEFAULT_DROPOUT_POSITIONS; + readonly open = output(); - protected updateSelectionMode = effect( + protected readonly updateSelectionMode = effect( () => { const selectedValue = untracked(this.selectedValue); - if (!this.multipleRef?.multiple()) { + if (!this.multiple()) { this.selectedValue.set( Array.isArray(selectedValue) ? selectedValue[0] : selectedValue ); @@ -84,20 +76,10 @@ export class SelectComponent implements ControlValueAccessor, AfterViewInit { togglePanel(visible: boolean): void { this.panelIsVisible.set(visible); this.open.emit(visible); - - if (visible) { - setTimeout(() => { - this.document.querySelector('sk-select-option')?.focus(); - }, 32); - } - } - - ngAfterViewInit(): void { - console.log('SelectComponent.ngAfterViewInit', this.options()); } - selectionChanged(value: T, forceClose = false): void { - if (this.multipleRef?.multiple()) { + selectionChanged(value: T): void { + if (this.multiple()) { this.selectedValue.update((selected) => { if (Array.isArray(selected)) { return selected.includes(value) @@ -115,11 +97,31 @@ export class SelectComponent implements ControlValueAccessor, AfterViewInit { this.onChange?.(this.selectedValue()); this.onTouched?.(); - if (forceClose) { + if (this.closeOnSelect()) { this.togglePanel(false); } } + keyArrowUp({ target }: Event): void { + console.log('Arrow Down', target); + if (target instanceof HTMLElement) { + console.log('Element', target.previousElementSibling); + if (target.previousElementSibling instanceof HTMLElement) { + target.previousElementSibling.focus(); + } + } + } + + keyArrowDown({ target }: Event): void { + console.log('Arrow Down', target); + if (target instanceof HTMLElement) { + console.log('Element', target.nextElementSibling); + if (target.nextElementSibling instanceof HTMLElement) { + target.nextElementSibling.focus(); + } + } + } + writeValue(obj: T | T[] | undefined): void { this.selectedValue.set(obj); this.changeDetectorRef.markForCheck(); @@ -135,5 +137,4 @@ export class SelectComponent implements ControlValueAccessor, AfterViewInit { private onChange?: (value: T | T[] | undefined) => void; private onTouched?: () => void; - protected readonly Array = Array; } diff --git a/package-lock.json b/package-lock.json index 028d08d..eaed249 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,9 @@ "@angular/platform-browser": "~17.3.0", "@angular/platform-browser-dynamic": "~17.3.0", "@angular/router": "~17.3.0", + "@fortawesome/angular-fontawesome": "^0.14.1", + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", "@nx/angular": "18.2.2", "playwright": "^1.43.0", "react": "^18.2.0", @@ -5772,6 +5775,51 @@ "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==", "dev": true }, + "node_modules/@fortawesome/angular-fontawesome": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-0.14.1.tgz", + "integrity": "sha512-Yb5HLiEOAxjSLEcaOM51CKIrzdfvoDafXVJERm9vufxfZkVZPZJgrZRgqwLVpejgq4/Ez6TqHZ6SqmJwdtRF6g==", + "dependencies": { + "tslib": "^2.6.2" + }, + "peerDependencies": { + "@angular/core": "^17.0.0", + "@fortawesome/fontawesome-svg-core": "~1.2.27 || ~1.3.0-beta2 || ^6.1.0" + } + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz", + "integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==", + "hasInstallScript": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz", + "integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz", + "integrity": "sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.2" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", diff --git a/package.json b/package.json index 7f43522..f2fcaf6 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "@angular/platform-browser": "~17.3.0", "@angular/platform-browser-dynamic": "~17.3.0", "@angular/router": "~17.3.0", + "@fortawesome/angular-fontawesome": "^0.14.1", + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", "@nx/angular": "18.2.2", "playwright": "^1.43.0", "react": "^18.2.0", From 4e33ade77366d719981f0b8ff57158423031ec39 Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Thu, 18 Apr 2024 14:08:07 +0200 Subject: [PATCH 08/11] feat(select): add sample and default style --- apps/demo-app/src/app/app.routes.ts | 18 ++- .../pages/overview/overview.component.html | 5 +- .../app/pages/overview/overview.component.ts | 11 ++ .../select-default.component.html | 9 ++ .../select-default.component.ts | 39 +++++++ .../select-sample/select-sample.component.css | 31 ------ .../select-sample.component.html | 26 +---- .../select-sample/select-sample.component.ts | 99 +---------------- .../select-options-sample.component.css | 0 .../select-options-sample.component.html | 0 .../select-options-sample.component.spec.ts | 0 .../select-options-sample.component.ts | 6 +- .../select-with-style.component.css | 46 ++++++++ .../select-with-style.component.html | 29 +++++ .../select-with-style.component.ts | 103 ++++++++++++++++++ .../select-option/select-option.component.css | 17 ++- .../select-option/select-option.component.ts | 24 ++++ .../select/directives/overlay.directive.ts | 5 + .../components/select/select.component.css | 19 +++- .../components/select/select.component.html | 3 +- .../lib/components/select/select.component.ts | 3 +- 21 files changed, 324 insertions(+), 169 deletions(-) create mode 100644 apps/demo-app/src/app/pages/select-sample/select-default/select-default.component.html create mode 100644 apps/demo-app/src/app/pages/select-sample/select-default/select-default.component.ts delete mode 100644 apps/demo-app/src/app/pages/select-sample/select-sample.component.css rename apps/demo-app/src/app/pages/select-sample/{ => select-with-style}/select-options-sample/select-options-sample.component.css (100%) rename apps/demo-app/src/app/pages/select-sample/{ => select-with-style}/select-options-sample/select-options-sample.component.html (100%) rename apps/demo-app/src/app/pages/select-sample/{ => select-with-style}/select-options-sample/select-options-sample.component.spec.ts (100%) rename apps/demo-app/src/app/pages/select-sample/{ => select-with-style}/select-options-sample/select-options-sample.component.ts (86%) create mode 100644 apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.css create mode 100644 apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.html create mode 100644 apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.ts diff --git a/apps/demo-app/src/app/app.routes.ts b/apps/demo-app/src/app/app.routes.ts index 3a83079..0708c1d 100644 --- a/apps/demo-app/src/app/app.routes.ts +++ b/apps/demo-app/src/app/app.routes.ts @@ -1,4 +1,7 @@ import { Route } from '@angular/router'; +import { SelectSampleComponent } from './pages/select-sample/select-sample.component'; +import { SelectDefaultComponent } from './pages/select-sample/select-default/select-default.component'; +import { SelectWithStyleComponent } from './pages/select-sample/select-with-style/select-with-style.component'; const UUID_REGEX = /^[a-z,0-9,-]{36,36}$/; @@ -17,10 +20,17 @@ export const appRoutes: Route[] = [ }, { path: 'select', - loadComponent: () => - import('./pages/select-sample/select-sample.component').then( - (m) => m.SelectSampleComponent - ), + component: SelectSampleComponent, + children: [ + { + path: '', + component: SelectDefaultComponent, + }, + { + path: 'with-style', + component: SelectWithStyleComponent, + }, + ], }, { path: 'list-sample', diff --git a/apps/demo-app/src/app/pages/overview/overview.component.html b/apps/demo-app/src/app/pages/overview/overview.component.html index 5cdbc21..c972fa2 100644 --- a/apps/demo-app/src/app/pages/overview/overview.component.html +++ b/apps/demo-app/src/app/pages/overview/overview.component.html @@ -4,9 +4,6 @@ Lorem ipsum dolor... - + Lorem ipsum dolor... diff --git a/apps/demo-app/src/app/pages/overview/overview.component.ts b/apps/demo-app/src/app/pages/overview/overview.component.ts index 7407495..2d606a4 100644 --- a/apps/demo-app/src/app/pages/overview/overview.component.ts +++ b/apps/demo-app/src/app/pages/overview/overview.component.ts @@ -23,6 +23,17 @@ export class OverviewComponent { }, ]; + readonly selectRoutes = [ + { + route: '/select/with-style', + label: 'Select Sample with styling', + }, + { + route: '/select', + label: 'Select Sample Headless Default', + }, + ]; + readonly widgetLinks = [ { route: '/widget-sample', diff --git a/apps/demo-app/src/app/pages/select-sample/select-default/select-default.component.html b/apps/demo-app/src/app/pages/select-sample/select-default/select-default.component.html new file mode 100644 index 0000000..0ef48c4 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-default/select-default.component.html @@ -0,0 +1,9 @@ + + Select option + {{ selectedValue?.label }} + @for (option of options; track option.value) { + + {{ option.label }} + + } + diff --git a/apps/demo-app/src/app/pages/select-sample/select-default/select-default.component.ts b/apps/demo-app/src/app/pages/select-sample/select-default/select-default.component.ts new file mode 100644 index 0000000..304b6d1 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-default/select-default.component.ts @@ -0,0 +1,39 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SelectDemoOption } from '../select-sample.component'; +import { + faBell, + faCog, + faEnvelope, + faGlobe, + faHeart, + faHome, + faKey, + faLock, + faStar, + faUser, +} from '@fortawesome/free-solid-svg-icons'; +import { SelectComponent, SelectOptionComponent } from '@qupaya/sketch'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-select-default', + standalone: true, + imports: [CommonModule, SelectComponent, SelectOptionComponent, FormsModule], + templateUrl: './select-default.component.html', +}) +export class SelectDefaultComponent { + readonly options: SelectDemoOption[] = [ + { label: 'Option 1', icon: faHome, value: 1 }, + { label: 'Option 2', icon: faUser, value: 2 }, + { label: 'Option 3', icon: faCog, value: 3 }, + { label: 'Option 4', icon: faHeart, value: 4 }, + { label: 'Option 5', icon: faStar, value: 5 }, + { label: 'Option 6', icon: faBell, value: 6 }, + { label: 'Option 7', icon: faEnvelope, value: 7 }, + { label: 'Option 8', icon: faGlobe, value: 8 }, + { label: 'Option 9', icon: faLock, value: 9 }, + { label: 'Option 10', icon: faKey, value: 10 }, + ]; + selectedValue?: SelectDemoOption; +} diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.css b/apps/demo-app/src/app/pages/select-sample/select-sample.component.css deleted file mode 100644 index 3787c7b..0000000 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.css +++ /dev/null @@ -1,31 +0,0 @@ -sk-select { - display: block; - margin-top: 1rem; -} - -.sk-label { - display: flex; - align-items: center; - gap: 0.25rem; - padding: 0.5rem 1rem; - border-radius: 8px; - background: rgb(255 255 255 / 10%); - color: #fefefe; - box-shadow: rgb(255 255 255 / 5%) 0 6px 24px 0, - rgb(255 255 255 / 8%) 0 0 0 1px; - - .sk-label-item { - display: flex; - align-items: center; - gap: 0.25rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 90%; - - fa-icon { - width: 1.5rem; - height: 1.5rem; - } - } -} diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html index a4d0b25..0680b43 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.html +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html @@ -1,25 +1 @@ -Multiple: - - -
Please select an option
-
-
- @for (item of selectedValues(); track item.value) { @if - (selectedValues().length <= 1) { - - } - {{ item.label }} - } -
-
- - - -
+ diff --git a/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts index 50b73b2..5f3ea2f 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts @@ -1,27 +1,7 @@ -import { Component, effect, signal } from '@angular/core'; +import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { SelectComponent, SelectOptionComponent } from '@qupaya/sketch'; -import { FormsModule } from '@angular/forms'; -import { SelectOptionsSampleComponent } from './select-options-sample/select-options-sample.component'; -import { query, transition, trigger } from '@angular/animations'; -import { - slideDeleteAnimation, - slideFadeAnimationFactory, -} from '../../animations/slide.animation'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { - faBell, - faCog, - faEnvelope, - faGlobe, - faHeart, - faHome, - faKey, - faLock, - faStar, - faUser, -} from '@fortawesome/free-solid-svg-icons'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { RouterOutlet } from '@angular/router'; export interface SelectDemoOption { label: string; @@ -32,78 +12,7 @@ export interface SelectDemoOption { @Component({ selector: 'app-select-sample', standalone: true, - imports: [ - CommonModule, - SelectComponent, - SelectOptionComponent, - FormsModule, - SelectOptionsSampleComponent, - FaIconComponent, - ], + imports: [CommonModule, RouterOutlet], templateUrl: './select-sample.component.html', - styleUrl: './select-sample.component.css', - animations: [ - trigger('animation', [ - transition( - 'hidden => visible', - query('.sk-option', slideFadeAnimationFactory(), { - optional: true, - }) - ), - transition( - 'visible => hidden', - query('.sk-option', slideDeleteAnimation(), { - optional: true, - }) - ), - ]), - ], }) -export class SelectSampleComponent { - readonly showAnimation = signal(false); - readonly animateOptions = signal<'hidden' | 'visible'>('hidden'); - readonly multiple = signal(false); - readonly selectedValues = signal([]); - readonly options: SelectDemoOption[] = [ - { label: 'Option 1', icon: faHome, value: 1 }, - { label: 'Option 2', icon: faUser, value: 2 }, - { label: 'Option 3', icon: faCog, value: 3 }, - { label: 'Option 4', icon: faHeart, value: 4 }, - { label: 'Option 5', icon: faStar, value: 5 }, - { label: 'Option 6', icon: faBell, value: 6 }, - { label: 'Option 7', icon: faEnvelope, value: 7 }, - { label: 'Option 8', icon: faGlobe, value: 8 }, - { label: 'Option 9', icon: faLock, value: 9 }, - { label: 'Option 10', icon: faKey, value: 10 }, - ]; - - value: number | number[] | undefined; - - switchMultiple(event: Event): void { - if (event?.target && 'checked' in event.target) { - this.multiple.set(event.target.checked as boolean); - } - } - - valueChanged(value: number | number[] | undefined): void { - if (!value) { - this.selectedValues.set([]); - } - if (Array.isArray(value)) { - this.selectedValues.set( - this.options.filter((option) => value.includes(option.value)) - ); - } else { - const item = this.options.find((option) => option.value === value); - this.selectedValues.set(item ? [item] : []); - } - this.value = value; - } - - protected readonly runAnimation = effect( - () => this.animateOptions.set(this.showAnimation() ? 'visible' : 'hidden'), - { allowSignalWrites: true } - ); - - protected readonly Array = Array; -} +export class SelectSampleComponent {} diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.css similarity index 100% rename from apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.css rename to apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.css diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.html similarity index 100% rename from apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.html rename to apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.html diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.spec.ts b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.spec.ts similarity index 100% rename from apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.spec.ts rename to apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.spec.ts diff --git a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.ts similarity index 86% rename from apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts rename to apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.ts index cfa03f6..f61f5bf 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-options-sample/select-options-sample.component.ts +++ b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.ts @@ -2,10 +2,10 @@ import { Component, effect, HostBinding, input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { query, transition, trigger } from '@angular/animations'; import { SelectOptionComponent } from '@qupaya/sketch'; -import { slideFadeAnimationFactory } from '../../../animations/slide.animation'; -import { fadeFactory } from '../../../animations/fade.animations'; +import { slideFadeAnimationFactory } from '../../../../animations/slide.animation'; +import { fadeFactory } from '../../../../animations/fade.animations'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { SelectDemoOption } from '../select-sample.component'; +import { SelectDemoOption } from '../../select-sample.component'; @Component({ selector: 'app-select-options-sample', diff --git a/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.css b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.css new file mode 100644 index 0000000..aba0cc1 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.css @@ -0,0 +1,46 @@ +sk-select { + --sk-select-label-background: rgb(255 255 255 / 10%); + --sk-select-label-color: #fefefe; + --sk-select-label-padding: 0; + --sk-select-label-border-radius: 8px; + --sk-select-label-border-color: transparent; + --sk-select-label-border-focus-color: deeppink; + + display: block; + margin-top: 1rem; +} + +.sk-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.25rem; + border-radius: 8px; + padding: 0.5rem 1rem; + box-shadow: rgb(255 255 255 / 5%) 0 6px 24px 0, + rgb(255 255 255 / 8%) 0 0 0 1px; +} + +.sk-label .sk-arrow { + transform: rotate(0); + transition: transform 0.3s ease-in-out; +} + +.sk-label.open .sk-arrow { + transform: rotate(180deg); +} + +.sk-label .sk-label-item { + display: flex; + align-items: center; + gap: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 90%; +} + +fa-icon { + width: 1.5rem; + height: 1.5rem; +} diff --git a/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.html b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.html new file mode 100644 index 0000000..a7def21 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.html @@ -0,0 +1,29 @@ +Multiple: + + +
+ Please select an option + +
+
+
+ @for (item of selectedValues(); track item.value) { @if + (selectedValues().length <= 1) { + + } + {{ item.label }} + } +
+ +
+ + + +
diff --git a/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.ts b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.ts new file mode 100644 index 0000000..fdb4c64 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.ts @@ -0,0 +1,103 @@ +import { Component, effect, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + faArrowAltCircleDown, + faBell, + faCog, + faEnvelope, + faGlobe, + faHeart, + faHome, + faKey, + faLock, + faStar, + faUser, +} from '@fortawesome/free-solid-svg-icons'; +import { SelectDemoOption } from '../select-sample.component'; +import { query, transition, trigger } from '@angular/animations'; +import { + slideDeleteAnimation, + slideFadeAnimationFactory, +} from '../../../animations/slide.animation'; +import { SelectOptionsSampleComponent } from './select-options-sample/select-options-sample.component'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { SelectComponent } from '@qupaya/sketch'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-select-with-style', + standalone: true, + imports: [ + CommonModule, + SelectOptionsSampleComponent, + FaIconComponent, + SelectComponent, + FormsModule, + ], + templateUrl: './select-with-style.component.html', + styleUrl: './select-with-style.component.css', + animations: [ + trigger('animation', [ + transition( + 'hidden => visible', + query('.sk-option', slideFadeAnimationFactory(), { + optional: true, + }) + ), + transition( + 'visible => hidden', + query('.sk-option', slideDeleteAnimation(), { + optional: true, + }) + ), + ]), + ], +}) +export class SelectWithStyleComponent { + readonly showAnimation = signal(false); + readonly animateOptions = signal<'hidden' | 'visible'>('hidden'); + readonly multiple = signal(false); + readonly selectedValues = signal([]); + readonly options: SelectDemoOption[] = [ + { label: 'Option 1', icon: faHome, value: 1 }, + { label: 'Option 2', icon: faUser, value: 2 }, + { label: 'Option 3', icon: faCog, value: 3 }, + { label: 'Option 4', icon: faHeart, value: 4 }, + { label: 'Option 5', icon: faStar, value: 5 }, + { label: 'Option 6', icon: faBell, value: 6 }, + { label: 'Option 7', icon: faEnvelope, value: 7 }, + { label: 'Option 8', icon: faGlobe, value: 8 }, + { label: 'Option 9', icon: faLock, value: 9 }, + { label: 'Option 10', icon: faKey, value: 10 }, + ]; + readonly faArrowAltCircleDown = faArrowAltCircleDown; + value: number | number[] | undefined; + + switchMultiple(event: Event): void { + if (event?.target && 'checked' in event.target) { + this.multiple.set(event.target.checked as boolean); + } + } + + valueChanged(value: number | number[] | undefined): void { + if (!value) { + this.selectedValues.set([]); + } + if (Array.isArray(value)) { + this.selectedValues.set( + this.options.filter((option) => value.includes(option.value)) + ); + } else { + const item = this.options.find((option) => option.value === value); + this.selectedValues.set(item ? [item] : []); + } + this.value = value; + } + + protected readonly runAnimation = effect( + () => this.animateOptions.set(this.showAnimation() ? 'visible' : 'hidden'), + { allowSignalWrites: true } + ); + + protected readonly Array = Array; +} diff --git a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.css b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.css index 75396aa..b562e39 100644 --- a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.css +++ b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.css @@ -1,8 +1,21 @@ :host { - border: 1px solid white; + --sk-select-option-background: white; + --sk-select-option-color: #000; + --sk-select-option-border-color: white; + --sk-select-option-selected-color: deeppink; + --sk-select-option-focus-color: deeppink; + + background: var(--sk-select-option-background); + color: var(--sk-select-option-color); + padding: 0.5rem 1rem; + cursor: pointer; +} + +:host.sk-selected { + color: var(--sk-select-option-selected-color); } :host:focus { outline: none; - border-color: deeppink; + background: rgb(255 255 255 / 60%); } diff --git a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts index 9ca549d..cc77588 100644 --- a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts +++ b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts @@ -5,6 +5,7 @@ import { HostListener, inject, input, + untracked, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SelectComponent } from '../../select.component'; @@ -19,6 +20,7 @@ import { SelectComponent } from '../../select.component'; export class SelectOptionComponent { private readonly parent = inject(SelectComponent); private tabIndexValue = 1; + private selected = false; value = input.required(); tabIndex = input(); @@ -27,6 +29,11 @@ export class SelectOptionComponent { return this.tabIndexValue; } + @HostBinding('class.sk-selected') + get selectedClass(): boolean { + return this.selected; + } + @HostListener('keydown.space', ['$event']) @HostListener('keydown.enter', ['$event']) @HostListener('click', ['$event']) @@ -34,6 +41,23 @@ export class SelectOptionComponent { this.parent.selectionChanged(this.value()); } + // WORKAROUND: This is a workaround until the HostBinding can also use as signal + protected readonly updateSelectedState = effect(() => { + const selectedValue = this.parent.selectedValue(); + const value = untracked(this.value); + + if (!selectedValue) { + this.selected = false; + } else if (Array.isArray(selectedValue)) { + this.selected = selectedValue.includes(value); + } else { + this.selected = selectedValue === value; + } + console.log('selectedValue', selectedValue); + console.log('value', value); + console.log('this.selected', this.selected); + }); + // WORKAROUND: This is a workaround until the HostBinding can also use as signal protected readonly updateTabIndex = effect(() => { this.tabIndexValue = this.tabIndex() ?? 1; diff --git a/libs/sketch/src/lib/components/select/directives/overlay.directive.ts b/libs/sketch/src/lib/components/select/directives/overlay.directive.ts index cc8ecd8..52eb855 100644 --- a/libs/sketch/src/lib/components/select/directives/overlay.directive.ts +++ b/libs/sketch/src/lib/components/select/directives/overlay.directive.ts @@ -41,6 +41,9 @@ export class CdkOverlayDirective { backdropClass = input('cdk-overlay-transparent-backdrop', { alias: 'skCdkOverlayBackdropClass', }); + panelClass = input('cdk-overlay-panel', { + alias: 'skCdkOverlayPanelClass', + }); visible = output({ alias: 'skCdkOverlayVisible' }); private _overlayRef?: OverlayRef; @@ -73,11 +76,13 @@ export class CdkOverlayDirective { .flexibleConnectedTo(this._relatedElement) .withPositions(this.connectedPositions()) .withPush(true) + .withDefaultOffsetY(10) .withFlexibleDimensions(false); this._overlayRef = this.overlay.create({ hasBackdrop: true, backdropClass: this.backdropClass(), + panelClass: this.panelClass(), positionStrategy, scrollStrategy: this.overlay.scrollStrategies.reposition({ autoClose: true, diff --git a/libs/sketch/src/lib/components/select/select.component.css b/libs/sketch/src/lib/components/select/select.component.css index 8533930..3556b63 100644 --- a/libs/sketch/src/lib/components/select/select.component.css +++ b/libs/sketch/src/lib/components/select/select.component.css @@ -1,4 +1,21 @@ +:host { + --sk-select-label-background: #fff; + --sk-select-label-color: #000; + --sk-select-label-padding: 0.5rem 1rem; + --sk-select-label-border-radius: 8px; + --sk-select-label-border-color: transparent; + --sk-select-label-border-focus-color: deeppink; +} + +.sk-select-trigger { + background: var(--sk-select-label-background); + color: var(--sk-select-label-color); + padding: var(--sk-select-label-padding); + border: 1px solid var(--sk-select-label-border-color); + border-radius: var(--sk-select-label-border-radius); +} + .sk-select-trigger:focus { outline: none; - border: 1px solid deeppink; + border: 1px solid var(--sk-select-label-border-focus-color); } diff --git a/libs/sketch/src/lib/components/select/select.component.html b/libs/sketch/src/lib/components/select/select.component.html index a1caaa9..fa61499 100644 --- a/libs/sketch/src/lib/components/select/select.component.html +++ b/libs/sketch/src/lib/components/select/select.component.html @@ -19,8 +19,7 @@
implements ControlValueAccessor { private readonly changeDetectorRef = inject(ChangeDetectorRef); - private readonly document = inject(DOCUMENT); animationDelay = input(0); closeOnSelect = input(false); From e97699459b69eba2bc14ed07d08c5c6f83343ddc Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Thu, 18 Apr 2024 14:32:04 +0200 Subject: [PATCH 09/11] feat(select): add story for select component --- .../select-option/select-option.component.ts | 3 -- .../components/select/select.component.html | 4 +- .../select/select.component.stories.ts | 39 +++++++++++++++++++ .../lib/components/select/select.component.ts | 4 +- 4 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 libs/sketch/src/lib/components/select/select.component.stories.ts diff --git a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts index cc77588..799fda9 100644 --- a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts +++ b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts @@ -53,9 +53,6 @@ export class SelectOptionComponent { } else { this.selected = selectedValue === value; } - console.log('selectedValue', selectedValue); - console.log('value', value); - console.log('this.selected', this.selected); }); // WORKAROUND: This is a workaround until the HostBinding can also use as signal diff --git a/libs/sketch/src/lib/components/select/select.component.html b/libs/sketch/src/lib/components/select/select.component.html index fa61499..06d2719 100644 --- a/libs/sketch/src/lib/components/select/select.component.html +++ b/libs/sketch/src/lib/components/select/select.component.html @@ -4,7 +4,7 @@ [skCdkOverlayShow]="panelIsVisible()" [skCdkOverlayDisposeDelay]="animationDelay()" [tabIndex]="0" - (click)="togglePanel(true)" + (click)="togglePanel(true, false)" (keydown.enter)="togglePanel(!this.panelIsVisible())" (keydown.space)="togglePanel(!this.panelIsVisible())" (skCdkOverlayVisible)="togglePanel($event)" @@ -23,7 +23,7 @@ (keydown.arrowUp)="keyArrowUp($event)" (keydown.arrowDown)="keyArrowDown($event)" cdkTrapFocus - [cdkTrapFocusAutoCapture]="true" + [cdkTrapFocusAutoCapture]="autoFocus()" >
diff --git a/libs/sketch/src/lib/components/select/select.component.stories.ts b/libs/sketch/src/lib/components/select/select.component.stories.ts new file mode 100644 index 0000000..b50aead --- /dev/null +++ b/libs/sketch/src/lib/components/select/select.component.stories.ts @@ -0,0 +1,39 @@ +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { CommonModule } from '@angular/common'; +import { SelectComponent } from './select.component'; +import { SelectOptionComponent } from './components/select-option/select-option.component'; + +const meta: Meta> = { + component: SelectComponent, + title: 'SelectComponent', + parameters: { + backgrounds: { default: 'dark' }, + }, + decorators: [ + moduleMetadata({ + imports: [CommonModule, SelectComponent, SelectOptionComponent], + }), + ], +}; +export default meta; +type Story = StoryObj>; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` + Placeholder + Selected + Option 1 + Option 2 + Option 3 + Option 4 + Option 5 + Option 6 + Option 7 + Option 8 + Option 9 + Option 10 + `, + }), +}; diff --git a/libs/sketch/src/lib/components/select/select.component.ts b/libs/sketch/src/lib/components/select/select.component.ts index 6eefda2..b2297db 100644 --- a/libs/sketch/src/lib/components/select/select.component.ts +++ b/libs/sketch/src/lib/components/select/select.component.ts @@ -39,6 +39,7 @@ export class SelectComponent implements ControlValueAccessor { animationDelay = input(0); closeOnSelect = input(false); multiple = input(false, { transform: booleanAttribute }); + readonly autoFocus = signal(false); readonly selectedValue = signal(undefined); readonly panelIsVisible = signal(false); readonly showPlaceholder = computed(() => { @@ -72,8 +73,9 @@ export class SelectComponent implements ControlValueAccessor { { allowSignalWrites: true } ); - togglePanel(visible: boolean): void { + togglePanel(visible: boolean, focus = true): void { this.panelIsVisible.set(visible); + this.autoFocus.set(focus); this.open.emit(visible); } From 36af2511f59a2ca698cddbebf4a96295b3a9ec07 Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Fri, 19 Apr 2024 08:12:00 +0200 Subject: [PATCH 10/11] feat(select): close on escape --- .../select/directives/overlay.directive.ts | 3 --- .../components/select/select.component.css | 1 + .../components/select/select.component.html | 2 +- .../lib/components/select/select.component.ts | 19 ++++++++++++------- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/libs/sketch/src/lib/components/select/directives/overlay.directive.ts b/libs/sketch/src/lib/components/select/directives/overlay.directive.ts index 52eb855..db9ab66 100644 --- a/libs/sketch/src/lib/components/select/directives/overlay.directive.ts +++ b/libs/sketch/src/lib/components/select/directives/overlay.directive.ts @@ -84,9 +84,6 @@ export class CdkOverlayDirective { backdropClass: this.backdropClass(), panelClass: this.panelClass(), positionStrategy, - scrollStrategy: this.overlay.scrollStrategies.reposition({ - autoClose: true, - }), }); this.syncOverlayWidth(); diff --git a/libs/sketch/src/lib/components/select/select.component.css b/libs/sketch/src/lib/components/select/select.component.css index 3556b63..694f1b0 100644 --- a/libs/sketch/src/lib/components/select/select.component.css +++ b/libs/sketch/src/lib/components/select/select.component.css @@ -13,6 +13,7 @@ padding: var(--sk-select-label-padding); border: 1px solid var(--sk-select-label-border-color); border-radius: var(--sk-select-label-border-radius); + cursor: pointer; } .sk-select-trigger:focus { diff --git a/libs/sketch/src/lib/components/select/select.component.html b/libs/sketch/src/lib/components/select/select.component.html index 06d2719..a19b3d4 100644 --- a/libs/sketch/src/lib/components/select/select.component.html +++ b/libs/sketch/src/lib/components/select/select.component.html @@ -4,7 +4,7 @@ [skCdkOverlayShow]="panelIsVisible()" [skCdkOverlayDisposeDelay]="animationDelay()" [tabIndex]="0" - (click)="togglePanel(true, false)" + (click)="togglePanel(true)" (keydown.enter)="togglePanel(!this.panelIsVisible())" (keydown.space)="togglePanel(!this.panelIsVisible())" (skCdkOverlayVisible)="togglePanel($event)" diff --git a/libs/sketch/src/lib/components/select/select.component.ts b/libs/sketch/src/lib/components/select/select.component.ts index b2297db..c1ce7a1 100644 --- a/libs/sketch/src/lib/components/select/select.component.ts +++ b/libs/sketch/src/lib/components/select/select.component.ts @@ -5,6 +5,7 @@ import { computed, effect, forwardRef, + HostListener, inject, input, output, @@ -39,7 +40,8 @@ export class SelectComponent implements ControlValueAccessor { animationDelay = input(0); closeOnSelect = input(false); multiple = input(false, { transform: booleanAttribute }); - readonly autoFocus = signal(false); + + readonly autoFocus = signal(true); readonly selectedValue = signal(undefined); readonly panelIsVisible = signal(false); readonly showPlaceholder = computed(() => { @@ -49,8 +51,16 @@ export class SelectComponent implements ControlValueAccessor { (!isArray && !selectedValue) || (isArray && selectedValue.length <= 0) ); }); + readonly open = output(); + @HostListener('document:keydown.escape') + closePanel(): void { + if (this.panelIsVisible()) { + this.togglePanel(false); + } + } + protected readonly updateSelectionMode = effect( () => { const selectedValue = untracked(this.selectedValue); @@ -73,9 +83,8 @@ export class SelectComponent implements ControlValueAccessor { { allowSignalWrites: true } ); - togglePanel(visible: boolean, focus = true): void { + togglePanel(visible: boolean): void { this.panelIsVisible.set(visible); - this.autoFocus.set(focus); this.open.emit(visible); } @@ -104,9 +113,7 @@ export class SelectComponent implements ControlValueAccessor { } keyArrowUp({ target }: Event): void { - console.log('Arrow Down', target); if (target instanceof HTMLElement) { - console.log('Element', target.previousElementSibling); if (target.previousElementSibling instanceof HTMLElement) { target.previousElementSibling.focus(); } @@ -114,9 +121,7 @@ export class SelectComponent implements ControlValueAccessor { } keyArrowDown({ target }: Event): void { - console.log('Arrow Down', target); if (target instanceof HTMLElement) { - console.log('Element', target.nextElementSibling); if (target.nextElementSibling instanceof HTMLElement) { target.nextElementSibling.focus(); } From af92c80a0887f50af667996609f5046c00195a69 Mon Sep 17 00:00:00 2001 From: patrickjahr Date: Fri, 19 Apr 2024 08:15:40 +0200 Subject: [PATCH 11/11] feat(lib): add style file for users --- .../select-with-style.component.html | 1 + apps/demo-app/src/styles.css | 15 ++++++++------- .../select/directives/overlay.directive.ts | 13 ++++++++++--- .../lib/components/select/select.component.html | 2 ++ .../src/lib/components/select/select.component.ts | 2 ++ 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.html b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.html index a7def21..64ab84b 100644 --- a/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.html +++ b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.html @@ -6,6 +6,7 @@ [multiple]="multiple()" [animationDelay]="250" [closeOnSelect]="!multiple()" + [panelOffsetY]="10" (open)="showAnimation.set($event)" >
diff --git a/apps/demo-app/src/styles.css b/apps/demo-app/src/styles.css index 8d63845..8f42865 100644 --- a/apps/demo-app/src/styles.css +++ b/apps/demo-app/src/styles.css @@ -1,14 +1,15 @@ -/* TODO: move to lib component*/ -@import '@angular/cdk/overlay-prebuilt.css'; +@import url('@angular/cdk/overlay-prebuilt.css'); /* You can add global styles to this file, and also import other style files */ :root { --sk-top-bar-height: 84px; - --sk-font-family: Montserrat, 'Helevetika Neue', sans-serif; + --sk-font-family: 'Montserrat', 'Helevetika Neue', sans-serif; --sk-primary-color-rgb: 252, 0, 84; --sk-primary-color: rgb(var(--sk-primary-color-rgb)); --sk-secondary-color-rgb: 59, 61, 64; --sk-secondary-color: rgb(var(--sk-secondary-color-rgb)); + --sk-light-color-rgb: 255, 255, 255; + --sk-gray-color-rgb: 59, 61, 64; } * { @@ -20,12 +21,12 @@ html, body { font-family: var(--sk-font-family); - color: rgb(255, 255, 255); - background: rgb(59, 61, 64); + color: rgb(var(--sk-light-color-rgb)); + background: rgb(var(--sk-secondary-color-rgb)); background: linear-gradient( 149deg, - rgba(var(--sk-secondary-color-rgb), 1) 0%, - rgba(var(--sk-primary-color-rgb), 1) 100% + rgb(var(--sk-secondary-color-rgb)) 0%, + rgb(var(--sk-primary-color-rgb)) 100% ); background-repeat: no-repeat; height: 100svh; diff --git a/libs/sketch/src/lib/components/select/directives/overlay.directive.ts b/libs/sketch/src/lib/components/select/directives/overlay.directive.ts index db9ab66..8db97e3 100644 --- a/libs/sketch/src/lib/components/select/directives/overlay.directive.ts +++ b/libs/sketch/src/lib/components/select/directives/overlay.directive.ts @@ -9,7 +9,7 @@ import { import { CdkPortal } from '@angular/cdk/portal'; import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; import { DOCUMENT } from '@angular/common'; -import { fromEvent, merge } from 'rxjs'; +import { debounceTime, fromEvent, merge } from 'rxjs'; import { toSignal } from '@angular/core/rxjs-interop'; export const DEFAULT_POSITIONS: ConnectedPosition[] = [ @@ -26,7 +26,7 @@ export class CdkOverlayDirective { private readonly window = inject(DOCUMENT)?.defaultView; private readonly windowResize = this.window - ? toSignal(fromEvent(this.window, 'resize')) + ? toSignal(fromEvent(this.window, 'resize').pipe(debounceTime(500))) : undefined; portal = input(undefined, { alias: 'skCdkOverlay' }); @@ -44,6 +44,12 @@ export class CdkOverlayDirective { panelClass = input('cdk-overlay-panel', { alias: 'skCdkOverlayPanelClass', }); + offsetX = input(0, { + alias: 'skCdkOverlayOffsetX', + }); + offsetY = input(0, { + alias: 'skCdkOverlayOffsetY', + }); visible = output({ alias: 'skCdkOverlayVisible' }); private _overlayRef?: OverlayRef; @@ -76,7 +82,8 @@ export class CdkOverlayDirective { .flexibleConnectedTo(this._relatedElement) .withPositions(this.connectedPositions()) .withPush(true) - .withDefaultOffsetY(10) + .withDefaultOffsetX(this.offsetX()) + .withDefaultOffsetY(this.offsetY()) .withFlexibleDimensions(false); this._overlayRef = this.overlay.create({ diff --git a/libs/sketch/src/lib/components/select/select.component.html b/libs/sketch/src/lib/components/select/select.component.html index a19b3d4..01b67ce 100644 --- a/libs/sketch/src/lib/components/select/select.component.html +++ b/libs/sketch/src/lib/components/select/select.component.html @@ -3,6 +3,8 @@ [skCdkOverlay]="overlay" [skCdkOverlayShow]="panelIsVisible()" [skCdkOverlayDisposeDelay]="animationDelay()" + [skCdkOverlayOffsetX]="panelOffsetX()" + [skCdkOverlayOffsetY]="panelOffsetY()" [tabIndex]="0" (click)="togglePanel(true)" (keydown.enter)="togglePanel(!this.panelIsVisible())" diff --git a/libs/sketch/src/lib/components/select/select.component.ts b/libs/sketch/src/lib/components/select/select.component.ts index c1ce7a1..9d2308f 100644 --- a/libs/sketch/src/lib/components/select/select.component.ts +++ b/libs/sketch/src/lib/components/select/select.component.ts @@ -39,6 +39,8 @@ export class SelectComponent implements ControlValueAccessor { animationDelay = input(0); closeOnSelect = input(false); + panelOffsetX = input(0); + panelOffsetY = input(0); multiple = input(false, { transform: booleanAttribute }); readonly autoFocus = signal(true);