UNPKG

uicore-ts

Version:

UICore is a library to build native-like user interfaces using pure Typescript. No HTML is needed at all. Components are described as TS classes and all user interactions are handled explicitly. This library is strongly inspired by the UIKit framework tha

288 lines (211 loc) 9.93 kB
// noinspection JSConstantReassignment import { UIAutocompleteItem, UIAutocompleteRowView } from "./UIAutocompleteRowView" import { UIColor } from "./UIColor" import { IS, IS_NOT, NO, YES } from "./UIObject" import { UIRectangle } from "./UIRectangle" import { UITableView } from "./UITableView" import { UIView } from "./UIView" export class UIAutocompleteDropdownView<T> extends UIView { tableView: UITableView _fullHeightView: UIView _filteredItems: UIAutocompleteItem<T>[] = [] _filterWords: string[] = [] _highlightedRowIndex: number = -1 _rowHeight: number = 36 _maxVisibleRows: number = 8 _isPointerInsideDropdown: boolean = NO _suppressHoverHighlight: boolean = NO didSelectItem?: (item: UIAutocompleteItem<T>) => void anchorView?: UIView constructor(elementID?: string) { super(elementID) this.hidden = YES this.userInteractionEnabled = YES this.backgroundColor = UIColor.whiteColor this.setBorder(0, 1) this.style.boxSizing = "content-box" this.tableView = new UITableView(elementID ? elementID + "TableView" : undefined) this.addSubview(this.tableView) this.tableView.allRowsHaveEqualHeight = YES this.tableView.numberOfRows = () => this._filteredItems.length this.tableView.heightForRowWithIndex = () => this._rowHeight this.tableView.newReusableViewForIdentifier = (identifier, rowIndex) => this.newRowView(identifier, rowIndex) this.tableView.viewForRowWithIndex = (index) => this.viewForRowWithIndex(index) // A transparent full-height view so the native scrollbar reflects the total // content height rather than just the virtualised visible rows. this._fullHeightView = new UIView(elementID ? elementID + "FullHeightView" : undefined) this._fullHeightView.userInteractionEnabled = NO this.tableView.addSubview(this._fullHeightView) // Use a native mousemove listener on the tableView element so we catch movement // regardless of which child row the pointer is over (framework events don't bubble // up through the scroll container from its row children). this.tableView.viewHTMLElement.addEventListener("mousemove", () => { this._suppressHoverHighlight = NO }) } /** Override in subclass to provide custom row views. */ newRowView(identifier: string, rowIndex: number): UIAutocompleteRowView<T> { return new UIAutocompleteRowView<T>(this.elementID + identifier + rowIndex) } viewForRowWithIndex(index: number): UIView { const row = this.tableView.reusableViewForIdentifier( "AutocompleteRow", index ) as UIAutocompleteRowView<T> const item = this._filteredItems[index] if (IS(item)) { row.item = item } row.filterWords = this._filterWords // Reflect current keyboard highlight state via the native selected flag. row.selected = (index === this._highlightedRowIndex) // PointerHover fires as the pointer moves over the row. // We suppress scroll-into-view since the user is already looking at the row. // We also suppress highlight changes after a keyboard-triggered scroll, until // the pointer actually moves (PointerMove clears the suppression flag). const rowWasHovered = () => { if (this._suppressHoverHighlight) { return } this._setHighlightedRowIndex(index, NO) } if ((row as any)._autocompleteHoverHandler) { row.removeTargetForControlEvent( UIView.controlEvent.PointerHover, (row as any)._autocompleteHoverHandler ) } row.controlEventTargetAccumulator.PointerHover = rowWasHovered; (row as any)._autocompleteHoverHandler = rowWasHovered // Clicking a row selects it. const rowWasTapped = () => { if (IS(item) && this.didSelectItem) { this.didSelectItem(item) } } if ((row as any)._autocompleteTapHandler) { row.removeTargetForControlEvent( UIView.controlEvent.PointerUpInside, (row as any)._autocompleteTapHandler ) } row.controlEventTargetAccumulator.PointerUpInside = rowWasTapped; (row as any)._autocompleteTapHandler = rowWasTapped return row } get highlightedRowIndex(): number { return this._highlightedRowIndex } set highlightedRowIndex(index: number) { this._setHighlightedRowIndex(index, YES) } /** Internal setter. scrollIntoView=YES for keyboard navigation, NO for pointer hover. */ _setHighlightedRowIndex(index: number, scrollIntoView: boolean) { const previousIndex = this._highlightedRowIndex this._highlightedRowIndex = index // Clear selected state on previous row. const previousRow = this.tableView.visibleRowWithIndex(previousIndex) as UIAutocompleteRowView<T> | undefined if (IS(previousRow)) { previousRow.selected = NO } // Set selected state on newly highlighted row. const currentRow = this.tableView.visibleRowWithIndex(index) as UIAutocompleteRowView<T> | undefined if (IS(currentRow)) { currentRow.selected = YES if (scrollIntoView) { // Scroll the view if needed let contentOffset = this.tableView.contentOffset if (currentRow.frame.y < contentOffset.y) { contentOffset.y = currentRow.frame.y } if (currentRow.frame.max.y > (contentOffset.y + this.tableView.bounds.height)) { contentOffset = contentOffset.pointByAddingY(-(contentOffset.y + this.tableView.bounds.height - currentRow.frame.max.y)) } const animationDuration = this.tableView.animationDuration this.tableView.animationDuration = 0 this.tableView.contentOffset = contentOffset this.tableView.animationDuration = animationDuration // Suppress hover-driven highlight changes until the user physically // moves the mouse — the native mousemove listener on the tableView // element clears this flag when actual movement is detected. this._suppressHoverHighlight = YES } } } get highlightedItem(): UIAutocompleteItem<T> | undefined { if (this._highlightedRowIndex >= 0 && this._highlightedRowIndex < this._filteredItems.length) { return this._filteredItems[this._highlightedRowIndex] } return undefined } set filteredItems(items: UIAutocompleteItem<T>[]) { this._filteredItems = items this._highlightedRowIndex = -1 this.tableView.reloadData() this.hidden = (items.length === 0) this._updateFullHeightView() this.setNeedsLayout() } get filteredItems(): UIAutocompleteItem<T>[] { return this._filteredItems } set filterWords(words: string[]) { this._filterWords = words this.tableView.reloadData() } get filterWords(): string[] { return this._filterWords } /** Anchors this dropdown below the given field view inside the rootView. */ showAnchoredToView(anchorView: UIView) { this.anchorView = anchorView this.calculateAndSetViewFrame = () => { const rootView = anchorView.rootView const padding = anchorView.core.paddingLength if (!this.superview || this.superview !== rootView) { this.removeFromSuperview() rootView.addSubview(this) } const fieldFrameInRoot = (this.anchorView?.superview?.rectangleInView( this.anchorView?.frame, rootView ) as UIRectangle) .rectangleByAddingX(padding) .rectangleByAddingY(padding) if (IS_NOT(fieldFrameInRoot)) { return } const dropdownHeight = Math.min( this._filteredItems.length * this._rowHeight, this._maxVisibleRows * this._rowHeight ) this.frame = fieldFrameInRoot.rectangleForNextRow(0, dropdownHeight) } this.setNeedsLayoutUpToRootView() this.calculateAndSetViewFrame() this.style.zIndex = "10000" this.hidden = (this._filteredItems.length === 0) } dismiss() { this.hidden = YES this._highlightedRowIndex = -1 this._isPointerInsideDropdown = NO } _updateFullHeightView() { const totalHeight = this._filteredItems.length * this._rowHeight this._fullHeightView.frame = this._fullHeightView.frame .rectangleWithY(0) .rectangleWithHeight(totalHeight) .rectangleWithWidth(1) this._fullHeightView.hasWeakFrame = YES } override layoutSubviews() { super.layoutSubviews() const bounds = this.contentBounds this.tableView.frame = bounds this._updateFullHeightView() } }