mirror of https://github.com/Squidex/squidex.git
13 changed files with 376 additions and 64 deletions
@ -0,0 +1,18 @@ |
|||||
|
<span> |
||||
|
<input type="text" class="form-control" (blur)="blur()" [attr.name]="inputName" (keydown)="keyDown($event)" |
||||
|
[formControl]="queryInput" |
||||
|
autocomplete="off" |
||||
|
autocorrect="off" |
||||
|
autocapitalize="off"> |
||||
|
|
||||
|
<div class="items-container" *ngIf="items.length > 0"> |
||||
|
<div class="items" #container> |
||||
|
<div *ngFor="let item of items; let i = index;" class="item" [class.active]="i === itemSelection" (mousedown)="chooseItem(item)" (mouseover)="selectIndex(i)" [sqxScrollActive]="i === itemSelection" [container]="container"> |
||||
|
<img class="item-image" [attr.src]="item.image" /> |
||||
|
|
||||
|
<span class="item-title">{{item.title}}</span> |
||||
|
<span class="item-description">{{item.description}}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</span> |
||||
@ -0,0 +1,51 @@ |
|||||
|
@import '_mixins'; |
||||
|
@import '_vars'; |
||||
|
|
||||
|
$color-input-border: rgba(0, 0, 0, .15); |
||||
|
|
||||
|
.items { |
||||
|
&-container { |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
& { |
||||
|
@include absolute(2px, auto, auto, 0); |
||||
|
@include border-radius(.25em); |
||||
|
@include box-shadow; |
||||
|
width: 300px; |
||||
|
max-height: 200px; |
||||
|
border: 1px solid $color-input-border; |
||||
|
background: $color-accent-dark; |
||||
|
padding: .3rem 0; |
||||
|
overflow-y: auto; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.item { |
||||
|
& { |
||||
|
padding: .3rem .8rem; |
||||
|
background: transparent; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
&.active { |
||||
|
color: $color-accent-dark; |
||||
|
background: $color-theme-blue; |
||||
|
} |
||||
|
|
||||
|
&-image { |
||||
|
@include circle(40px); |
||||
|
float: left; |
||||
|
} |
||||
|
|
||||
|
&-title, |
||||
|
&-description { |
||||
|
@include truncate; |
||||
|
margin-left: 45px; |
||||
|
} |
||||
|
|
||||
|
&-description { |
||||
|
font-size: .8rem; |
||||
|
font-style: italic; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,208 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Sebastian Stehle. All rights reserved |
||||
|
*/ |
||||
|
|
||||
|
import * as Ng2 from '@angular/core'; |
||||
|
import * as Ng2Forms from '@angular/forms'; |
||||
|
|
||||
|
import { Observable, Subscription } from 'rxjs'; |
||||
|
|
||||
|
export interface AutocompleteSource { |
||||
|
find(query: string): Observable<AutocompleteItem[]>; |
||||
|
} |
||||
|
|
||||
|
export class AutocompleteItem { |
||||
|
constructor( |
||||
|
public readonly title: string, |
||||
|
public readonly description: string, |
||||
|
public readonly image: string, |
||||
|
public readonly model: any |
||||
|
) { |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const KEY_ENTER = 13; |
||||
|
const KEY_UP = 38; |
||||
|
const KEY_DOWN = 40; |
||||
|
|
||||
|
const NOOP = () => { }; |
||||
|
|
||||
|
@Ng2.Component({ |
||||
|
selector: 'sqx-autocomplete', |
||||
|
styles, |
||||
|
template, |
||||
|
providers: [{ |
||||
|
provide: Ng2Forms.NG_VALUE_ACCESSOR, |
||||
|
useExisting: Ng2.forwardRef(() => { |
||||
|
return AutocompleteComponent; |
||||
|
}), |
||||
|
multi: true |
||||
|
}] |
||||
|
}) |
||||
|
export class AutocompleteComponent implements Ng2Forms.ControlValueAccessor { |
||||
|
private subscription: Subscription | null = null; |
||||
|
private lastQuery: string | null; |
||||
|
private changeCallback: (value: any) => void = NOOP; |
||||
|
private touchedCallback: () => void = NOOP; |
||||
|
|
||||
|
@Ng2.Input() |
||||
|
public source: AutocompleteSource; |
||||
|
|
||||
|
@Ng2.Input() |
||||
|
public inputName: string; |
||||
|
|
||||
|
public items: AutocompleteItem[] = []; |
||||
|
public itemSelection = -1; |
||||
|
|
||||
|
public queryInput = new Ng2Forms.FormControl(); |
||||
|
|
||||
|
constructor() { |
||||
|
this.queryInput.valueChanges.delay(100).subscribe(query => this.loadItems(query)); |
||||
|
} |
||||
|
|
||||
|
public writeValue(value: any) { |
||||
|
if (!value) { |
||||
|
this.queryInput.setValue(''); |
||||
|
} else { |
||||
|
let item: AutocompleteItem | null = null; |
||||
|
|
||||
|
if (value instanceof AutocompleteItem) { |
||||
|
item = value; |
||||
|
} else { |
||||
|
item = this.items.find(i => i.model === value); |
||||
|
} |
||||
|
|
||||
|
if (item) { |
||||
|
this.queryInput.setValue(value.title || ''); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
this.reset(); |
||||
|
} |
||||
|
|
||||
|
public registerOnChange(fn: any) { |
||||
|
this.changeCallback = fn; |
||||
|
} |
||||
|
|
||||
|
public registerOnTouched(fn: any) { |
||||
|
this.touchedCallback = fn; |
||||
|
} |
||||
|
|
||||
|
public setDisabledState(isDisabled: boolean): void { |
||||
|
if (isDisabled) { |
||||
|
this.reset(); |
||||
|
this.queryInput.disable(); |
||||
|
} else { |
||||
|
this.queryInput.enable(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private loadItems(query: string) { |
||||
|
const source = this.source; |
||||
|
|
||||
|
if (this.subscription != null) { |
||||
|
this.subscription.unsubscribe(); |
||||
|
this.subscription = null; |
||||
|
} |
||||
|
|
||||
|
if (!source) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
let isInvalidQuery = this.lastQuery === query || !query || query.trim() === ''; |
||||
|
|
||||
|
this.lastQuery = query; |
||||
|
|
||||
|
if (isInvalidQuery) { |
||||
|
this.reset(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.lastQuery = query; |
||||
|
|
||||
|
this.subscription = source.find(query) |
||||
|
.catch(error => { |
||||
|
return Observable.of([]); |
||||
|
}) |
||||
|
.subscribe(result => { |
||||
|
this.reset(); |
||||
|
this.items = result || []; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public keyDown(event: KeyboardEvent) { |
||||
|
switch (event.keyCode) { |
||||
|
case KEY_UP: |
||||
|
this.up(); |
||||
|
|
||||
|
event.stopPropagation(); |
||||
|
event.preventDefault(); |
||||
|
break; |
||||
|
case KEY_DOWN: |
||||
|
this.down(); |
||||
|
|
||||
|
event.stopPropagation(); |
||||
|
event.preventDefault(); |
||||
|
break; |
||||
|
case KEY_ENTER: |
||||
|
if (this.items.length > 0) { |
||||
|
this.chooseItem(); |
||||
|
|
||||
|
event.stopPropagation(); |
||||
|
event.preventDefault(); |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private reset() { |
||||
|
this.items = []; |
||||
|
this.itemSelection = -1; |
||||
|
} |
||||
|
|
||||
|
public blur() { |
||||
|
this.reset(); |
||||
|
} |
||||
|
|
||||
|
public up() { |
||||
|
this.selectIndex(this.itemSelection - 1); |
||||
|
} |
||||
|
|
||||
|
public down() { |
||||
|
this.selectIndex(this.itemSelection + 1); |
||||
|
} |
||||
|
|
||||
|
public chooseItem(selection: AutocompleteItem = null) { |
||||
|
if (!selection) { |
||||
|
selection = this.items[this.itemSelection]; |
||||
|
} |
||||
|
|
||||
|
if (!selection && this.items.length === 1) { |
||||
|
selection = this.items[0]; |
||||
|
} |
||||
|
|
||||
|
if (selection) { |
||||
|
try { |
||||
|
this.queryInput.setValue(selection.title); |
||||
|
this.changeCallback(selection); |
||||
|
} finally { |
||||
|
this.reset(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public selectIndex(selection: number) { |
||||
|
if (selection < 0) { |
||||
|
selection = 0; |
||||
|
} |
||||
|
|
||||
|
if (selection >= this.items.length) { |
||||
|
selection = this.items.length - 1; |
||||
|
} |
||||
|
|
||||
|
this.itemSelection = selection; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,54 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Sebastian Stehle. All rights reserved |
||||
|
*/ |
||||
|
|
||||
|
import * as Ng2 from '@angular/core'; |
||||
|
|
||||
|
@Ng2.Directive({ |
||||
|
selector: '[sqxScrollActive]' |
||||
|
}) |
||||
|
export class ScrollActiveDirective implements Ng2.OnChanges { |
||||
|
@Ng2.Input('sqxScrollActive') |
||||
|
public isActive = false; |
||||
|
|
||||
|
@Ng2.Input() |
||||
|
public container: HTMLElement; |
||||
|
|
||||
|
constructor( |
||||
|
private readonly element: Ng2.ElementRef |
||||
|
) { |
||||
|
} |
||||
|
|
||||
|
public ngOnChanges() { |
||||
|
if (this.isActive && this.container) { |
||||
|
this.scrollInView(this.container, this.element.nativeElement); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private scrollInView(parent: HTMLElement, target: HTMLElement) { |
||||
|
if (!parent.getBoundingClientRect) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const parentRect = parent.getBoundingClientRect(); |
||||
|
const targetRect = target.getBoundingClientRect(); |
||||
|
|
||||
|
const offset = (targetRect.top + document.body.scrollTop) - (parentRect.top + document.body.scrollTop); |
||||
|
|
||||
|
const scroll = parent.scrollTop; |
||||
|
|
||||
|
if (offset < 0) { |
||||
|
parent.scrollTop = scroll + offset; |
||||
|
} else { |
||||
|
const targetHeight = targetRect.height; |
||||
|
const parentHeight = parentRect.height; |
||||
|
|
||||
|
if ((offset + targetHeight) > parentHeight) { |
||||
|
parent.scrollTop = scroll + offset - parentHeight + targetHeight; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue