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 e0f998a..f95c7ed 100644 --- a/apps/demo-app/src/app/app.config.ts +++ b/apps/demo-app/src/app/app.config.ts @@ -1,11 +1,12 @@ -import { ApplicationConfig } from '@angular/core'; +import { ApplicationConfig, importProvidersFrom } 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'; +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/app.routes.ts b/apps/demo-app/src/app/app.routes.ts index 7f027c9..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}$/; @@ -15,6 +18,20 @@ export const appRoutes: Route[] = [ (m) => m.OverviewComponent ), }, + { + path: 'select', + component: SelectSampleComponent, + children: [ + { + path: '', + component: SelectDefaultComponent, + }, + { + path: 'with-style', + component: SelectWithStyleComponent, + }, + ], + }, { 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..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,3 +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.html b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html new file mode 100644 index 0000000..0680b43 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.html @@ -0,0 +1 @@ + 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..cb1ebdc --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.spec.ts @@ -0,0 +1,22 @@ +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; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SelectSampleComponent, NoopAnimationsModule], + }).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..5f3ea2f --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-sample.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { RouterOutlet } from '@angular/router'; + +export interface SelectDemoOption { + label: string; + icon: IconProp; + value: number; +} + +@Component({ + selector: 'app-select-sample', + standalone: true, + imports: [CommonModule, RouterOutlet], + templateUrl: './select-sample.component.html', +}) +export class SelectSampleComponent {} diff --git a/apps/demo-app/src/app/pages/select-sample/select-with-style/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 new file mode 100644 index 0000000..69229e0 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.css @@ -0,0 +1,41 @@ +:host { + display: flex; + flex-direction: column; + width: 100%; + max-height: 200px; + margin-top: 0.5rem; + overflow-x: auto; +} + +sk-select-option { + 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; +} + +sk-select-option:last-child { + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +@media (hover) { + 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-with-style/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 new file mode 100644 index 0000000..49199a2 --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.html @@ -0,0 +1,6 @@ +@for (option of options(); track option.value) { + + + {{ option.label }} + +} diff --git a/apps/demo-app/src/app/pages/select-sample/select-with-style/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 new file mode 100644 index 0000000..3f06ffa --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.spec.ts @@ -0,0 +1,25 @@ +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; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SelectOptionsSampleComponent, NoopAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(SelectOptionsSampleComponent); + component = fixture.componentInstance; + const componentRef = fixture.componentRef; + componentRef.setInput('show', true); + componentRef.setInput('options', []); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/demo-app/src/app/pages/select-sample/select-with-style/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 new file mode 100644 index 0000000..f61f5bf --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-options-sample/select-options-sample.component.ts @@ -0,0 +1,48 @@ +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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { SelectDemoOption } from '../../select-sample.component'; + +@Component({ + selector: 'app-select-options-sample', + standalone: true, + imports: [CommonModule, SelectOptionComponent, FaIconComponent], + templateUrl: './select-options-sample.component.html', + styleUrl: './select-options-sample.component.css', + animations: [ + trigger('animation', [ + transition( + '* => visible', + query('sk-select-option', slideFadeAnimationFactory(), { + optional: true, + }) + ), + transition( + '* => hidden', + query('sk-select-option', fadeFactory(1, 0, '350ms'), { + optional: true, + }) + ), + transition( + 'visible => void', + query('sk-select-option', fadeFactory(1, 0, '350ms'), { + optional: true, + }) + ), + ]), + ], +}) +export class SelectOptionsSampleComponent { + options = input.required(); + @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-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..64ab84b --- /dev/null +++ b/apps/demo-app/src/app/pages/select-sample/select-with-style/select-with-style.component.html @@ -0,0 +1,30 @@ +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/apps/demo-app/src/styles.css b/apps/demo-app/src/styles.css index a130ed3..8f42865 100644 --- a/apps/demo-app/src/styles.css +++ b/apps/demo-app/src/styles.css @@ -1,11 +1,15 @@ +@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; } * { @@ -17,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/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..2b6503c 100644 --- a/libs/sketch/src/index.ts +++ b/libs/sketch/src/index.ts @@ -1,2 +1,3 @@ -export * from './lib/components/list'; export * from './lib/components/sketch/sketch.component'; +export * from './lib/components/select'; +export * from './lib/components/list'; 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 new file mode 100644 index 0000000..b562e39 --- /dev/null +++ b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.css @@ -0,0 +1,21 @@ +:host { + --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; + background: rgb(255 255 255 / 60%); +} diff --git a/libs/sketch/src/lib/components/select/components/select-option/select-option.component.html b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.html @@ -0,0 +1 @@ + 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 new file mode 100644 index 0000000..799fda9 --- /dev/null +++ b/libs/sketch/src/lib/components/select/components/select-option/select-option.component.ts @@ -0,0 +1,62 @@ +import { + Component, + effect, + HostBinding, + HostListener, + inject, + input, + untracked, +} 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); + private tabIndexValue = 1; + private selected = false; + value = input.required(); + tabIndex = input(); + + @HostBinding('tabindex') + get tabindex(): number { + return this.tabIndexValue; + } + + @HostBinding('class.sk-selected') + get selectedClass(): boolean { + return this.selected; + } + + @HostListener('keydown.space', ['$event']) + @HostListener('keydown.enter', ['$event']) + @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 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; + } + }); + + // 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 new file mode 100644 index 0000000..8db97e3 --- /dev/null +++ b/libs/sketch/src/lib/components/select/directives/overlay.directive.ts @@ -0,0 +1,115 @@ +import { + Directive, + effect, + ElementRef, + inject, + input, + output, +} from '@angular/core'; +import { CdkPortal } from '@angular/cdk/portal'; +import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { DOCUMENT } from '@angular/common'; +import { debounceTime, fromEvent, merge } from 'rxjs'; +import { toSignal } from '@angular/core/rxjs-interop'; + +export const DEFAULT_POSITIONS: ConnectedPosition[] = [ + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }, +]; + +@Directive({ + selector: '[skCdkOverlay]', + standalone: true, +}) +export class CdkOverlayDirective { + private readonly overlay = inject(Overlay); + private readonly elementRef = inject(ElementRef); + private readonly window = inject(DOCUMENT)?.defaultView; + + private readonly windowResize = this.window + ? toSignal(fromEvent(this.window, 'resize').pipe(debounceTime(500))) + : undefined; + + portal = input(undefined, { alias: 'skCdkOverlay' }); + showOverlay = input(false, { alias: 'skCdkOverlayShow' }); + connectedPositions = input(DEFAULT_POSITIONS, { + alias: 'skCdkOverlayPositions', + }); + disposeDelay = input(0, { alias: 'skCdkOverlayDisposeDelay' }); + relativeTo = input(undefined, { + alias: 'skCdkOverlayRelativeTo', + }); + backdropClass = input('cdk-overlay-transparent-backdrop', { + alias: 'skCdkOverlayBackdropClass', + }); + panelClass = input('cdk-overlay-panel', { + alias: 'skCdkOverlayPanelClass', + }); + offsetX = input(0, { + alias: 'skCdkOverlayOffsetX', + }); + offsetY = input(0, { + alias: 'skCdkOverlayOffsetY', + }); + visible = output({ alias: 'skCdkOverlayVisible' }); + + private _overlayRef?: OverlayRef; + private _relatedElement?: HTMLElement = + this.relativeTo() || this.elementRef.nativeElement; + + protected readonly detectVisibleChange = effect(() => { + if (this._relatedElement) { + if (this.showOverlay()) { + this.createOverlay(); + } else { + this.hide(); + } + } + }); + + protected readonly updateOverlayPortal = effect(() => { + if (this.windowResize && this.windowResize()) { + this.syncOverlayWidth(); + } + }); + + private createOverlay(): void { + if (!this._relatedElement) { + return; + } + + const positionStrategy = this.overlay + .position() + .flexibleConnectedTo(this._relatedElement) + .withPositions(this.connectedPositions()) + .withPush(true) + .withDefaultOffsetX(this.offsetX()) + .withDefaultOffsetY(this.offsetY()) + .withFlexibleDimensions(false); + + this._overlayRef = this.overlay.create({ + hasBackdrop: true, + backdropClass: this.backdropClass(), + panelClass: this.panelClass(), + positionStrategy, + }); + + this.syncOverlayWidth(); + + this._overlayRef.attach(this.portal()); + merge( + this._overlayRef.backdropClick(), + this._overlayRef.detachments() + ).subscribe(() => this.hide()); + } + + 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 new file mode 100644 index 0000000..ad1b0c7 --- /dev/null +++ b/libs/sketch/src/lib/components/select/index.ts @@ -0,0 +1,3 @@ +export * from './components/select-option/select-option.component'; +export * from './select.component'; +export * from './directives/overlay.directive'; diff --git a/libs/sketch/src/lib/components/select/select.component.css b/libs/sketch/src/lib/components/select/select.component.css new file mode 100644 index 0000000..694f1b0 --- /dev/null +++ b/libs/sketch/src/lib/components/select/select.component.css @@ -0,0 +1,22 @@ +: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); + cursor: pointer; +} + +.sk-select-trigger:focus { + outline: none; + 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 new file mode 100644 index 0000000..01b67ce --- /dev/null +++ b/libs/sketch/src/lib/components/select/select.component.html @@ -0,0 +1,32 @@ +
+ @if (showPlaceholder()) { + + } @else { + + } +
+ + + +
+ +
+
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 new file mode 100644 index 0000000..9d2308f --- /dev/null +++ b/libs/sketch/src/lib/components/select/select.component.ts @@ -0,0 +1,148 @@ +import { + booleanAttribute, + ChangeDetectorRef, + Component, + computed, + effect, + forwardRef, + HostListener, + inject, + input, + output, + signal, + untracked, + ViewEncapsulation, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CdkOverlayDirective } from './directives/overlay.directive'; +import { CdkPortal } from '@angular/cdk/portal'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { CdkTrapFocus } from '@angular/cdk/a11y'; + +@Component({ + selector: 'sk-select', + standalone: true, + imports: [CommonModule, CdkOverlayDirective, CdkPortal, CdkTrapFocus], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectComponent), + multi: true, + }, + ], + templateUrl: './select.component.html', + styleUrl: './select.component.css', + encapsulation: ViewEncapsulation.ShadowDom, +}) +export class SelectComponent implements ControlValueAccessor { + private readonly changeDetectorRef = inject(ChangeDetectorRef); + + animationDelay = input(0); + closeOnSelect = input(false); + panelOffsetX = input(0); + panelOffsetY = input(0); + multiple = input(false, { transform: booleanAttribute }); + + readonly autoFocus = signal(true); + readonly selectedValue = signal(undefined); + readonly panelIsVisible = signal(false); + readonly showPlaceholder = computed(() => { + const selectedValue = this.selectedValue(); + const isArray = Array.isArray(selectedValue); + return ( + (!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); + if (!this.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 } + ); + + togglePanel(visible: boolean): void { + this.panelIsVisible.set(visible); + this.open.emit(visible); + } + + selectionChanged(value: T): void { + if (this.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( + this.selectedValue() === value ? undefined : value + ); + } + + this.onChange?.(this.selectedValue()); + this.onTouched?.(); + + if (this.closeOnSelect()) { + this.togglePanel(false); + } + } + + keyArrowUp({ target }: Event): void { + if (target instanceof HTMLElement) { + if (target.previousElementSibling instanceof HTMLElement) { + target.previousElementSibling.focus(); + } + } + } + + keyArrowDown({ target }: Event): void { + if (target instanceof HTMLElement) { + if (target.nextElementSibling instanceof HTMLElement) { + target.nextElementSibling.focus(); + } + } + } + + 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; + } + + private onChange?: (value: T | T[] | undefined) => void; + private onTouched?: () => void; +} diff --git a/package-lock.json b/package-lock.json index 902dd9c..eaed249 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", @@ -17,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", @@ -2044,6 +2048,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", @@ -5743,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 432e4f9..f2fcaf6 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", @@ -20,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",