PromucFlow_constructor/app/client/src/components/propertyControls/IconSelectControlV2.tsx
albinAppsmith 4c278803cd
fix: Revert "Revert "feat: Added focus ring for focus visible (#37700)" (#… (#38655)
…38650)"

This reverts commit e1b3b0df00.

## Description
> [!TIP]  
> _Add a TL;DR when the description is longer than 500 words or
extremely technical (helps the content, marketing, and DevRel team)._
>
> _Please also include relevant motivation and context. List any
dependencies that are required for this change. Add links to Notion,
Figma or any other documents that might be relevant to the PR._


Fixes #`Issue Number`  
_or_  
Fixes `Issue URL`
> [!WARNING]  
> _If no issue exists, please create an issue first, and check with the
maintainers if the issue is valid._

## Automation

/ok-to-test tags="@tag.All"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/13106069580>
> Commit: 7f7dcd2f14650de40864e2d80f02a1528d7562bc
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13106069580&attempt=2"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.All`
> Spec:
> <hr>Mon, 03 Feb 2025 05:27:48 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Style Updates**
	- Enhanced focus visibility across multiple design system components.
	- Updated focus styles to use `outline` with `!important` flag.
	- Improved focus ring and outline handling for better accessibility.

- **Design System Refinements**
- Modified focus handling in buttons, inputs, links, and other
interactive elements.
	- Standardized focus state styling across components.
	- Introduced more consistent visual feedback for keyboard navigation.

- **CSS Improvements**
	- Removed outline-disabling styles from global CSS.
	- Added more precise focus indication mechanisms.
	- Adjusted padding and outline properties for various components.
- Simplified styling and structure of the `PropertyPaneSearchInput`
component.
- Introduced new focus styles for the select component to enhance visual
distinction.
- Enhanced the focus state for the `ColorPickerComponent` and
`IconSelectControl` components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-02-03 11:12:29 +05:30

510 lines
14 KiB
TypeScript

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<GridListProps>`
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<IconProps>["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<IconType>();
class IconSelectControlV2 extends BaseControl<
IconSelectControlV2Props,
IconSelectControlState
> {
private iconSelectTargetRef: React.RefObject<HTMLButtonElement>;
private virtuosoRef: React.RefObject<VirtuosoGridHandle>;
private initialItemIndex: number;
private filteredItems: Array<IconType>;
private searchInput: React.RefObject<HTMLInputElement>;
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 (
<>
<IconSelectContainerStyles id={this.id} targetWidth={containerWidth} />
<TypedSelect
activeItem={activeIcon || defaultIconName || NONE}
className="icon-select-container"
inputProps={{
inputRef: this.searchInput,
}}
itemListRenderer={this.renderMenu}
itemPredicate={this.filterIconName}
itemRenderer={this.renderIconItem}
items={ICON_NAMES}
onItemSelect={this.handleItemSelect}
onQueryChange={this.handleQueryChange}
popoverProps={{
enforceFocus: false,
minimal: true,
isOpen: this.state.isOpen,
popoverClassName: `icon-select-popover icon-select-popover-${this.id}`,
onInteraction: (state) => {
if (this.state.isOpen !== state)
this.debouncedSetState({ isOpen: state });
},
}}
>
<StyledButton
alignText={Alignment.LEFT}
className={
Classes.TEXT_OVERFLOW_ELLIPSIS + " " + replayHighlightClass
}
elementRef={this.iconSelectTargetRef}
fill
onClick={this.handleButtonClick}
rightIcon="caret-down"
tabIndex={0}
>
<span>
{iconName !== "" &&
iconName !== NONE &&
iconName !== undefined &&
iconName !== null && <Icon name={iconName} />}
</span>
<span>{iconName || defaultIconName || NONE}</span>
</StyledButton>
</TypedSelect>
</>
);
}
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<IconType> = ({
activeItem,
filteredItems,
renderItem,
}) => {
this.filteredItems = filteredItems;
this.initialItemIndex = filteredItems.findIndex((x) => x === activeItem);
return (
<VirtuosoGrid
components={{
List: StyledMenu,
}}
computeItemKey={(index) => filteredItems[index]}
initialItemCount={16}
itemContent={(index) => renderItem(filteredItems[index], index)}
ref={this.virtuosoRef}
style={{ height: "165px" }}
tabIndex={-1}
totalCount={filteredItems.length}
/>
);
};
private renderIconItem: ItemRenderer<IconType> = (
icon,
{ handleClick, modifiers },
) => {
if (!modifiers.matchesPredicate) {
return null;
}
return (
<Tooltip content={icon} mouseEnterDelay={0}>
<StyledMenuItem
active={modifiers.active}
key={icon}
onClick={handleClick}
text={icon === NONE || icon === EMPTY ? NONE : <Icon name={icon} />}
textClassName={icon === NONE ? "bp3-icon-(none)" : ""}
/>
</Tooltip>
);
};
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;