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
text/typescript
// 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()
}
}