import * as React from "react"; import styled, { createGlobalStyle } from "styled-components"; import { Alignment, Button, Classes, MenuItem } from "@blueprintjs/core"; import type { ItemListRenderer, ItemRenderer } from "@blueprintjs/select"; import { Select } from "@blueprintjs/select"; import type { GridListProps, VirtuosoGridHandle } from "react-virtuoso"; import { VirtuosoGrid } from "react-virtuoso"; import type { ControlProps } from "./BaseControl"; import BaseControl from "./BaseControl"; import { replayHighlightClass } from "globalStyles/portals"; import _ from "lodash"; import { generateReactKey } from "utils/generators"; import { emitInteractionAnalyticsEvent } from "utils/AppsmithUtils"; import { Tooltip } from "@appsmith/ads"; import { ICONS, Icon } from "@appsmith/wds"; import type { IconProps } from "@appsmith/wds"; const IconSelectContainerStyles = createGlobalStyle<{ targetWidth: number | undefined; id: string; }>` ${({ id, targetWidth }) => ` .icon-select-popover-${id} { width: ${targetWidth}px; background: white; .bp3-input-group { margin: 5px !important; } } .bp3-button-text { color: var(--ads-v2-color-fg) !important; } .bp3-icon { color: var(--ads-v2-color-fg) !important; } `} `; const StyledButton = styled(Button)` box-shadow: none !important; border: 1px solid var(--ads-v2-color-border); border-radius: var(--ads-v2-border-radius); height: 36px; background-color: #ffffff !important; > span.bp3-icon-caret-down { color: rgb(169, 167, 167); } &:hover { border: 1px solid var(--ads-v2-color-border-emphasis); } &:focus-visible { outline: var(--ads-v2-border-width-outline) solid var(--ads-v2-color-outline); outline-offset: var(--ads-v2-offset-outline); } > span.bp3-button-text { display: flex; align-items: center; gap: 0.5rem; } `; const StyledMenu = styled.ul` display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: minmax(50px, auto); gap: 8px; max-height: 170px !important; padding-left: 5px !important; padding-right: 5px !important; & li { list-style: none; } `; const StyledMenuItem = styled(MenuItem)` flex-direction: column; align-items: center; padding: 13px 5px; display: flex; align-items: center; &:active, &.bp3-active { background-color: var(--ads-v2-color-bg-muted) !important; border-radius: var(--ads-v2-border-radius) !important; } &:hover { background-color: var(--ads-v2-color-bg-subtle) !important; border-radius: var(--ads-v2-border-radius) !important; } > span.bp3-icon { margin-right: 0; color: var(--ads-v2-color-fg) !important; } > div { width: 100%; text-align: center; color: var(--ads-v2-color-fg) !important; display: flex; align-items: center; justify-content: center; } `; export interface IconSelectControlV2Props extends ControlProps { propertyValue?: IconType; defaultIconName?: IconType; hideNoneIcon?: boolean; } export interface IconSelectControlState { activeIcon: IconType; isOpen: boolean; } const NONE = "(none)"; const EMPTY = ""; type IconType = Required["name"] | typeof NONE | typeof EMPTY; // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any const ICON_NAMES = Object.keys(ICONS) as any as IconType[]; const icons = new Set(ICON_NAMES); const TypedSelect = Select.ofType(); class IconSelectControlV2 extends BaseControl< IconSelectControlV2Props, IconSelectControlState > { private iconSelectTargetRef: React.RefObject; private virtuosoRef: React.RefObject; private initialItemIndex: number; private filteredItems: Array; private searchInput: React.RefObject; id: string = generateReactKey(); constructor(props: IconSelectControlV2Props) { super(props); this.iconSelectTargetRef = React.createRef(); this.virtuosoRef = React.createRef(); this.searchInput = React.createRef(); this.initialItemIndex = 0; this.filteredItems = []; /** * Multiple instances of the IconSelectControl class may be created, * and each instance modifies the ICON_NAMES array and the icons set. * Without the below logic, the NONE icon may be added or removed * multiple times, leading to unexpected behaviour. */ const noneIconExists = icons.has(NONE); if (!props.hideNoneIcon && !noneIconExists) { ICON_NAMES.unshift(NONE); icons.add(NONE); } else if (props.hideNoneIcon && noneIconExists) { ICON_NAMES.shift(); icons.delete(NONE); } this.state = { activeIcon: props.propertyValue ?? NONE, isOpen: false, }; } // debouncedSetState is used to fix the following bug: // https://github.com/appsmithorg/appsmith/pull/10460#issuecomment-1022895174 private debouncedSetState = _.debounce( // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, callback?: () => void) => { this.setState((prevState: IconSelectControlState) => { return { ...prevState, ...obj, }; }, callback); }, 300, { leading: true, trailing: false, }, ); componentDidMount() { // keydown event is attached to body so that it will not interfere with the keydown handler in GlobalHotKeys document.body.addEventListener("keydown", this.handleKeydown); } componentWillUnmount() { document.body.removeEventListener("keydown", this.handleKeydown); } private handleQueryChange = _.debounce(() => { if (this.filteredItems.length === 2) this.setState({ activeIcon: this.filteredItems[1] }); }, 50); public render() { const { defaultIconName, propertyValue: iconName } = this.props; const { activeIcon } = this.state; const containerWidth = this.iconSelectTargetRef.current?.getBoundingClientRect?.()?.width || 0; return ( <> { if (this.state.isOpen !== state) this.debouncedSetState({ isOpen: state }); }, }} > {iconName !== "" && iconName !== NONE && iconName !== undefined && iconName !== null && } {iconName || defaultIconName || NONE} ); } private setActiveIcon(iconIndex: number) { this.setState( { activeIcon: this.filteredItems[iconIndex], }, () => { if (this.virtuosoRef.current) { this.virtuosoRef.current.scrollToIndex(iconIndex); } }, ); } private handleKeydown = (e: KeyboardEvent) => { if (this.state.isOpen) { switch (e.key) { case "Tab": e.preventDefault(); this.setState({ isOpen: false, activeIcon: this.props.propertyValue ?? NONE, }); break; case "ArrowDown": case "Down": { emitInteractionAnalyticsEvent(this.iconSelectTargetRef.current, { key: e.key, }); if (document.activeElement === this.searchInput.current) { (document.activeElement as HTMLElement).blur(); if (this.initialItemIndex < 0) this.initialItemIndex = -4; else break; } const nextIndex = this.initialItemIndex + 4; if (nextIndex < this.filteredItems.length) this.setActiveIcon(nextIndex); e.preventDefault(); break; } case "ArrowUp": case "Up": { if (document.activeElement === this.searchInput.current) { break; } else if ( (e.shiftKey || (this.initialItemIndex >= 0 && this.initialItemIndex < 4)) && this.searchInput.current ) { emitInteractionAnalyticsEvent(this.iconSelectTargetRef.current, { key: e.key, }); this.searchInput.current.focus(); break; } emitInteractionAnalyticsEvent(this.iconSelectTargetRef.current, { key: e.key, }); const nextIndex = this.initialItemIndex - 4; if (nextIndex >= 0) this.setActiveIcon(nextIndex); e.preventDefault(); break; } case "ArrowRight": case "Right": { if (document.activeElement === this.searchInput.current) { break; } emitInteractionAnalyticsEvent(this.iconSelectTargetRef.current, { key: e.key, }); const nextIndex = this.initialItemIndex + 1; if (nextIndex < this.filteredItems.length) this.setActiveIcon(nextIndex); e.preventDefault(); break; } case "ArrowLeft": case "Left": { if (document.activeElement === this.searchInput.current) { break; } emitInteractionAnalyticsEvent(this.iconSelectTargetRef.current, { key: e.key, }); const nextIndex = this.initialItemIndex - 1; if (nextIndex >= 0) this.setActiveIcon(nextIndex); e.preventDefault(); break; } case " ": case "Enter": { if ( this.searchInput.current === document.activeElement && this.filteredItems.length !== 2 ) break; emitInteractionAnalyticsEvent(this.iconSelectTargetRef.current, { key: e.key, }); this.handleIconChange( this.filteredItems[this.initialItemIndex], true, ); this.debouncedSetState({ isOpen: false }); e.preventDefault(); e.stopPropagation(); break; } case "Escape": { emitInteractionAnalyticsEvent(this.iconSelectTargetRef.current, { key: e.key, }); this.setState({ isOpen: false, activeIcon: this.props.propertyValue ?? NONE, }); e.stopPropagation(); } } } else if (this.iconSelectTargetRef.current === document.activeElement) { switch (e.key) { case "ArrowUp": case "Up": case "ArrowDown": case "Down": this.debouncedSetState({ isOpen: true }, this.handleButtonClick); break; case "Tab": emitInteractionAnalyticsEvent(this.iconSelectTargetRef.current, { key: `${e.shiftKey ? "Shift+" : ""}${e.key}`, }); break; } } }; private handleButtonClick = () => { setTimeout(() => { if (this.virtuosoRef.current) { this.virtuosoRef.current.scrollToIndex(this.initialItemIndex); } }, 0); }; private renderMenu: ItemListRenderer = ({ activeItem, filteredItems, renderItem, }) => { this.filteredItems = filteredItems; this.initialItemIndex = filteredItems.findIndex((x) => x === activeItem); return ( filteredItems[index]} initialItemCount={16} itemContent={(index) => renderItem(filteredItems[index], index)} ref={this.virtuosoRef} style={{ height: "165px" }} tabIndex={-1} totalCount={filteredItems.length} /> ); }; private renderIconItem: ItemRenderer = ( icon, { handleClick, modifiers }, ) => { if (!modifiers.matchesPredicate) { return null; } return ( } textClassName={icon === NONE ? "bp3-icon-(none)" : ""} /> ); }; private filterIconName = (query: string, iconName: IconType) => { if (iconName === NONE || query === "") { return true; } return (iconName || "").toLowerCase().indexOf(query.toLowerCase()) >= 0; }; private handleIconChange = (icon: IconType, isUpdatedViaKeyboard = false) => { this.setState({ activeIcon: icon }); this.updateProperty( this.props.propertyName, icon === NONE ? undefined : icon, isUpdatedViaKeyboard, ); }; private handleItemSelect = (icon: IconType) => { this.handleIconChange(icon, false); }; static getControlType() { return "ICON_SELECT_V2"; } static canDisplayValueInUI( config: IconSelectControlV2Props, // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any, ): boolean { if (icons.has(value)) return true; return false; } } export default IconSelectControlV2;