-
Notifications
You must be signed in to change notification settings - Fork 6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(ui-kit): ported toggletip module from Hanami #83
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,5 +3,6 @@ | |
"dest": "../../dist/libs/ui-kit", | ||
"lib": { | ||
"entryFile": "src/index.ts" | ||
} | ||
}, | ||
"assets": ["styles/**/*.scss"] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
html { | ||
font-size: var(--cz-font-size); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
:root { | ||
--cz-color-white: #ffffff; | ||
--cz-font-size: 1rem; | ||
--cz-font-color-high: #1f1f1f; | ||
--cz-font-color-medium: #505050; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export * from './toggletip-trigger-for.directive'; | ||
export * from './toggletip.component'; | ||
export * from './toggletip.module'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"lib": { | ||
"entryFile": "index.ts" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
import { Overlay, OverlayRef } from '@angular/cdk/overlay'; | ||
import { TemplatePortal } from '@angular/cdk/portal'; | ||
import { | ||
Directive, | ||
ElementRef, | ||
HostBinding, | ||
HostListener, | ||
inject, | ||
Injector, | ||
Input, | ||
NgZone, | ||
OnDestroy, | ||
ViewContainerRef, | ||
} from '@angular/core'; | ||
import { OnDestroy$ } from '@cognizone/ng-core'; | ||
import { filter, first, fromEvent } from 'rxjs'; | ||
|
||
import { ToggletipComponent } from './toggletip.component'; | ||
|
||
@Directive({ | ||
selector: '[czToggletipTriggerFor]', | ||
exportAs: 'czToggletipTriggerFor', | ||
standalone: true, | ||
}) | ||
export class ToggletipTriggerForDirective extends OnDestroy$ implements OnDestroy { | ||
@Input('czToggletipTriggerFor') | ||
toggletip?: ToggletipComponent; | ||
|
||
@HostBinding('attr.aria-expanded') | ||
opened = false; | ||
|
||
@HostBinding('attr.aria-haspopup') | ||
hasPopup = true; | ||
|
||
@HostBinding('attr.aria-controls') | ||
get ariaControls(): string | undefined { | ||
return this.opened ? this.toggletip?.dialogId : undefined; | ||
} | ||
|
||
private injector = inject(Injector); | ||
private viewContainerRef = inject(ViewContainerRef); | ||
private overlay = inject(Overlay); | ||
private elRef = inject(ElementRef); | ||
private ngZone = inject(NgZone); | ||
private overlayRef?: OverlayRef; | ||
private portal?: TemplatePortal; | ||
|
||
private processingClick = false; | ||
|
||
@HostListener('click') | ||
async onClick(): Promise<void> { | ||
if (this.processingClick) return; | ||
this.processingClick = true; | ||
if (!this.toggletip) return; | ||
|
||
const boundingBox = this.elRef.nativeElement.getBoundingClientRect(); | ||
const positionClass = boundingBox.left < window.innerWidth / 2 ? 'is-left' : 'is-right'; | ||
|
||
if (!this.overlayRef) { | ||
const positionStrategy = this.overlay | ||
.position() | ||
.flexibleConnectedTo(this.elRef) | ||
.withPositions([ | ||
{ | ||
originX: positionClass === 'is-left' ? 'end' : 'start', | ||
originY: 'center', | ||
overlayX: positionClass === 'is-left' ? 'start' : 'end', | ||
overlayY: 'center', | ||
}, | ||
]) | ||
.withDefaultOffsetX(positionClass === 'is-left' ? 8 : -8); | ||
|
||
this.overlayRef = this.overlay.create({ | ||
positionStrategy, | ||
scrollStrategy: this.overlay.scrollStrategies.reposition(), | ||
panelClass: positionClass, | ||
}); | ||
} else { | ||
this.overlayRef.detach(); | ||
await new Promise(resolve => setTimeout(resolve, 50)); | ||
} | ||
|
||
this.portal = new TemplatePortal(this.toggletip.tpl, this.viewContainerRef, undefined, this.injector); | ||
this.subSink = this.toggletip.toggletipClose.subscribe(() => this.close()); | ||
this.overlayRef.attach(this.portal); | ||
this.initInteractions(); | ||
this.subSink = this.ngZone.onStable.pipe(first()).subscribe(() => { | ||
this.overlayRef?.overlayElement.querySelector('button')?.focus(); | ||
}); | ||
this.opened = true; | ||
this.processingClick = false; | ||
} | ||
|
||
@HostListener('window:keydown.escape') | ||
close(): void { | ||
if (!this.overlayRef?.hasAttached()) return; | ||
this.emptySink(); | ||
const hasFocus = !!this.overlayRef.overlayElement.querySelector('*:focus-within'); | ||
if (hasFocus) { | ||
this.elRef.nativeElement.focus(); | ||
} | ||
this.overlayRef.detach(); | ||
this.opened = false; | ||
} | ||
|
||
override ngOnDestroy(): void { | ||
super.ngOnDestroy(); | ||
this.close(); | ||
} | ||
|
||
private initInteractions(): void { | ||
const overlayEl = this.overlayRef?.overlayElement; | ||
if (!overlayEl) return; | ||
|
||
this.subSink = fromEvent<KeyboardEvent>(overlayEl, 'keydown') | ||
.pipe(filter(e => e.key === 'Tab')) | ||
.subscribe(event => { | ||
const list = this.getAllFocusableElements(overlayEl); | ||
const target = event.getModifierState('Shift') ? list.item(0) : list.item(list.length - 1); | ||
if (event.target === target) { | ||
event.preventDefault(); | ||
this.close(); | ||
} | ||
}); | ||
} | ||
|
||
getAllFocusableElements<T extends Element = Element>(parent: HTMLElement): NodeListOf<T> { | ||
return parent.querySelectorAll( | ||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled]), details:not([disabled]), summary:not(:disabled)' | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
cz-toggletip { | ||
display: none; | ||
} | ||
|
||
.cz-toggletip-content { | ||
--bg-color: var(--cz-color-white); | ||
--font-size: 0.875rem; | ||
position: relative; | ||
max-width: 23rem; | ||
padding: 1.5rem; | ||
background-color: var(--bg-color); | ||
box-shadow: 0 2px 8px 0 rgba(40, 41, 61, 0.04), 0 16px 24px 0 rgba(96, 97, 112, 0.16); | ||
font-size: var(--font-size); | ||
|
||
.cz-toggletip-close { | ||
position: absolute; | ||
top: 4px; | ||
right: 4px; | ||
|
||
height: 24px; | ||
width: 24px; | ||
border-radius: 4px; | ||
.mat-button-wrapper { | ||
display: flex; | ||
align-items: center; | ||
justify-content: center; | ||
} | ||
mat-icon.mat-icon { | ||
font-size: 16px; | ||
width: 16px; | ||
height: 16px; | ||
line-height: 16px; | ||
color: var(--cz-font-color-high); | ||
} | ||
} | ||
|
||
&::after { | ||
top: 50%; | ||
transform: translate(0, -50%); | ||
position: absolute; | ||
content: ''; | ||
width: 0; | ||
height: 0; | ||
border-top: 5px solid transparent; | ||
border-bottom: 5px solid transparent; | ||
} | ||
} | ||
|
||
.is-left .cz-toggletip-content::after { | ||
right: 100%; | ||
border-right: 7px solid var(--bg-color); | ||
border-left: 5px solid transparent; | ||
} | ||
|
||
.is-right .cz-toggletip-content::after { | ||
left: 100%; | ||
border-left: 7px solid var(--bg-color); | ||
border-right: 5px solid transparent; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import { ChangeDetectionStrategy, Component, EventEmitter, Output, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; | ||
import { MatIconModule } from '@angular/material/icon'; | ||
|
||
import { MatButtonModule } from '@angular/material/button'; | ||
|
||
let instanceId = 0; | ||
|
||
@Component({ | ||
selector: 'cz-toggletip', | ||
template: ` | ||
<ng-template #tpl> | ||
<div [id]="dialogId" class="cz-toggletip-content" role="dialog" tabindex="-1"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the [id] can come after class/role etc :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Id should alwasy be (almost) first, even if it is dynamic :) https://www.notion.so/cognizone/Angular-c8f02176425644c093b1f7c33bcccae9?pvs=4#b48d5c9b77584d769b9947da69a97703 |
||
<button class="cz-toggletip-close" mat-icon-button (click)="toggletipClose.emit()"> | ||
<mat-icon>close</mat-icon> | ||
</button> | ||
<ng-content></ng-content> | ||
</div> | ||
</ng-template> | ||
`, | ||
styleUrls: ['./toggletip.component.scss'], | ||
encapsulation: ViewEncapsulation.None, | ||
changeDetection: ChangeDetectionStrategy.OnPush, | ||
standalone: true, | ||
imports: [MatButtonModule, MatIconModule], | ||
}) | ||
export class ToggletipComponent { | ||
@ViewChild('tpl', { static: true }) | ||
tpl!: TemplateRef<unknown>; | ||
|
||
dialogId = `cz-toggletip-${instanceId++}`; | ||
|
||
@Output() | ||
toggletipClose = new EventEmitter<void>(); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { NgModule } from '@angular/core'; | ||
|
||
import { ToggletipTriggerForDirective } from './toggletip-trigger-for.directive'; | ||
import { ToggletipComponent } from './toggletip.component'; | ||
|
||
@NgModule({ | ||
imports: [ToggletipTriggerForDirective, ToggletipComponent], | ||
exports: [ToggletipTriggerForDirective, ToggletipComponent], | ||
}) | ||
export class ToggletipModule {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oui :D can't we have this in px? (since we want to use px for width at this moment 🤔 )
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here it's one of the cases where width in rem makes more sense -> if the user sets a high font size, it's better to leave it some more breathing room, otherwise with a width in px it will feel cramped 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok, good to know :)