/**@jsxImportSource react */ import { Instance } from "../ui/Instance"; import type { RenderingContext } from "../ui/RenderingContext"; import { getAccessor } from "../data/getAccessor"; import { GroupAdapter, GroupingConfig } from "../ui/adapter/GroupAdapter"; import { batchUpdates } from "../ui/batchUpdates"; import { Container, StyledContainerBase, StyledContainerConfig } from "../ui/Container"; import { FocusManager, offFocusOut, oneFocusOut, preventFocusOnTouch } from "../ui/FocusManager"; import { Selection } from "../ui/selection/Selection"; import { VDOM, Widget, getContent } from "../ui/Widget"; import { addEventListenerWithOptions } from "../util/addEventListenerWithOptions"; import { isArray } from "../util/isArray"; import { isString } from "../util/isString"; import { KeyCode } from "../util/KeyCode"; import { scrollElementIntoView } from "../util/scrollElementIntoView"; import { BooleanProp, ClassProp, CollatorOptions, Prop, RecordAlias, StringProp, StructuredProp, StyleProp, } from "../ui/Prop"; import { Create } from "../util/Component"; export interface ListConfig extends StyledContainerConfig { /** An array of records to be displayed in the list. */ records?: Prop; /** Record alias. Default is `$record`. */ recordName?: RecordAlias; /** Record alias. Alias for `recordName`. Default is `$record`. */ recordAlias?: RecordAlias; /** Index alias. Default is `$index`. */ indexName?: RecordAlias; /** Index alias. Alias for `indexName`. Default is `$index`. */ indexAlias?: RecordAlias; /** Style to be applied to each list item. */ itemStyle?: StyleProp; /** CSS class to be applied to each list item. */ itemClass?: ClassProp; /** CSS class to be applied to each list item. */ itemClassName?: ClassProp; /** Text to be displayed when the list is empty. */ emptyText?: StringProp; /** A grouping definition, field name, or an array of grouping level definitions. */ grouping?: string | GroupingConfig | (string | GroupingConfig)[]; /** Base CSS class to be applied to the element. Default is 'list'. */ baseClass?: string; /** Set to `true` to allow focus on the list. */ focusable?: BooleanProp; /** Boolean for focus state. */ focused?: BooleanProp; /** Boolean for item padding. */ itemPad?: BooleanProp; /** Set to `true` to enable row caching. */ cached?: BooleanProp; /** Selection configuration. */ selection?: StructuredProp; /** Parameters that affect filtering. */ filterParams?: StructuredProp; /** Callback to create a filter function for given filter params. */ onCreateFilter?: (filterParams: any, instance?: Instance) => (record: T) => boolean; /** Scroll selection into the view. Default value is false. */ scrollSelectionIntoView?: BooleanProp; /** Options for data sorting. */ sortOptions?: CollatorOptions; /** Parameter for disabling specific items. */ itemDisabled?: BooleanProp; /** Automatic selection without cursor navigation. */ selectMode?: BooleanProp; /** Tab key selects item under cursor. */ selectOnTab?: BooleanProp; /** Callback invoked during scrolling. */ onScroll?: string | ((event: Event, instance: Instance) => void); /** Callback for item click. */ onItemClick?: string | ((e: React.MouseEvent, instance: Instance) => void); /** Callback for item double click. */ onItemDoubleClick?: string | ((e: React.MouseEvent, instance: Instance) => void); /** Callback for keyboard down events. */ pipeKeyDown?: string | ((handler: ((e: React.KeyboardEvent) => void) | null, instance: Instance) => void); /** A field used to get the unique identifier of the record. */ keyField?: string; /** Data adapter used to convert data in list of records. Used to enable grouping and tree operations. */ dataAdapter?: Create; } /* - renders list of items - focusable (keyboard navigation) - selection - fake focus - list appears focused and receives keyboard inputs redirected from other control (dropdown scenario) */ export class List extends StyledContainerBase { constructor(config?: ListConfig) { super(config); } declare public recordAlias?: string; declare public recordName: string; declare public indexAlias?: string; declare public indexName: string; declare public adapter: GroupAdapter; declare public child: ListItem; declare public selection: Selection; declare public itemClass?: string; declare public itemClassName?: string; declare public itemStyle?: string; declare public itemDisabled?: boolean; declare public item?: Widget; declare public layout?: any; declare public keyField?: string; declare public records?: any[]; declare public sortOptions?: any; declare public grouping?: string | GroupingConfig | (string | GroupingConfig)[]; declare public focusable?: boolean; declare public focused?: boolean; declare public itemPad?: boolean; declare public cached?: boolean; declare public scrollSelectionIntoView?: boolean; declare public selectMode?: boolean; declare public selectOnTab?: boolean; declare public pipeKeyDown?: | string | ((handler: ((e: React.KeyboardEvent) => void) | null, instance: Instance) => void); declare public autoFocus?: boolean; declare public baseClass: string; declare public filter?: (item: unknown, filterParams: Record) => boolean; declare public onCreateFilter?: ( filterParams: Record, instance: Instance, ) => (record: unknown) => boolean; declare public onItemClick?: (e: React.MouseEvent, instance: Instance) => void; declare public onItemDoubleClick?: (e: React.MouseEvent, instance: Instance) => void; declare public onKeyDown?: (e: React.KeyboardEvent, instance: Instance) => void; declare public onScroll?: (event: Event, instance: Instance) => void; declare public onFocus?: (event: FocusEvent, instance: Instance) => void; declare public onBlur?: (event: FocusEvent, instance: Instance) => void; declare public onMouseLeave?: (event: React.MouseEvent, instance: Instance) => void; declare public onMouseEnter?: (event: React.MouseEvent, instance: Instance) => void; declare public onMouseMove?: (event: React.MouseEvent, instance: Instance) => void; declare public onMouseUp?: (event: React.MouseEvent, instance: Instance) => void; declare public onMouseDown?: (event: React.MouseEvent, instance: Instance) => void; declare public onMouseOver?: (event: React.MouseEvent, instance: Instance) => void; declare public onMouseOut?: (event: React.MouseEvent, instance: Instance) => void; init() { if (this.recordAlias) this.recordName = this.recordAlias; if (this.indexAlias) this.indexName = this.indexAlias; this.adapter = GroupAdapter.create(this.adapter || GroupAdapter, { recordName: this.recordName, indexName: this.indexName, recordsAccessor: getAccessor(this.records), keyField: this.keyField, sortOptions: this.sortOptions, }); this.child = ListItem.create({ layout: this.layout, items: this.items, children: this.children, styled: true, class: this.itemClass, className: this.itemClassName, style: this.itemStyle, disabled: this.itemDisabled, ...this.item, }) as ListItem; delete this.children; this.selection = Selection.create(this.selection, { records: this.records, }); super.init(); if (this.grouping) { this.groupBy(this.grouping); } } initInstance(context: RenderingContext, instance: Instance) { this.adapter.initInstance(context, instance); } declareData() { let selection = this.selection.configureWidget(this); super.declareData( selection, { records: undefined, sorters: undefined, sortField: undefined, sortDirection: undefined, filterParams: { structured: true, }, itemStyle: { structured: true, }, emptyText: undefined, tabIndex: undefined, }, ...arguments, ); } prepareData(context: RenderingContext, instance: Instance) { let { data } = instance; if (data.sortField) data.sorters = [ { field: data.sortField, direction: data.sortDirection || "ASC", }, ]; this.adapter.sort(data.sorters); let filter = null; if (this.onCreateFilter) filter = instance.invoke("onCreateFilter", data.filterParams, instance); else if (this.filter) filter = (item: unknown) => this.filter!(item, data.filterParams); this.adapter.setFilter(filter); instance.mappedRecords = this.adapter.getRecords(context, instance, data.records, instance.store); data.stateMods = Object.assign(data.stateMods || {}, { selectable: !this.selection.isDummy || this.onItemClick, empty: instance.mappedRecords.length == 0, }); super.prepareData(context, instance); } applyParentStore(instance: Instance) { super.applyParentStore(instance); // force prepareData to execute again and propagate the store change to the records if (instance.cached) delete instance.cached.rawData; } explore(context: RenderingContext, instance: Instance): void { let instances: Instance[] = []; let isSelected = this.selection.getIsSelectedDelegate(instance.store); instance.mappedRecords!.forEach((record) => { if (record.type == "data") { let itemInstance = instance.getChild(context, this.child, record.key, record.store); itemInstance.record = record; itemInstance.selected = isSelected(record.data, record.index); let changed = false; if (itemInstance.cache("recordData", record.data)) changed = true; if (itemInstance.cache("selected", itemInstance.selected)) changed = true; if (this.cached && !changed && itemInstance.visible && !itemInstance.childStateDirty) { instances.push(itemInstance); itemInstance.shouldUpdate = false; } else if (itemInstance.scheduleExploreIfVisible(context)) instances.push(itemInstance); } else if (record.type == "group-header" && record.grouping.header) { let itemInstance = instance.getChild(context, record.grouping.header, record.key, record.store); itemInstance.record = record; if (itemInstance.scheduleExploreIfVisible(context)) instances.push(itemInstance); } else if (record.type == "group-footer" && record.grouping.footer) { let itemInstance = instance.getChild(context, record.grouping.footer, record.key, record.store); itemInstance.record = record; if (itemInstance.scheduleExploreIfVisible(context)) instances.push(itemInstance); } }); instance.instances = instances; } render(context: RenderingContext, instance: Instance, key: string): React.ReactNode { let items = instance.instances!.map((x) => ({ instance: x, key: x.record.key, type: x.record.type, content: getContent(x.render(context)), })); return ( ); } groupBy(grouping: string | GroupingConfig | (string | GroupingConfig)[]): void { if (!isArray(grouping)) { if (isString(grouping) || typeof grouping == "object") return this.groupBy([grouping]); throw new Error("DynamicGrouping should be an array of grouping objects"); } let normalized = grouping.map((g) => { if (isString(g)) { return { key: { [g]: { bind: this.recordName + "." + g, }, }, } as GroupingConfig; } // Clone to avoid mutating the original config return { ...g, header: g.header ? Widget.create(g.header) : undefined, footer: g.footer ? Widget.create(g.footer) : undefined, }; }); this.adapter.groupBy(normalized); this.update(); } } List.prototype.recordName = "$record"; List.prototype.indexName = "$index"; List.prototype.baseClass = "list"; List.prototype.focusable = true; List.prototype.focused = false; List.prototype.itemPad = true; List.prototype.cached = false; List.prototype.styled = true; List.prototype.scrollSelectionIntoView = false; List.prototype.selectMode = false; List.prototype.selectOnTab = false; Widget.alias("list", List); interface ListItemData { instance: Instance; key: string; type: string; content: React.ReactNode; } interface ListComponentProps { instance: Instance; items: ListItemData[]; selectable: boolean | ((e: MouseEvent, instance: Instance) => void); } interface ListComponentState { cursor: number; focused: boolean; selectionStart?: number; } class ListComponent extends VDOM.Component { declare el?: HTMLUListElement; cursorChildIndex: number[] = []; declare selectedEl?: Element | null; unsubscribeScroll?: () => void; onKeyDown?: (e: React.KeyboardEvent, instance: Instance) => boolean | void; constructor(props: ListComponentProps) { super(props); let { widget } = props.instance; let { focused } = widget as unknown as { focused: boolean }; this.state = { cursor: focused && props.selectable ? 0 : -1, focused: focused, }; this.handleItemMouseDown = this.handleItemMouseDown.bind(this); this.handleItemDoubleClick = this.handleItemDoubleClick.bind(this); this.handleItemClick = this.handleItemClick.bind(this); } shouldComponentUpdate(props: ListComponentProps, state: ListComponentState): boolean { return props.instance.shouldUpdate || state != this.state; } componentDidMount(): void { let { instance } = this.props; let { widget } = instance as unknown as { widget: List }; if (widget.pipeKeyDown) { instance.invoke("pipeKeyDown", this.handleKeyDown.bind(this), instance); this.showCursor(); } if (widget.autoFocus && this.el) FocusManager.focus(this.el); if (widget.onScroll && this.el) { this.unsubscribeScroll = addEventListenerWithOptions( this.el, "scroll", (event) => { instance.invoke("onScroll", event, instance); }, { passive: true }, ); } this.componentDidUpdate(); } UNSAFE_componentWillReceiveProps(props: ListComponentProps): void { if (this.state.focused && (props.instance.widget as List).selectMode) this.showCursor(true, props.items); else if (this.state.cursor >= props.items.length) this.moveCursor(props.items.length - 1); else if (this.state.focused && this.state.cursor < 0) this.moveCursor(0); } componentWillUnmount(): void { let { instance } = this.props; let { widget } = instance as unknown as { widget: List }; offFocusOut(this); if (widget.pipeKeyDown) instance.invoke("pipeKeyDown", null, instance); } handleItemMouseDown(e: React.MouseEvent): void { let index = Number((e.currentTarget as HTMLElement).dataset.recordIndex); this.moveCursor(index); if (e.shiftKey) e.preventDefault(); this.moveCursor(index, { select: true, selectOptions: { toggle: e.ctrlKey && !e.shiftKey, add: e.ctrlKey && e.shiftKey, }, selectRange: e.shiftKey, }); } handleItemClick(e: React.MouseEvent): void { let { instance, items } = this.props; let index = Number((e.currentTarget as HTMLElement).dataset.recordIndex); let item = items[this.cursorChildIndex[index]]; if (instance.invoke("onItemClick", e, item.instance) === false) return; this.moveCursor(index, { select: true, selectOptions: { toggle: e.ctrlKey && !e.shiftKey, add: e.ctrlKey && e.shiftKey, }, selectRange: e.shiftKey, }); } handleItemDoubleClick(e: React.MouseEvent): void { let { instance, items } = this.props; let index = Number((e.currentTarget as HTMLElement).dataset.recordIndex); let item = items[this.cursorChildIndex[index]]; instance.invoke("onItemDoubleClick", e, item.instance); } render(): React.ReactNode { let { instance, items, selectable } = this.props; let { data, widget } = instance as unknown as { data: Record; widget: List }; let { CSS, baseClass } = widget; let itemStyle = CSS.parseStyle(data.itemStyle); this.cursorChildIndex = []; let cursorIndex = 0; let onDblClick: ((e: React.MouseEvent) => void) | undefined; let onClick: ((e: React.MouseEvent) => void) | undefined; if (widget.onItemClick) onClick = this.handleItemClick; if (widget.onItemDoubleClick) onDblClick = this.handleItemDoubleClick; let children: React.ReactNode = items.length > 0 && items.map((x, i) => { let { data, selected } = x.instance as { data: any; selected?: boolean }; let className; if (x.type == "data") { let ind = cursorIndex++; this.cursorChildIndex.push(i); className = CSS.element(baseClass, "item", { selected: selected, cursor: ind == this.state.cursor, pad: widget.itemPad, disabled: data.disabled, }); return (
  • {x.content}
  • ); } else { return (
  • {x.content}
  • ); } }); if (!children && data.emptyText) { children =
  • {data.emptyText}
  • ; } return (
      { this.el = el as HTMLUListElement; }} className={CSS.expand(data.classNames, CSS.state({ focused: this.state.focused }))} style={data.style} tabIndex={widget.focusable && selectable && items.length > 0 ? data.tabIndex || 0 : undefined} onMouseDown={preventFocusOnTouch} onKeyDown={this.handleKeyDown.bind(this)} onMouseLeave={this.handleMouseLeave.bind(this)} onFocus={this.onFocus.bind(this)} onBlur={this.onBlur.bind(this)} > {children}
    ); } componentDidUpdate(): void { let { widget } = this.props.instance as unknown as { widget: List }; if (widget.scrollSelectionIntoView) { //The timeout is reqired for use-cases when parent needs to do some measuring that affect scrollbars, i.e. LookupField. setTimeout(() => this.scrollElementIntoView(), 0); } } scrollElementIntoView(): void { if (!this.el) return; //unmount let { widget } = this.props.instance as unknown as { widget: List }; let { CSS, baseClass } = widget; let selectedRowSelector = `.${CSS.element(baseClass, "item")}.${CSS.state("selected")}`; let firstSelectedRow = this.el.querySelector(selectedRowSelector); if (firstSelectedRow != this.selectedEl) { if (firstSelectedRow) scrollElementIntoView(firstSelectedRow, true, false, 0, this.el); this.selectedEl = firstSelectedRow; } } moveCursor( index: number, { focused, hover, scrollIntoView, select, selectRange, selectOptions, }: { focused?: boolean; hover?: boolean; scrollIntoView?: boolean; select?: boolean; selectRange?: boolean; selectOptions?: { toggle?: boolean; add?: boolean }; } = {}, ): void { let { instance, selectable } = this.props; if (!selectable) return; let { widget } = instance as unknown as { widget: List }; let newState: Partial = {}; if (widget.focused) focused = true; if (focused != null && this.state.focused != focused) newState.focused = focused; //ignore mouse enter/leave events (support with a flag if a feature request comes) if (!hover) newState.cursor = index; //batch updates to avoid flickering between selection and cursor changes batchUpdates(() => { if (select || widget.selectMode) { let start: number | undefined = selectRange && this.state.selectionStart !== undefined && this.state.selectionStart >= 0 ? this.state.selectionStart : index; if (start < 0) start = index; this.selectRange(start, index, selectOptions); if (!selectRange) newState.selectionStart = index; } if (Object.keys(newState).length > 0) { this.setState(newState as ListComponentState, () => { if (scrollIntoView && this.el) { let item = this.el.children[this.cursorChildIndex[index]]; if (item) scrollElementIntoView(item); } }); } }); } selectRange(from: number, to: number, options?: { toggle?: boolean; add?: boolean }): void { let { instance, items } = this.props; let { widget } = instance as unknown as { widget: List }; if (from > to) { let tmp = from; from = to; to = tmp; } let selection: any[] = [], indexes: number[] = []; for (let cursor = from; cursor <= to; cursor++) { let item = items[this.cursorChildIndex[cursor]]; if (item) { let { record, data } = item.instance as { record: any; data: any }; if (data.disabled) continue; selection.push(record.data); indexes.push(record.index); } } widget.selection.selectMultiple(instance.store, selection, indexes, options); } showCursor(force?: boolean, newItems?: ListItemData[]): void { if (!force && this.state.cursor >= 0) return; let items = newItems || this.props.items; let index = -1, firstSelected = -1, firstValid = -1; for (let i = 0; i < items.length; i++) { let item = items[i]; if (isDataItem(item)) { index++; if (!isItemDisabled(item) && firstValid == -1) firstValid = index; if ((item.instance as any).selected) { firstSelected = index; break; } } } this.moveCursor(firstSelected != -1 ? firstSelected : firstValid, { focused: true, }); } onFocus(): void { let { widget } = this.props.instance as unknown as { widget: List }; FocusManager.nudge(); this.showCursor(widget.selectMode); if (!widget.focused && this.el) oneFocusOut(this, this.el, () => { this.moveCursor(-1, { focused: false }); }); this.setState({ focused: true, }); } onBlur(): void { FocusManager.nudge(); } handleMouseLeave(): void { let { widget } = this.props.instance as unknown as { widget: List }; if (!widget.focused) this.moveCursor(-1, { hover: true }); } handleKeyDown(e: React.KeyboardEvent): void { let { instance, items } = this.props; let { widget } = instance as unknown as { widget: List }; if (this.onKeyDown && instance.invoke("onKeyDown", e, instance) === false) return; switch (e.keyCode) { case KeyCode.tab: case KeyCode.enter: if (!widget.selectOnTab && e.keyCode == KeyCode.tab) break; let item = items[this.cursorChildIndex[this.state.cursor]]; if (item && widget.onItemClick && instance.invoke("onItemClick", e, item.instance) === false) return; this.moveCursor(this.state.cursor, { select: true, selectOptions: { toggle: e.ctrlKey && !e.shiftKey, add: e.ctrlKey && e.shiftKey, }, selectRange: e.shiftKey, }); break; case KeyCode.down: for (let index = this.state.cursor + 1; index < this.cursorChildIndex.length; index++) { let item = items[this.cursorChildIndex[index]]; if (!isItemSelectable(item)) continue; this.moveCursor(index, { focused: true, scrollIntoView: true, select: e.shiftKey, selectRange: e.shiftKey, }); e.stopPropagation(); e.preventDefault(); break; } break; case KeyCode.up: for (let index = this.state.cursor - 1; index >= 0; index--) { let item = items[this.cursorChildIndex[index]]; if (!isItemSelectable(item)) continue; this.moveCursor(index, { focused: true, scrollIntoView: true, select: e.shiftKey, selectRange: e.shiftKey, }); e.stopPropagation(); e.preventDefault(); break; } break; case KeyCode.a: if (!e.ctrlKey || !widget.selection.multiple) return; this.selectRange(0, this.cursorChildIndex.length); e.stopPropagation(); e.preventDefault(); break; } } } class ListItem extends Container { declareData(...args: Record[]) { super.declareData(...args, { disabled: undefined, }); } } function isItemSelectable(item: any) { return isDataItem(item) && !isItemDisabled(item); } function isDataItem(item: any) { return item?.type == "data"; } function isItemDisabled(item: any) { return item?.instance.data.disabled; }