From abf291bdca6254fb7a0ed9aaa17a8e0e75b2daaf Mon Sep 17 00:00:00 2001 From: Alexander Ciesielski Date: Sat, 6 Apr 2024 10:44:39 +0200 Subject: [PATCH] refactor(select): decouple hlm from brn --- libs/ui/select/brain/src/index.ts | 18 +-- ...ent.ts => brn-select-content.directive.ts} | 101 +++--------- .../lib/brn-select-scroll-down.directive.ts | 6 +- .../src/lib/brn-select-scroll-up.directive.ts | 6 +- ...onent.ts => brn-select-value.directive.ts} | 29 +--- .../brain/src/lib/brn-select.directive.ts | 147 ++++++++++++++++++ libs/ui/select/helm/src/index.ts | 16 +- .../src/lib/hlm-select-content.component.ts | 139 +++++++++++++++++ .../src/lib/hlm-select-content.directive.ts | 34 ---- .../src/lib/hlm-select-label.directive.ts | 4 +- .../src/lib/hlm-select-value.directive.ts | 38 ++++- .../src/lib/hlm-select.component.ts} | 82 +++------- .../helm/src/lib/hlm-select.directive.ts | 16 -- libs/ui/select/select.stories.ts | 18 +-- 14 files changed, 406 insertions(+), 248 deletions(-) rename libs/ui/select/brain/src/lib/{brn-select-content.component.ts => brn-select-content.directive.ts} (51%) rename libs/ui/select/brain/src/lib/{brn-select-value.component.ts => brn-select-value.directive.ts} (50%) create mode 100644 libs/ui/select/brain/src/lib/brn-select.directive.ts create mode 100644 libs/ui/select/helm/src/lib/hlm-select-content.component.ts delete mode 100644 libs/ui/select/helm/src/lib/hlm-select-content.directive.ts rename libs/ui/select/{brain/src/lib/brn-select.component.ts => helm/src/lib/hlm-select.component.ts} (77%) delete mode 100644 libs/ui/select/helm/src/lib/hlm-select.directive.ts diff --git a/libs/ui/select/brain/src/index.ts b/libs/ui/select/brain/src/index.ts index ac732073b..242168e4e 100644 --- a/libs/ui/select/brain/src/index.ts +++ b/libs/ui/select/brain/src/index.ts @@ -1,31 +1,31 @@ import { NgModule } from '@angular/core'; -import { BrnSelectContentComponent } from './lib/brn-select-content.component'; +import { BrnSelectContentDirective } from './lib/brn-select-content.directive'; import { BrnSelectGroupDirective } from './lib/brn-select-group.directive'; import { BrnSelectLabelDirective } from './lib/brn-select-label.directive'; import { BrnSelectOptionDirective } from './lib/brn-select-option.directive'; import { BrnSelectScrollDownDirective } from './lib/brn-select-scroll-down.directive'; import { BrnSelectScrollUpDirective } from './lib/brn-select-scroll-up.directive'; import { BrnSelectTriggerDirective } from './lib/brn-select-trigger.directive'; -import { BrnSelectValueComponent } from './lib/brn-select-value.component'; -import { BrnSelectComponent } from './lib/brn-select.component'; +import { BrnSelectValueDirective } from './lib/brn-select-value.directive'; +import { BrnSelectDirective } from './lib/brn-select.directive'; -export * from './lib/brn-select-content.component'; +export * from './lib/brn-select-content.directive'; export * from './lib/brn-select-group.directive'; export * from './lib/brn-select-label.directive'; export * from './lib/brn-select-option.directive'; export * from './lib/brn-select-scroll-down.directive'; export * from './lib/brn-select-scroll-up.directive'; export * from './lib/brn-select-trigger.directive'; -export * from './lib/brn-select-value.component'; -export * from './lib/brn-select.component'; +export * from './lib/brn-select-value.directive'; +export * from './lib/brn-select.directive'; export * from './lib/brn-select.service'; export const BrnSelectImports = [ - BrnSelectComponent, - BrnSelectContentComponent, + BrnSelectDirective, + BrnSelectContentDirective, BrnSelectTriggerDirective, BrnSelectOptionDirective, - BrnSelectValueComponent, + BrnSelectValueDirective, BrnSelectScrollDownDirective, BrnSelectScrollUpDirective, BrnSelectGroupDirective, diff --git a/libs/ui/select/brain/src/lib/brn-select-content.component.ts b/libs/ui/select/brain/src/lib/brn-select-content.directive.ts similarity index 51% rename from libs/ui/select/brain/src/lib/brn-select-content.component.ts rename to libs/ui/select/brain/src/lib/brn-select-content.directive.ts index 494957d18..42be7a67a 100644 --- a/libs/ui/select/brain/src/lib/brn-select-content.component.ts +++ b/libs/ui/select/brain/src/lib/brn-select-content.directive.ts @@ -1,101 +1,43 @@ import { CdkListbox, ListboxValueChangeEvent } from '@angular/cdk/listbox'; -import { NgTemplateOutlet } from '@angular/common'; import { AfterViewInit, - ChangeDetectionStrategy, - Component, ContentChild, ContentChildren, DestroyRef, + Directive, ElementRef, QueryList, - ViewChild, effect, inject, signal, } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { filter, firstValueFrom } from 'rxjs'; import { BrnSelectOptionDirective } from './brn-select-option.directive'; import { BrnSelectScrollDownDirective } from './brn-select-scroll-down.directive'; import { BrnSelectScrollUpDirective } from './brn-select-scroll-up.directive'; import { BrnSelectService } from './brn-select.service'; -@Component({ - selector: 'brn-select-content, hlm-select-content:not(noHlm)', +@Directive({ + selector: '[brnSelectContent]', standalone: true, - imports: [BrnSelectScrollUpDirective, BrnSelectScrollDownDirective, NgTemplateOutlet], hostDirectives: [CdkListbox], - changeDetection: ChangeDetectionStrategy.OnPush, - host: { - '[attr.aria-labelledBy]': 'labelledBy()', - '[attr.aria-controlledBy]': "id() +'--trigger'", - '[id]': "id() + '--content'", - '[attr.dir]': '_selectService.dir()', - }, - styles: [ - ` - :host { - display: flex; - box-sizing: border-box; - flex-direction: column; - outline: none; - pointer-events: auto; - } - - [data-brn-select-viewport] { - scrollbar-width: none; - -ms-overflow-style: none; - -webkit-overflow-scrolling: touch; - } - - [data-brn-select-viewport]::-webkit-scrollbar { - display: none; - } - `, - ], - template: ` - - - - - -
- -
- - - - - - `, }) -export class BrnSelectContentComponent implements AfterViewInit { +export class BrnSelectContentDirective implements AfterViewInit { private readonly _el: ElementRef = inject(ElementRef); private readonly _cdkListbox = inject(CdkListbox, { host: true }); private readonly destroyRef = inject(DestroyRef); protected readonly _selectService = inject(BrnSelectService); - protected readonly labelledBy = this._selectService.labelId; - protected readonly id = this._selectService.id; - protected readonly canScrollUp = signal(false); - protected readonly canScrollDown = signal(false); + readonly labelledBy = this._selectService.labelId; + readonly id = this._selectService.id; + readonly canScrollUp = signal(false); + readonly canScrollDown = signal(false); + readonly viewport = signal | undefined>(undefined); + readonly viewport$ = toObservable(this.viewport); protected initialSelectedOptions$ = toObservable(this._selectService.initialSelectedOptions); - @ViewChild('viewport') - protected viewport!: ElementRef; - @ContentChild(BrnSelectScrollUpDirective, { static: false }) protected scrollUpBtn!: BrnSelectScrollUpDirective; @@ -140,10 +82,11 @@ export class BrnSelectContentComponent implements AfterViewInit { }); } - public updateArrowDisplay(): void { - this.canScrollUp.set(this.viewport.nativeElement.scrollTop > 0); - const maxScroll = this.viewport.nativeElement.scrollHeight - this.viewport.nativeElement.clientHeight; - this.canScrollDown.set(Math.ceil(this.viewport.nativeElement.scrollTop) < maxScroll); + public async updateArrowDisplay() { + const viewport = await firstValueFrom(this.viewport$.pipe(filter(Boolean))); + this.canScrollUp.set(viewport.nativeElement.scrollTop > 0); + const maxScroll = viewport.nativeElement.scrollHeight - viewport.nativeElement.clientHeight; + this.canScrollDown.set(Math.ceil(viewport.nativeElement.scrollTop) < maxScroll); } public handleScroll() { @@ -155,17 +98,19 @@ export class BrnSelectContentComponent implements AfterViewInit { } public moveFocusUp() { - this.viewport.nativeElement.scrollBy({ top: -100, behavior: 'smooth' }); - if (this.viewport.nativeElement.scrollTop === 0) { + const viewport = this.viewport()!; + viewport.nativeElement.scrollBy({ top: -100, behavior: 'smooth' }); + if (viewport.nativeElement.scrollTop === 0) { this.scrollUpBtn.stopEmittingEvents(); } } public moveFocusDown() { - this.viewport.nativeElement.scrollBy({ top: 100, behavior: 'smooth' }); + const viewport = this.viewport()!; + viewport.nativeElement.scrollBy({ top: 100, behavior: 'smooth' }); const viewportSize = this._el.nativeElement.scrollHeight; - const viewportScrollPosition = this.viewport.nativeElement.scrollTop; - if (viewportSize + viewportScrollPosition + 100 > this.viewport.nativeElement.scrollHeight + 50) { + const viewportScrollPosition = viewport.nativeElement.scrollTop; + if (viewportSize + viewportScrollPosition + 100 > viewport.nativeElement.scrollHeight + 50) { this.scrollDownBtn.stopEmittingEvents(); } } diff --git a/libs/ui/select/brain/src/lib/brn-select-scroll-down.directive.ts b/libs/ui/select/brain/src/lib/brn-select-scroll-down.directive.ts index 972409927..860cd626b 100644 --- a/libs/ui/select/brain/src/lib/brn-select-scroll-down.directive.ts +++ b/libs/ui/select/brain/src/lib/brn-select-scroll-down.directive.ts @@ -1,10 +1,10 @@ import { DestroyRef, Directive, ElementRef, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Subject, fromEvent, interval, takeUntil } from 'rxjs'; -import { BrnSelectContentComponent } from './brn-select-content.component'; +import { BrnSelectContentDirective } from './brn-select-content.directive'; @Directive({ - selector: '[brnSelectScrollDown], brn-select-scroll-down, hlm-select-scroll-down:not(noHlm)', + selector: '[brnSelectScrollDown], brn-select-scroll-down', standalone: true, host: { 'aria-hidden': 'true', @@ -13,7 +13,7 @@ import { BrnSelectContentComponent } from './brn-select-content.component'; }) export class BrnSelectScrollDownDirective { private readonly _el = inject(ElementRef); - private readonly _selectContent = inject(BrnSelectContentComponent); + private readonly _selectContent = inject(BrnSelectContentDirective); private readonly endReached = new Subject(); private readonly _destroyRef = inject(DestroyRef); diff --git a/libs/ui/select/brain/src/lib/brn-select-scroll-up.directive.ts b/libs/ui/select/brain/src/lib/brn-select-scroll-up.directive.ts index 8dc2175b0..fa86f7d59 100644 --- a/libs/ui/select/brain/src/lib/brn-select-scroll-up.directive.ts +++ b/libs/ui/select/brain/src/lib/brn-select-scroll-up.directive.ts @@ -1,10 +1,10 @@ import { DestroyRef, Directive, ElementRef, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Subject, fromEvent, interval, takeUntil } from 'rxjs'; -import { BrnSelectContentComponent } from './brn-select-content.component'; +import { BrnSelectContentDirective } from './brn-select-content.directive'; @Directive({ - selector: '[brnSelectScrollUp], brn-select-scroll-up, hlm-select-scroll-up:not(noHlm)', + selector: '[brnSelectScrollUp], brn-select-scroll-up', standalone: true, host: { 'aria-hidden': 'true', @@ -13,7 +13,7 @@ import { BrnSelectContentComponent } from './brn-select-content.component'; }) export class BrnSelectScrollUpDirective { private readonly _el = inject(ElementRef); - private readonly _selectContent = inject(BrnSelectContentComponent); + private readonly _selectContent = inject(BrnSelectContentDirective); private readonly endReached = new Subject(); private readonly _destroyRef = inject(DestroyRef); diff --git a/libs/ui/select/brain/src/lib/brn-select-value.component.ts b/libs/ui/select/brain/src/lib/brn-select-value.directive.ts similarity index 50% rename from libs/ui/select/brain/src/lib/brn-select-value.component.ts rename to libs/ui/select/brain/src/lib/brn-select-value.directive.ts index 4628b6670..a81d45c54 100644 --- a/libs/ui/select/brain/src/lib/brn-select-value.component.ts +++ b/libs/ui/select/brain/src/lib/brn-select-value.directive.ts @@ -1,29 +1,11 @@ -import { ChangeDetectionStrategy, Component, computed, inject, Input } from '@angular/core'; +import { computed, Directive, inject, Input } from '@angular/core'; import { BrnSelectService } from './brn-select.service'; -@Component({ - selector: 'brn-select-value, hlm-select-value', - template: ` - {{ value() || placeholder() }} - `, - host: { - '[id]': 'id()', - }, - styles: [ - ` - :host { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; - white-space: nowrap; - pointer-events: none; - } - `, - ], +@Directive({ + selector: '[brnSelectValue]', standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BrnSelectValueComponent { +export class BrnSelectValueDirective { private readonly _selectService = inject(BrnSelectService); public readonly id = computed(() => `${this._selectService.id()}--value`); @@ -43,6 +25,5 @@ export class BrnSelectValueComponent { }); @Input() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - transformFn: (values: (string | undefined)[]) => any = (values) => (values ?? []).join(', '); + transformFn: (values: (string | undefined)[]) => string = (values) => (values ?? []).join(', '); } diff --git a/libs/ui/select/brain/src/lib/brn-select.directive.ts b/libs/ui/select/brain/src/lib/brn-select.directive.ts new file mode 100644 index 000000000..164bcae1e --- /dev/null +++ b/libs/ui/select/brain/src/lib/brn-select.directive.ts @@ -0,0 +1,147 @@ +import { ConnectedOverlayPositionChange, ConnectedPosition } from '@angular/cdk/overlay'; +import { Directive, EventEmitter, Input, Output, Signal, computed, inject, input, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { Subject, map, skip } from 'rxjs'; +import { BrnSelectService } from './brn-select.service'; + +export type BrnReadDirection = 'ltr' | 'rtl'; + +let nextId = 0; + +@Directive({ + selector: '[brnSelect]', + standalone: true, +}) +export class BrnSelectDirective implements ControlValueAccessor { + private readonly _selectService = inject(BrnSelectService); + + public readonly triggerWidth = this._selectService.triggerWidth; + + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input({ alias: 'multiple' }) + set multiple(multiple: boolean) { + this._selectService.state.update((state) => ({ ...state, multiple })); + } + protected readonly _multiple = this._selectService.multiple; + + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input({ alias: 'placeholder' }) + set placeholder(placeholder: string) { + this._selectService.state.update((state) => ({ ...state, placeholder })); + } + protected readonly _placeholder = this._selectService.placeholder; + + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input({ alias: 'disabled' }) + set disabled(disabled: boolean) { + this._selectService.state.update((state) => ({ ...state, disabled })); + } + protected readonly _disabled = this._selectService.disabled; + + public readonly dir = input('ltr'); + + @Output() + openedChange = new EventEmitter(); + + protected readonly _positionChanges$ = new Subject(); + public readonly side: Signal<'top' | 'bottom' | 'left' | 'right'> = toSignal( + this._positionChanges$.pipe( + map((change) => + // todo: better translation or adjusting hlm to take that into account + change.connectionPair.originY === 'center' + ? change.connectionPair.originX === 'start' + ? 'left' + : 'right' + : change.connectionPair.originY, + ), + ), + { initialValue: 'bottom' }, + ); + + public readonly backupLabelId = computed(() => this._selectService.labelId()); + public readonly labelProvided = signal(false); + + public readonly ngControl = inject(NgControl, { optional: true, self: true }); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private _onChange: (value: unknown) => void = () => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function + private _onTouched = () => {}; + + /* + * This position config ensures that the top "start" corner of the overlay + * is aligned with with the top "start" of the origin by default (overlapping + * the trigger completely). If the panel cannot fit below the trigger, it + * will fall back to a position above the trigger. + */ + protected _positions: ConnectedPosition[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + }, + { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + }, + ]; + + constructor() { + this._selectService.state.update((state) => ({ + ...state, + id: `brn-select-${nextId++}`, + })); + if (this.ngControl != null) { + this.ngControl.valueAccessor = this; + } + + toObservable(this._selectService.value) + // skipping first else ngcontrol always starts off as dirty and triggering value change on init value + .pipe(takeUntilDestroyed(), skip(1)) + .subscribe((value) => this._onChange(value || null)); + + toObservable(this.dir) + .pipe(takeUntilDestroyed()) + .subscribe(() => + this._selectService.state.update((state) => ({ + ...state, + dir: this.dir(), + })), + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public writeValue(value: any): void { + this._selectService.setInitialSelectedOptions(value); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public registerOnChange(fn: any): void { + this._onChange = fn; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public registerOnTouched(fn: any): void { + this._onTouched = fn; + } + + public setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + } +} diff --git a/libs/ui/select/helm/src/index.ts b/libs/ui/select/helm/src/index.ts index 1d9260ba0..529ff6d1a 100644 --- a/libs/ui/select/helm/src/index.ts +++ b/libs/ui/select/helm/src/index.ts @@ -1,15 +1,15 @@ import { NgModule } from '@angular/core'; -import { HlmSelectContentDirective } from './lib/hlm-select-content.directive'; +import { HlmSelectContentComponent } from './lib/hlm-select-content.component'; import { HlmSelectGroupDirective } from './lib/hlm-select-group.directive'; import { HlmSelectLabelDirective } from './lib/hlm-select-label.directive'; import { HlmSelectOptionComponent } from './lib/hlm-select-option.component'; import { HlmSelectScrollDownComponent } from './lib/hlm-select-scroll-down.component'; import { HlmSelectScrollUpComponent } from './lib/hlm-select-scroll-up.component'; import { HlmSelectTriggerComponent } from './lib/hlm-select-trigger.component'; -import { HlmSelectValueDirective } from './lib/hlm-select-value.directive'; -import { HlmSelectDirective } from './lib/hlm-select.directive'; +import { HlmSelectValueComponent } from './lib/hlm-select-value.directive'; +import { HlmSelectComponent } from './lib/hlm-select.component'; -export * from './lib/hlm-select-content.directive'; +export * from './lib/hlm-select-content.component'; export * from './lib/hlm-select-group.directive'; export * from './lib/hlm-select-label.directive'; export * from './lib/hlm-select-option.component'; @@ -17,14 +17,14 @@ export * from './lib/hlm-select-scroll-down.component'; export * from './lib/hlm-select-scroll-up.component'; export * from './lib/hlm-select-trigger.component'; export * from './lib/hlm-select-value.directive'; -export * from './lib/hlm-select.directive'; +export * from './lib/hlm-select.component'; export const HlmSelectImports = [ - HlmSelectContentDirective, + HlmSelectContentComponent, HlmSelectTriggerComponent, HlmSelectOptionComponent, - HlmSelectValueDirective, - HlmSelectDirective, + HlmSelectValueComponent, + HlmSelectComponent, HlmSelectScrollUpComponent, HlmSelectScrollDownComponent, HlmSelectLabelDirective, diff --git a/libs/ui/select/helm/src/lib/hlm-select-content.component.ts b/libs/ui/select/helm/src/lib/hlm-select-content.component.ts new file mode 100644 index 000000000..578555b80 --- /dev/null +++ b/libs/ui/select/helm/src/lib/hlm-select-content.component.ts @@ -0,0 +1,139 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + ContentChild, + effect, + ElementRef, + inject, + input, + Input, + signal, + viewChild, +} from '@angular/core'; +import { hlm, injectExposedSideProvider, injectExposesStateProvider } from '@spartan-ng/ui-core'; +import { + BrnSelectContentDirective, + BrnSelectScrollDownDirective, + BrnSelectScrollUpDirective, + BrnSelectService, +} from '@spartan-ng/ui-select-brain'; +import { ClassValue } from 'clsx'; + +@Component({ + selector: '[hlmSelectContent], hlm-select-content', + standalone: true, + imports: [BrnSelectScrollUpDirective, BrnSelectScrollDownDirective, NgTemplateOutlet], + hostDirectives: [BrnSelectContentDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[class]': '_computedClass()', + '[attr.data-state]': '_stateProvider?.state() ?? "open"', + '[attr.data-side]': '_sideProvider?.side() ?? "bottom"', + '[attr.aria-labelledBy]': 'labelledBy()', + '[attr.aria-controlledBy]': "id() +'--trigger'", + '[id]': "id() + '--content'", + '[attr.dir]': '_selectService.dir()', + }, + styles: [ + ` + :host { + display: flex; + box-sizing: border-box; + flex-direction: column; + outline: none; + pointer-events: auto; + } + + [data-brn-select-viewport] { + scrollbar-width: none; + -ms-overflow-style: none; + -webkit-overflow-scrolling: touch; + } + + [data-brn-select-viewport]::-webkit-scrollbar { + display: none; + } + `, + ], + template: ` + + + + + +
+ +
+ + + + + + `, +}) +export class HlmSelectContentComponent { + constructor() { + effect( + () => { + this.brnSelectContent.viewport.set(this.viewport()); + }, + { + // TODO figure out how to do this more elegantly? + allowSignalWrites: true, + }, + ); + } + + private readonly brnSelectContent = inject(BrnSelectContentDirective); + protected readonly _selectService = inject(BrnSelectService); + + protected readonly labelledBy = this.brnSelectContent.labelledBy; + protected readonly id = this.brnSelectContent.id; + protected readonly canScrollUp = this.brnSelectContent.canScrollUp; + protected readonly canScrollDown = this.brnSelectContent.canScrollDown; + + public readonly userClass = input('', { alias: 'class' }); + protected readonly _stateProvider = injectExposesStateProvider({ optional: true }); + protected readonly _sideProvider = injectExposedSideProvider({ optional: true }); + + protected readonly _computedClass = computed(() => + hlm( + 'w-full relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md p-1 data-[side=bottom]:top-[2px] data-[side=top]:bottom-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + this.userClass(), + ), + ); + + private readonly _stickyLabels = signal(false); + @Input() + set stickyLabels(value: boolean) { + this._stickyLabels.set(value); + } + get stickyLabels() { + return this._stickyLabels(); + } + + protected readonly viewport = viewChild>('viewPort'); + + @ContentChild(BrnSelectScrollUpDirective, { static: false }) + protected scrollUpBtn!: BrnSelectScrollUpDirective; + + @ContentChild(BrnSelectScrollDownDirective, { static: false }) + protected scrollDownBtn!: BrnSelectScrollDownDirective; + + public handleScroll() { + this.brnSelectContent.updateArrowDisplay(); + } +} diff --git a/libs/ui/select/helm/src/lib/hlm-select-content.directive.ts b/libs/ui/select/helm/src/lib/hlm-select-content.directive.ts deleted file mode 100644 index ccb25721a..000000000 --- a/libs/ui/select/helm/src/lib/hlm-select-content.directive.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { computed, Directive, input, Input, signal } from '@angular/core'; -import { hlm, injectExposedSideProvider, injectExposesStateProvider } from '@spartan-ng/ui-core'; -import { ClassValue } from 'clsx'; - -@Directive({ - selector: '[hlmSelectContent], hlm-select-content', - standalone: true, - host: { - '[class]': '_computedClass()', - '[attr.data-state]': '_stateProvider?.state() ?? "open"', - '[attr.data-side]': '_sideProvider?.side() ?? "bottom"', - }, -}) -export class HlmSelectContentDirective { - public readonly userClass = input('', { alias: 'class' }); - protected readonly _stateProvider = injectExposesStateProvider({ optional: true }); - protected readonly _sideProvider = injectExposedSideProvider({ optional: true }); - - protected readonly _computedClass = computed(() => - hlm( - 'w-full relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md p-1 data-[side=bottom]:top-[2px] data-[side=top]:bottom-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', - this.userClass(), - ), - ); - - private readonly _stickyLabels = signal(false); - @Input() - set stickyLabels(value: boolean) { - this._stickyLabels.set(value); - } - get stickyLabels() { - return this._stickyLabels(); - } -} diff --git a/libs/ui/select/helm/src/lib/hlm-select-label.directive.ts b/libs/ui/select/helm/src/lib/hlm-select-label.directive.ts index 59f68524c..0abd56474 100644 --- a/libs/ui/select/helm/src/lib/hlm-select-label.directive.ts +++ b/libs/ui/select/helm/src/lib/hlm-select-label.directive.ts @@ -2,7 +2,7 @@ import { computed, Directive, inject, input, OnInit, signal } from '@angular/cor import { hlm } from '@spartan-ng/ui-core'; import { BrnSelectLabelDirective } from '@spartan-ng/ui-select-brain'; import { ClassValue } from 'clsx'; -import { HlmSelectContentDirective } from './hlm-select-content.directive'; +import { HlmSelectContentComponent } from './hlm-select-content.component'; @Directive({ selector: '[hlmSelectLabel], hlm-select-label', @@ -13,7 +13,7 @@ import { HlmSelectContentDirective } from './hlm-select-content.directive'; }, }) export class HlmSelectLabelDirective implements OnInit { - private readonly selectContent = inject(HlmSelectContentDirective); + private readonly selectContent = inject(HlmSelectContentComponent); private readonly _stickyLabels = signal(false); public readonly userClass = input('', { alias: 'class' }); protected _computedClass = computed(() => diff --git a/libs/ui/select/helm/src/lib/hlm-select-value.directive.ts b/libs/ui/select/helm/src/lib/hlm-select-value.directive.ts index c5796a2fa..674221387 100644 --- a/libs/ui/select/helm/src/lib/hlm-select-value.directive.ts +++ b/libs/ui/select/helm/src/lib/hlm-select-value.directive.ts @@ -1,16 +1,40 @@ -import { computed, Directive, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { hlm } from '@spartan-ng/ui-core'; +import { BrnSelectValueDirective } from '@spartan-ng/ui-select-brain'; import { ClassValue } from 'clsx'; -@Directive({ - // eslint-disable-next-line @angular-eslint/component-selector - selector: 'hlm-select-value,[hlmSelectValue], brn-select-value[hlm]', +@Component({ + selector: 'hlm-select-value,[hlmSelectValue]', standalone: true, + hostDirectives: [ + { + directive: BrnSelectValueDirective, + inputs: ['transformFn'], + }, + ], host: { + '[id]': 'id()', '[class]': '_computedClass()', }, + template: ` + {{ value() || placeholder() }} + `, + styles: [ + ` + :host { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + white-space: nowrap; + pointer-events: none; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class HlmSelectValueDirective { +export class HlmSelectValueComponent { + private readonly selectValueDirective = inject(BrnSelectValueDirective); + public readonly userClass = input('', { alias: 'class' }); protected readonly _computedClass = computed(() => hlm( @@ -18,4 +42,8 @@ export class HlmSelectValueDirective { this.userClass(), ), ); + + protected readonly id = this.selectValueDirective.id; + protected readonly placeholder = this.selectValueDirective.placeholder; + protected readonly value = this.selectValueDirective.value; } diff --git a/libs/ui/select/brain/src/lib/brn-select.component.ts b/libs/ui/select/helm/src/lib/hlm-select.component.ts similarity index 77% rename from libs/ui/select/brain/src/lib/brn-select.component.ts rename to libs/ui/select/helm/src/lib/hlm-select.component.ts index ecd97392b..96713be6a 100644 --- a/libs/ui/select/brain/src/lib/brn-select.component.ts +++ b/libs/ui/select/helm/src/lib/hlm-select.component.ts @@ -5,7 +5,6 @@ import { ConnectedPosition, OverlayModule, } from '@angular/cdk/overlay'; -import { JsonPipe } from '@angular/common'; import { AfterContentInit, ChangeDetectionStrategy, @@ -13,7 +12,6 @@ import { ContentChild, ContentChildren, EventEmitter, - Input, Output, QueryList, Signal, @@ -24,7 +22,7 @@ import { signal, } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { NgControl } from '@angular/forms'; import { ExposesSide, ExposesState, @@ -32,26 +30,35 @@ import { provideExposesStateProviderExisting, } from '@spartan-ng/ui-core'; import { BrnLabelDirective } from '@spartan-ng/ui-label-brain'; +import { + BrnReadDirection, + BrnSelectContentDirective, + BrnSelectDirective, + BrnSelectOptionDirective, + BrnSelectService, + BrnSelectTriggerDirective, +} from '@spartan-ng/ui-select-brain'; import { Subject, delay, map, of, skip, switchMap } from 'rxjs'; -import { BrnSelectContentComponent } from './brn-select-content.component'; -import { BrnSelectOptionDirective } from './brn-select-option.directive'; -import { BrnSelectTriggerDirective } from './brn-select-trigger.directive'; -import { BrnSelectService } from './brn-select.service'; - -export type BrnReadDirection = 'ltr' | 'rtl'; let nextId = 0; @Component({ - selector: 'brn-select, hlm-select', + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'hlm-select, brn-select [hlm]', standalone: true, - imports: [OverlayModule, BrnSelectTriggerDirective, CdkListboxModule, JsonPipe], - changeDetection: ChangeDetectionStrategy.OnPush, + imports: [OverlayModule, BrnSelectTriggerDirective, CdkListboxModule], providers: [ BrnSelectService, CdkListbox, - provideExposedSideProviderExisting(() => BrnSelectComponent), - provideExposesStateProviderExisting(() => BrnSelectComponent), + provideExposedSideProviderExisting(() => HlmSelectComponent), + provideExposesStateProviderExisting(() => HlmSelectComponent), + ], + hostDirectives: [ + { + directive: BrnSelectDirective, + inputs: ['multiple', 'placeholder', 'disabled'], + outputs: ['openedChange'], + }, ], template: ` @if (!labelProvided() && _placeholder()) { @@ -79,31 +86,14 @@ let nextId = 0; `, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BrnSelectComponent implements ControlValueAccessor, AfterContentInit, ExposesSide, ExposesState { +export class HlmSelectComponent implements AfterContentInit, ExposesSide, ExposesState { private readonly _selectService = inject(BrnSelectService); - public readonly triggerWidth = this._selectService.triggerWidth; - // eslint-disable-next-line @angular-eslint/no-input-rename - @Input({ alias: 'multiple' }) - set multiple(multiple: boolean) { - this._selectService.state.update((state) => ({ ...state, multiple })); - } protected readonly _multiple = this._selectService.multiple; - - // eslint-disable-next-line @angular-eslint/no-input-rename - @Input({ alias: 'placeholder' }) - set placeholder(placeholder: string) { - this._selectService.state.update((state) => ({ ...state, placeholder })); - } protected readonly _placeholder = this._selectService.placeholder; - - // eslint-disable-next-line @angular-eslint/no-input-rename - @Input({ alias: 'disabled' }) - set disabled(disabled: boolean) { - this._selectService.state.update((state) => ({ ...state, disabled })); - } protected readonly _disabled = this._selectService.disabled; public readonly dir = input('ltr'); @@ -111,8 +101,8 @@ export class BrnSelectComponent implements ControlValueAccessor, AfterContentIni @ContentChild(BrnLabelDirective, { descendants: false }) protected selectLabel!: BrnLabelDirective; /** Overlay pane containing the options. */ - @ContentChild(BrnSelectContentComponent) - protected selectContent!: BrnSelectContentComponent; + @ContentChild(BrnSelectContentDirective) + protected selectContent!: BrnSelectContentDirective; @ContentChildren(BrnSelectOptionDirective, { descendants: true }) protected options!: QueryList; /** Overlay pane containing the options. */ @@ -196,9 +186,6 @@ export class BrnSelectComponent implements ControlValueAccessor, AfterContentIni ...state, id: `brn-select-${nextId++}`, })); - if (this.ngControl != null) { - this.ngControl.valueAccessor = this; - } // Watch for Listbox Selection Changes to trigger Collapse this._selectService.listBoxValueChangeEvent$.pipe(takeUntilDestroyed()).subscribe(() => { @@ -280,23 +267,4 @@ export class BrnSelectComponent implements ControlValueAccessor, AfterContentIni private _moveFocusToCDKList(): void { setTimeout(() => this.selectContent.focusList()); } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public writeValue(value: any): void { - this._selectService.setInitialSelectedOptions(value); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public registerOnChange(fn: any): void { - this._onChange = fn; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public registerOnTouched(fn: any): void { - this._onTouched = fn; - } - - public setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } } diff --git a/libs/ui/select/helm/src/lib/hlm-select.directive.ts b/libs/ui/select/helm/src/lib/hlm-select.directive.ts deleted file mode 100644 index 6a7cc5ee5..000000000 --- a/libs/ui/select/helm/src/lib/hlm-select.directive.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { computed, Directive, input } from '@angular/core'; -import { hlm } from '@spartan-ng/ui-core'; -import { ClassValue } from 'clsx'; - -@Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: 'hlm-select, brn-select [hlm]', - standalone: true, - host: { - '[class]': '_computedClass()', - }, -}) -export class HlmSelectDirective { - public readonly userClass = input('', { alias: 'class' }); - protected readonly _computedClass = computed(() => hlm('space-y-2', this.userClass())); -} diff --git a/libs/ui/select/select.stories.ts b/libs/ui/select/select.stories.ts index 735f14354..863785ea6 100644 --- a/libs/ui/select/select.stories.ts +++ b/libs/ui/select/select.stories.ts @@ -65,9 +65,9 @@ export const ReactiveFormControl: Story = {
Form Control Value: {{ fruitGroup.controls.fruit.valueChanges | async | json }}
- + - + Fruits @@ -78,7 +78,7 @@ export const ReactiveFormControl: Story = { Pineapple Clear - +
`, @@ -98,9 +98,9 @@ export const ReactiveFormControlWithValidation: Story = {
Form Control Value: {{ fruitGroup.controls.fruit.valueChanges | async | json }}
- + - + Fruits @@ -111,7 +111,7 @@ export const ReactiveFormControlWithValidation: Story = { Pineapple Clear - + @if (fruitGroup.controls.fruit.invalid && fruitGroup.controls.fruit.touched){ Required } @@ -136,7 +136,7 @@ export const ReactiveFormControlWithValidationWithLabel: Story = { - + Fruits @@ -175,7 +175,7 @@ export const NgModelFormControl: Story = { > - + Fruits @@ -199,7 +199,7 @@ export const SelectWithLabel: Story = { - + Fruits